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 = {} 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) => { 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', }) }) }) })