Files
upstream-mirror 083a46e93b
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
snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:11:02 +05:00

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