Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
524 lines
16 KiB
TypeScript
524 lines
16 KiB
TypeScript
import type { JSONContent } from '@tiptap/core'
|
|
import {
|
|
createAtomBlockMarkdownSpec,
|
|
createBlockMarkdownSpec,
|
|
createInlineMarkdownSpec,
|
|
} from '@tiptap/core'
|
|
import { describe, expect, it } from 'vite-plus/test'
|
|
|
|
function measure(run: () => unknown): number {
|
|
const startedAt = performance.now()
|
|
run()
|
|
return performance.now() - startedAt
|
|
}
|
|
|
|
function expectLinearTokenization(run: (attributes: string) => unknown, attackUnit: string) {
|
|
run('warmup="true"')
|
|
const sizes = [768, 1536]
|
|
|
|
sizes.forEach(size => {
|
|
const attack = attackUnit.repeat(size)
|
|
const control = '-'.repeat(attack.length)
|
|
const controlDuration = measure(() => run(control))
|
|
const attackDuration = measure(() => run(attack))
|
|
|
|
expect(attackDuration).toBeLessThan(controlDuration * 20 + 100)
|
|
})
|
|
}
|
|
|
|
describe('Markdown Utilities', () => {
|
|
describe('createInlineMarkdownSpec', () => {
|
|
it('should create a valid spec for self-closing inline nodes', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'mention',
|
|
selfClosing: true,
|
|
allowedAttributes: ['id', 'label'],
|
|
})
|
|
|
|
expect(spec).toHaveProperty('parseMarkdown')
|
|
expect(spec).toHaveProperty('markdownTokenizer')
|
|
expect(spec).toHaveProperty('renderMarkdown')
|
|
expect(spec.markdownTokenizer.level).toBe('inline')
|
|
})
|
|
|
|
it('should create a valid spec for inline nodes with content', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'highlight',
|
|
selfClosing: false,
|
|
allowedAttributes: ['color'],
|
|
})
|
|
|
|
expect(spec).toHaveProperty('parseMarkdown')
|
|
expect(spec).toHaveProperty('markdownTokenizer')
|
|
expect(spec).toHaveProperty('renderMarkdown')
|
|
expect(spec.markdownTokenizer.level).toBe('inline')
|
|
})
|
|
|
|
it('should handle tokenizer start detection', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'mention',
|
|
selfClosing: true,
|
|
})
|
|
|
|
const src = '[mention id="test"] some other text'
|
|
const startIndex =
|
|
typeof spec.markdownTokenizer.start === 'function'
|
|
? spec.markdownTokenizer.start?.(src)
|
|
: src.indexOf(spec.markdownTokenizer.start!)
|
|
|
|
expect(startIndex).toBe(0)
|
|
|
|
const falseSrc = 'no mention here'
|
|
const noMatch =
|
|
typeof spec.markdownTokenizer.start === 'function'
|
|
? spec.markdownTokenizer.start?.(falseSrc)
|
|
: falseSrc.indexOf(spec.markdownTokenizer.start!)
|
|
|
|
expect(noMatch).toBe(-1)
|
|
})
|
|
|
|
it('should tokenize self-closing shortcodes correctly', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'mention',
|
|
selfClosing: true,
|
|
})
|
|
|
|
const token = spec.markdownTokenizer.tokenize(
|
|
'[mention id="test" label="Test User"]',
|
|
[],
|
|
null as any,
|
|
)
|
|
expect(token).toMatchObject({
|
|
type: 'mention',
|
|
raw: '[mention id="test" label="Test User"]',
|
|
content: '',
|
|
attributes: {
|
|
id: 'test',
|
|
label: 'Test User',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should tokenize shortcodes with content correctly', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'highlight',
|
|
selfClosing: false,
|
|
})
|
|
|
|
const token = spec.markdownTokenizer.tokenize(
|
|
'[highlight color="yellow"]highlighted text[/highlight]',
|
|
[],
|
|
null as any,
|
|
)
|
|
expect(token).toMatchObject({
|
|
type: 'highlight',
|
|
raw: '[highlight color="yellow"]highlighted text[/highlight]',
|
|
content: 'highlighted text',
|
|
attributes: {
|
|
color: 'yellow',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should parse malformed shortcode attributes in linear time', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'probe',
|
|
selfClosing: true,
|
|
})
|
|
|
|
expectLinearTokenization(
|
|
attributes => spec.markdownTokenizer.tokenize(`[probe ${attributes}]`, [], null as any),
|
|
'0000000000000000',
|
|
)
|
|
})
|
|
|
|
it('should only parse shortcode attributes at attribute boundaries', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'probe',
|
|
selfClosing: true,
|
|
})
|
|
|
|
const token = spec.markdownTokenizer.tokenize(
|
|
'[probe prefix:id="ignored" label="kept"]',
|
|
[],
|
|
null as any,
|
|
)
|
|
|
|
expect(token).toMatchObject({ attributes: { label: 'kept' } })
|
|
})
|
|
|
|
it('should render self-closing shortcodes correctly', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'mention',
|
|
selfClosing: true,
|
|
allowedAttributes: ['id', 'label'],
|
|
})
|
|
|
|
const node = {
|
|
attrs: {
|
|
id: 'test',
|
|
label: 'Test User',
|
|
internalId: 'should-be-filtered', // This should not appear
|
|
},
|
|
}
|
|
|
|
const rendered = spec.renderMarkdown(node)
|
|
expect(rendered).toBe('[mention id="test" label="Test User"]')
|
|
})
|
|
|
|
it('should render shortcodes with content correctly', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'highlight',
|
|
selfClosing: false,
|
|
allowedAttributes: ['color'],
|
|
})
|
|
|
|
const node: JSONContent = {
|
|
content: [{ type: 'text', text: 'highlighted text' }],
|
|
attrs: {
|
|
color: 'yellow',
|
|
},
|
|
}
|
|
|
|
const rendered = spec.renderMarkdown(node)
|
|
expect(rendered).toBe('[highlight color="yellow"]highlighted text[/highlight]')
|
|
})
|
|
|
|
it('should skip attributes with default values when skipIfDefault is specified', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'mention',
|
|
selfClosing: true,
|
|
allowedAttributes: ['id', 'label', { name: 'mentionSuggestionChar', skipIfDefault: '@' }],
|
|
})
|
|
|
|
// When mentionSuggestionChar equals the default '@', it should be omitted
|
|
const nodeWithDefault = {
|
|
attrs: {
|
|
id: 'test',
|
|
label: 'Test User',
|
|
mentionSuggestionChar: '@',
|
|
},
|
|
}
|
|
|
|
const renderedWithDefault = spec.renderMarkdown(nodeWithDefault)
|
|
expect(renderedWithDefault).toBe('[mention id="test" label="Test User"]')
|
|
|
|
// When mentionSuggestionChar differs from default, it should be included
|
|
const nodeWithNonDefault = {
|
|
attrs: {
|
|
id: 'bug',
|
|
label: 'Bug',
|
|
mentionSuggestionChar: '#',
|
|
},
|
|
}
|
|
|
|
const renderedWithNonDefault = spec.renderMarkdown(nodeWithNonDefault)
|
|
expect(renderedWithNonDefault).toBe(
|
|
'[mention id="bug" label="Bug" mentionSuggestionChar="#"]',
|
|
)
|
|
})
|
|
|
|
it('should support custom parseAttributes and serializeAttributes for attribute name mapping', () => {
|
|
const spec = createInlineMarkdownSpec({
|
|
nodeName: 'mention',
|
|
name: '@',
|
|
selfClosing: true,
|
|
allowedAttributes: ['id', 'label', { name: 'mentionSuggestionChar', skipIfDefault: '@' }],
|
|
parseAttributes: (attrString: string) => {
|
|
const attrs: Record<string, any> = {}
|
|
const regex = /(?:^|\s)(\w+)=(?:"([^"]*)"|'([^']*)')/g
|
|
let match = regex.exec(attrString)
|
|
|
|
while (match !== null) {
|
|
const [, key, doubleQuoted, singleQuoted] = match
|
|
const value = doubleQuoted ?? singleQuoted
|
|
attrs[key === 'char' ? 'mentionSuggestionChar' : key] = value
|
|
match = regex.exec(attrString)
|
|
}
|
|
|
|
return attrs
|
|
},
|
|
serializeAttributes: (attrs: Record<string, any>) => {
|
|
return Object.entries(attrs)
|
|
.filter(([, value]) => value !== undefined && value !== null)
|
|
.map(([key, value]) => {
|
|
const serializedKey = key === 'mentionSuggestionChar' ? 'char' : key
|
|
return `${serializedKey}="${value}"`
|
|
})
|
|
.join(' ')
|
|
},
|
|
})
|
|
|
|
// Test serialization: mentionSuggestionChar should be serialized as 'char'
|
|
const nodeWithHashChar = {
|
|
attrs: {
|
|
id: 'bug',
|
|
mentionSuggestionChar: '#',
|
|
},
|
|
}
|
|
|
|
const rendered = spec.renderMarkdown(nodeWithHashChar)
|
|
expect(rendered).toBe('[@ id="bug" char="#"]')
|
|
|
|
// Test serialization: default '@' should be omitted
|
|
const nodeWithDefaultChar = {
|
|
attrs: {
|
|
id: 'Madonna',
|
|
mentionSuggestionChar: '@',
|
|
},
|
|
}
|
|
|
|
const renderedDefault = spec.renderMarkdown(nodeWithDefaultChar)
|
|
expect(renderedDefault).toBe('[@ id="Madonna"]')
|
|
|
|
// Test parsing: 'char' should be mapped to 'mentionSuggestionChar'
|
|
const token = spec.markdownTokenizer.tokenize('[@ id="feature" char="#"]', [], null as any)
|
|
expect(token).toMatchObject({
|
|
type: 'mention',
|
|
attributes: {
|
|
id: 'feature',
|
|
mentionSuggestionChar: '#',
|
|
},
|
|
})
|
|
|
|
// Test parsing: without char attribute
|
|
const tokenNoChar = spec.markdownTokenizer.tokenize('[@ id="Tom Cruise"]', [], null as any)
|
|
expect(tokenNoChar).toMatchObject({
|
|
type: 'mention',
|
|
attributes: {
|
|
id: 'Tom Cruise',
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('createBlockMarkdownSpec', () => {
|
|
it('should create a valid spec for block nodes', () => {
|
|
const spec = createBlockMarkdownSpec({
|
|
nodeName: 'callout',
|
|
})
|
|
|
|
expect(spec).toHaveProperty('parseMarkdown')
|
|
expect(spec).toHaveProperty('markdownTokenizer')
|
|
expect(spec).toHaveProperty('renderMarkdown')
|
|
expect(spec.markdownTokenizer.level).toBe('block')
|
|
})
|
|
|
|
it('should handle tokenizer start detection', () => {
|
|
const spec = createBlockMarkdownSpec({
|
|
nodeName: 'callout',
|
|
})
|
|
|
|
const src = ':::callout\ncontent\n:::'
|
|
const startIndex =
|
|
typeof spec.markdownTokenizer.start === 'function'
|
|
? spec.markdownTokenizer.start?.(src)
|
|
: src.indexOf(spec.markdownTokenizer.start!)
|
|
|
|
expect(startIndex).toBe(0)
|
|
|
|
const falseSrc = 'no callout here'
|
|
const noMatch =
|
|
typeof spec.markdownTokenizer.start === 'function'
|
|
? spec.markdownTokenizer.start?.(falseSrc)
|
|
: falseSrc.indexOf(spec.markdownTokenizer.start!)
|
|
|
|
expect(noMatch).toBe(-1)
|
|
})
|
|
|
|
it('should tokenize block syntax correctly', () => {
|
|
const spec = createBlockMarkdownSpec({
|
|
nodeName: 'callout',
|
|
})
|
|
|
|
const mockLexer = {
|
|
blockTokens: (src: string) => [{ type: 'paragraph', text: src.trim() }],
|
|
inlineTokens: (src: string) => [{ type: 'text', text: src.trim() }],
|
|
}
|
|
|
|
const token = spec.markdownTokenizer.tokenize(
|
|
':::callout {type="info"}\nThis is a callout\n:::',
|
|
[],
|
|
mockLexer,
|
|
)
|
|
expect(token).toMatchObject({
|
|
type: 'callout',
|
|
attributes: { type: 'info' },
|
|
content: 'This is a callout',
|
|
})
|
|
|
|
expect(token).toHaveProperty('tokens')
|
|
expect(Array.isArray((token as any).tokens)).toBe(true)
|
|
expect((token as any).tokens).toHaveLength(1)
|
|
})
|
|
|
|
it('should parse malformed block attributes in linear time', () => {
|
|
const spec = createBlockMarkdownSpec({
|
|
nodeName: 'probe',
|
|
})
|
|
const lexer = {
|
|
blockTokens: () => [],
|
|
inlineTokens: () => [],
|
|
}
|
|
|
|
expectLinearTokenization(
|
|
attributes =>
|
|
spec.markdownTokenizer.tokenize(`:::probe {${attributes}}\ncontent\n:::`, [], lexer),
|
|
'__QUOTED_0',
|
|
)
|
|
})
|
|
|
|
it('should render block syntax correctly', () => {
|
|
const spec = createBlockMarkdownSpec({
|
|
nodeName: 'callout',
|
|
allowedAttributes: ['type'],
|
|
})
|
|
|
|
const mockHelpers = {
|
|
renderChildren: () => 'This is a callout',
|
|
wrapInBlock: (content: string) => content,
|
|
indent: (content: string) => content,
|
|
}
|
|
|
|
const node = {
|
|
attrs: { type: 'info' },
|
|
content: [],
|
|
}
|
|
|
|
const rendered = spec.renderMarkdown(node, mockHelpers)
|
|
expect(rendered).toBe(':::callout {type="info"}\n\nThis is a callout\n\n:::')
|
|
})
|
|
})
|
|
|
|
describe('createAtomBlockMarkdownSpec', () => {
|
|
it('should create a valid spec for atomic block nodes', () => {
|
|
const spec = createAtomBlockMarkdownSpec({
|
|
nodeName: 'youtube',
|
|
})
|
|
|
|
expect(spec).toHaveProperty('parseMarkdown')
|
|
expect(spec).toHaveProperty('markdownTokenizer')
|
|
expect(spec).toHaveProperty('renderMarkdown')
|
|
expect(spec.markdownTokenizer.level).toBe('block')
|
|
})
|
|
|
|
it('should handle tokenizer start detection', () => {
|
|
const spec = createAtomBlockMarkdownSpec({
|
|
nodeName: 'youtube',
|
|
})
|
|
|
|
const src = ':::youtube {src="test"} :::'
|
|
const startIndex =
|
|
typeof spec.markdownTokenizer.start === 'function'
|
|
? spec.markdownTokenizer.start?.(src)
|
|
: src.indexOf(spec.markdownTokenizer.start!)
|
|
expect(startIndex).toBe(0)
|
|
|
|
const falseSrc = 'no youtube here'
|
|
const noMatch =
|
|
typeof spec.markdownTokenizer.start === 'function'
|
|
? spec.markdownTokenizer.start?.(falseSrc)
|
|
: falseSrc.indexOf(spec.markdownTokenizer.start!)
|
|
expect(noMatch).toBe(-1)
|
|
})
|
|
|
|
it('should tokenize atom block syntax correctly', () => {
|
|
const spec = createAtomBlockMarkdownSpec({
|
|
nodeName: 'youtube',
|
|
})
|
|
|
|
const token = spec.markdownTokenizer.tokenize(
|
|
':::youtube {src="https://youtube.com/watch?v=test"} :::\n',
|
|
[],
|
|
null as any,
|
|
)
|
|
expect(token).toMatchObject({
|
|
type: 'youtube',
|
|
raw: ':::youtube {src="https://youtube.com/watch?v=test"} :::\n',
|
|
attributes: {
|
|
src: 'https://youtube.com/watch?v=test',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should parse malformed atom block attributes in linear time', () => {
|
|
const spec = createAtomBlockMarkdownSpec({
|
|
nodeName: 'probe',
|
|
})
|
|
|
|
expectLinearTokenization(
|
|
attributes =>
|
|
spec.markdownTokenizer.tokenize(`:::probe {${attributes}} :::\n`, [], null as any),
|
|
'__QUOTED_0',
|
|
)
|
|
})
|
|
|
|
it('should validate required attributes', () => {
|
|
const spec = createAtomBlockMarkdownSpec({
|
|
nodeName: 'youtube',
|
|
requiredAttributes: ['src'],
|
|
})
|
|
|
|
// Should fail without required attribute
|
|
const tokenWithoutSrc = spec.markdownTokenizer.tokenize(
|
|
':::youtube {width="400"} :::\n',
|
|
[],
|
|
null as any,
|
|
)
|
|
expect(tokenWithoutSrc).toBe(undefined)
|
|
|
|
// Should succeed with required attribute
|
|
const tokenWithSrc = spec.markdownTokenizer.tokenize(
|
|
':::youtube {src="test"} :::\n',
|
|
[],
|
|
null as any,
|
|
)
|
|
expect(tokenWithSrc).not.toBe(undefined)
|
|
expect(tokenWithSrc).toHaveProperty('attributes')
|
|
expect((tokenWithSrc as any).attributes).toHaveProperty('src', 'test')
|
|
})
|
|
|
|
it('should render atom block syntax correctly', () => {
|
|
const spec = createAtomBlockMarkdownSpec({
|
|
nodeName: 'youtube',
|
|
allowedAttributes: ['src', 'width'],
|
|
})
|
|
|
|
const node = {
|
|
attrs: {
|
|
src: 'https://youtube.com/watch?v=test',
|
|
width: '400',
|
|
internalId: 'should-be-filtered', // This should not appear
|
|
},
|
|
}
|
|
|
|
const rendered = spec.renderMarkdown(node)
|
|
expect(rendered).toBe(':::youtube {src="https://youtube.com/watch?v=test" width="400"} :::')
|
|
})
|
|
|
|
it('should apply default attributes', () => {
|
|
const spec = createAtomBlockMarkdownSpec({
|
|
nodeName: 'youtube',
|
|
defaultAttributes: { start: '0', width: '640' },
|
|
})
|
|
|
|
const mockHelpers = {
|
|
createNode: (name: string, attrs: any, content: any) => ({ type: name, attrs, content }),
|
|
parseInline: () => [],
|
|
parseChildren: () => [],
|
|
createTextNode: (text: string) => ({ type: 'text', text }),
|
|
applyMark: (mark: string, content: any) => ({ mark, content }),
|
|
}
|
|
|
|
const token = { attributes: { src: 'test' } }
|
|
const result = spec.parseMarkdown(token as any, mockHelpers as any)
|
|
|
|
expect((result as any).attrs).toMatchObject({
|
|
src: 'test',
|
|
start: '0',
|
|
width: '640',
|
|
})
|
|
})
|
|
})
|
|
})
|