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
832 lines
26 KiB
TypeScript
832 lines
26 KiB
TypeScript
import type { Extension } from '@tiptap/core'
|
|
import { Bold } from '@tiptap/extension-bold'
|
|
import { Code } from '@tiptap/extension-code'
|
|
import { CodeBlock } from '@tiptap/extension-code-block'
|
|
import { Document } from '@tiptap/extension-document'
|
|
import { HardBreak } from '@tiptap/extension-hard-break'
|
|
import { Heading } from '@tiptap/extension-heading'
|
|
import { Italic } from '@tiptap/extension-italic'
|
|
import { Link } from '@tiptap/extension-link'
|
|
import { BulletList, ListItem, OrderedList, TaskItem, TaskList } from '@tiptap/extension-list'
|
|
import { Mention } from '@tiptap/extension-mention'
|
|
import { Paragraph } from '@tiptap/extension-paragraph'
|
|
import { Text } from '@tiptap/extension-text'
|
|
import { Youtube } from '@tiptap/extension-youtube'
|
|
import { MarkdownManager } from '@tiptap/markdown'
|
|
import { describe, expect, it } from 'vite-plus/test'
|
|
|
|
import * as conversionfiles from '../fixtures/markdown/index.js'
|
|
|
|
describe('Markdown Conversion Tests', () => {
|
|
const extensions = [
|
|
Document,
|
|
Paragraph,
|
|
Text,
|
|
Bold,
|
|
Italic,
|
|
Code,
|
|
Link,
|
|
Heading,
|
|
HardBreak,
|
|
CodeBlock,
|
|
BulletList,
|
|
OrderedList,
|
|
ListItem,
|
|
TaskList,
|
|
TaskItem.configure({
|
|
nested: true,
|
|
}),
|
|
Mention.configure({
|
|
HTMLAttributes: {
|
|
class: 'mention',
|
|
},
|
|
suggestion: undefined,
|
|
}),
|
|
Youtube.configure({
|
|
HTMLAttributes: {
|
|
class: 'youtube-video',
|
|
},
|
|
}),
|
|
]
|
|
|
|
const conversionExtensions: Extension[] = []
|
|
|
|
Object.values(conversionfiles).forEach((file: any) => {
|
|
if (!file?.extensions) {
|
|
return
|
|
}
|
|
|
|
conversionExtensions.push(...file.extensions)
|
|
})
|
|
|
|
const markdownManager = new MarkdownManager({
|
|
extensions: [...extensions, ...conversionExtensions],
|
|
})
|
|
|
|
describe('convert simple taskList from and to markdown', () => {
|
|
const simpleMarkdown = `
|
|
- [ ] Task 1
|
|
- [x] Task 2
|
|
- [ ] Task 3
|
|
`.trim()
|
|
|
|
const simpleJSON = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'taskList',
|
|
content: [
|
|
{
|
|
type: 'taskItem',
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Task 1' }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'taskItem',
|
|
attrs: { checked: true },
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Task 2' }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'taskItem',
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Task 3' }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
it('should convert simple markdown to expected JSON structure', () => {
|
|
const json = markdownManager.parse(simpleMarkdown)
|
|
expect(json).toEqual(simpleJSON)
|
|
})
|
|
|
|
it('should convert simple JSON structure back to expected markdown', () => {
|
|
const md = markdownManager.serialize(simpleJSON)
|
|
expect(md.trim()).toBe(simpleMarkdown.trim())
|
|
})
|
|
})
|
|
|
|
describe('convert taskList with leading paragraph from and to markdown', () => {
|
|
const markdownWithLeadingParagraph = `Leading paragraph
|
|
|
|
- [ ] Task`
|
|
|
|
const expectedJSON = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Leading paragraph' }],
|
|
},
|
|
{
|
|
type: 'taskList',
|
|
content: [
|
|
{
|
|
type: 'taskItem',
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Task' }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
it('should convert markdown with leading paragraph to expected JSON structure', () => {
|
|
const json = markdownManager.parse(markdownWithLeadingParagraph)
|
|
expect(json).to.deep.equal(expectedJSON)
|
|
})
|
|
|
|
it('should convert JSON structure back to expected markdown', () => {
|
|
const md = markdownManager.serialize(expectedJSON)
|
|
expect(md).to.equal(markdownWithLeadingParagraph)
|
|
})
|
|
})
|
|
|
|
Object.values(conversionfiles).forEach(file => {
|
|
describe(`convert ${file.name} from and to markdown`, () => {
|
|
it(`should convert ${file.name} markdown to expected JSON structure`, () => {
|
|
const json = markdownManager.parse(file.expectedInput)
|
|
|
|
const normalizedActual = JSON.parse(JSON.stringify(json))
|
|
const normalizedExpected = JSON.parse(JSON.stringify(file.expectedOutput))
|
|
|
|
expect(normalizedActual).toEqual(normalizedExpected)
|
|
})
|
|
|
|
it(`should convert ${file.name} JSON structure back to expected markdown`, () => {
|
|
const md = markdownManager.serialize(file.expectedOutput)
|
|
expect(md.trim()).toBe(file.expectedInput.trim())
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('multiple empty paragraphs', () => {
|
|
// The first top-level empty paragraph stays empty markdown spacing.
|
|
// Additional consecutive empty paragraphs are preserved with markers.
|
|
it('should preserve two empty paragraphs with blank spacing plus one marker when converting to markdown', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Line1' }],
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
content: [],
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
content: [],
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Line2' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
|
|
expect(markdown).toBe('Line1\n\n\n\n \n\nLine2')
|
|
})
|
|
|
|
it('should parse markdown with one preserved blank paragraph back correctly', () => {
|
|
const markdown = 'Line1\n\n \n\nLine2'
|
|
const json = markdownManager.parse(markdown)
|
|
const content = json.content!
|
|
|
|
expect(content).toHaveLength(3)
|
|
expect(content[0].type).toBe('paragraph')
|
|
expect(content[0].content![0].text).toBe('Line1')
|
|
expect(content[1].type).toBe('paragraph')
|
|
expect(content[1].content).toEqual([])
|
|
expect(content[2].type).toBe('paragraph')
|
|
expect(content[2].content![0].text).toBe('Line2')
|
|
})
|
|
|
|
it('should reserialize parsed empty paragraphs using blank spacing plus preserved markers', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Line1' }],
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
content: [],
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
content: [],
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Line2' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
const parsed = markdownManager.parse(markdown)
|
|
const content = parsed.content!
|
|
|
|
expect(content).toHaveLength(4)
|
|
expect(content[1].content).toEqual([])
|
|
expect(content[2].content).toEqual([])
|
|
|
|
const remarked = markdownManager.serialize(parsed)
|
|
expect(remarked).toBe(markdown)
|
|
})
|
|
|
|
it('should roundtrip five consecutive empty paragraphs without losing one', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'Line1' }] },
|
|
{ type: 'paragraph', content: [] },
|
|
{ type: 'paragraph', content: [] },
|
|
{ type: 'paragraph', content: [] },
|
|
{ type: 'paragraph', content: [] },
|
|
{ type: 'paragraph', content: [] },
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'Line2' }] },
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
const parsed = markdownManager.parse(markdown)
|
|
const content = parsed.content!
|
|
|
|
expect(markdown).toBe('Line1\n\n\n\n \n\n \n\n \n\n \n\nLine2')
|
|
expect(content).toHaveLength(7)
|
|
expect(
|
|
content.slice(1, 6).every(node => node.type === 'paragraph' && node.content?.length === 0),
|
|
).toBe(true)
|
|
})
|
|
|
|
it('should handle empty paragraphs without content field (real editor output)', () => {
|
|
// Real editor output may omit the content field for empty paragraphs
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Line1' }],
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
// No content field - this is what the real editor might output
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
// No content field
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'Line2' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
|
|
expect(markdown).toBe('Line1\n\n\n\n \n\nLine2')
|
|
})
|
|
|
|
it('should parse literal NBSP character (\\u00A0) as empty paragraph', () => {
|
|
// Some markdown parsers may convert entity to the literal NBSP character
|
|
const markdown = 'Line1\n\n\u00A0\n\nLine2'
|
|
const json = markdownManager.parse(markdown)
|
|
const content = json.content!
|
|
|
|
expect(content).toHaveLength(3)
|
|
expect(content[0].type).toBe('paragraph')
|
|
expect(content[0].content![0].text).toBe('Line1')
|
|
expect(content[1].type).toBe('paragraph')
|
|
expect(content[1].content).toEqual([])
|
|
expect(content[2].type).toBe('paragraph')
|
|
expect(content[2].content![0].text).toBe('Line2')
|
|
|
|
const serialized = markdownManager.serialize(json)
|
|
expect(serialized).toBe('Line1\n\n\n\nLine2')
|
|
})
|
|
})
|
|
|
|
describe('tilde fenced code blocks', () => {
|
|
const codeBlockJSON = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'codeBlock',
|
|
attrs: { language: null },
|
|
content: [{ type: 'text', text: 'code block' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const codeBlockWithLangJSON = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'codeBlock',
|
|
attrs: { language: 'js' },
|
|
content: [{ type: 'text', text: 'console.log("hello")' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
it('should parse tilde fenced code blocks', () => {
|
|
const markdown = '~~~\ncode block\n~~~'
|
|
const json = markdownManager.parse(markdown)
|
|
expect(json).toEqual(codeBlockJSON)
|
|
})
|
|
|
|
it('should parse tilde fenced code blocks with language', () => {
|
|
const markdown = '~~~js\nconsole.log("hello")\n~~~'
|
|
const json = markdownManager.parse(markdown)
|
|
expect(json).toEqual(codeBlockWithLangJSON)
|
|
})
|
|
|
|
it('should parse backtick fenced code blocks', () => {
|
|
const markdown = '```\ncode block\n```'
|
|
const json = markdownManager.parse(markdown)
|
|
expect(json).toEqual(codeBlockJSON)
|
|
})
|
|
|
|
it('should parse backtick fenced code blocks with language', () => {
|
|
const markdown = '```js\nconsole.log("hello")\n```'
|
|
const json = markdownManager.parse(markdown)
|
|
expect(json).toEqual(codeBlockWithLangJSON)
|
|
})
|
|
|
|
it('should produce the same result for tilde and backtick fenced code blocks', () => {
|
|
const tildeMarkdown = '~~~\ncode block\n~~~'
|
|
const backtickMarkdown = '```\ncode block\n```'
|
|
const tildeJSON = markdownManager.parse(tildeMarkdown)
|
|
const backtickJSON = markdownManager.parse(backtickMarkdown)
|
|
expect(tildeJSON).toEqual(backtickJSON)
|
|
})
|
|
})
|
|
|
|
describe('HTML character escaping', () => {
|
|
it('should decode < and > entities to literal < and > when parsing', () => {
|
|
const markdown = 'foo <bar> baz'
|
|
const json = markdownManager.parse(markdown)
|
|
|
|
expect(json.content).toHaveLength(1)
|
|
expect(json.content[0].type).toBe('paragraph')
|
|
expect(json.content[0].content).toHaveLength(1)
|
|
expect(json.content[0].content[0].text).toBe('foo <bar> baz')
|
|
})
|
|
|
|
it('should decode & entity to literal & when parsing', () => {
|
|
const markdown = 'foo & bar'
|
|
const json = markdownManager.parse(markdown)
|
|
|
|
expect(json.content[0].content[0].text).toBe('foo & bar')
|
|
})
|
|
|
|
it('should encode < and > back to entities when serializing', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'foo <bar> baz' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('foo <bar> baz')
|
|
})
|
|
|
|
it('should encode & back to & when serializing', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'foo & bar' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('foo & bar')
|
|
})
|
|
|
|
it('should roundtrip <bar> correctly', () => {
|
|
const markdown = 'foo <bar> baz'
|
|
const json = markdownManager.parse(markdown)
|
|
|
|
// Editor should show literal <bar>
|
|
expect(json.content[0].content[0].text).toBe('foo <bar> baz')
|
|
|
|
// Serialize back should produce the entity form
|
|
const serialized = markdownManager.serialize(json)
|
|
expect(serialized).toBe('foo <bar> baz')
|
|
})
|
|
|
|
it('should roundtrip & correctly', () => {
|
|
const markdown = 'foo & bar'
|
|
const json = markdownManager.parse(markdown)
|
|
expect(json.content[0].content[0].text).toBe('foo & bar')
|
|
|
|
const serialized = markdownManager.serialize(json)
|
|
expect(serialized).toBe('foo & bar')
|
|
})
|
|
|
|
it('should decode " entity to literal " when parsing', () => {
|
|
const markdown = 'foo "bar" baz'
|
|
const json = markdownManager.parse(markdown)
|
|
|
|
expect(json.content[0].content[0].text).toBe('foo "bar" baz')
|
|
})
|
|
|
|
it('should not encode " when serializing (quotes are valid markdown)', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: 'foo "bar" baz' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('foo "bar" baz')
|
|
})
|
|
|
|
it('should decode " when parsing but serialize as literal "', () => {
|
|
const markdown = 'foo "bar" baz'
|
|
const json = markdownManager.parse(markdown)
|
|
expect(json.content[0].content[0].text).toBe('foo "bar" baz')
|
|
|
|
const serialized = markdownManager.serialize(json)
|
|
expect(serialized).toBe('foo "bar" baz')
|
|
})
|
|
|
|
it('should not encode entities inside code blocks', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'codeBlock',
|
|
attrs: { language: null },
|
|
content: [{ type: 'text', text: 'foo <bar> & baz' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('```\nfoo <bar> & baz\n```')
|
|
})
|
|
|
|
it('should not encode entities inside inline code marks', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<tag>',
|
|
marks: [{ type: 'code' }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('`<tag>`')
|
|
})
|
|
|
|
it('should handle doubly-encoded entities correctly', () => {
|
|
// &lt; should decode to < (not to <)
|
|
const markdown = 'foo &lt; bar'
|
|
const json = markdownManager.parse(markdown)
|
|
expect(json.content[0].content[0].text).toBe('foo < bar')
|
|
|
|
// Serializing should re-encode the & in <
|
|
const serialized = markdownManager.serialize(json)
|
|
expect(serialized).toBe('foo &lt; bar')
|
|
})
|
|
|
|
it('should preserve empty paragraph behavior', () => {
|
|
const markdown = 'Line1\n\n \n\nLine2'
|
|
const json = markdownManager.parse(markdown)
|
|
|
|
// Empty paragraph check should still work
|
|
expect(json.content).toHaveLength(3)
|
|
expect(json.content[1].type).toBe('paragraph')
|
|
expect(json.content[1].content).toEqual([])
|
|
|
|
// A single empty paragraph between content paragraphs uses blank-line
|
|
// spacing (the first empty paragraph doesn't need an marker).
|
|
const serialized = markdownManager.serialize(json)
|
|
expect(serialized).toBe('Line1\n\n\n\nLine2')
|
|
})
|
|
|
|
it('should roundtrip literal &nbsp; without it being treated as an empty paragraph marker', () => {
|
|
// A user writing &nbsp; in markdown intends for the text " " to display.
|
|
// decodeHtmlEntities decodes & → &, producing text content " ".
|
|
// On serialization, encodeHtmlEntities re-encodes & → &, restoring &nbsp;.
|
|
// The intermediate " " text must NOT be confused with the empty-paragraph marker.
|
|
const markdown = 'before &nbsp; after'
|
|
const json = markdownManager.parse(markdown)
|
|
|
|
expect(json.content).toHaveLength(1)
|
|
expect(json.content[0].type).toBe('paragraph')
|
|
expect(json.content[0].content[0].text).toBe('before after')
|
|
|
|
const serialized = markdownManager.serialize(json)
|
|
expect(serialized).toBe('before &nbsp; after')
|
|
})
|
|
|
|
it('should preserve a standalone literal &nbsp; paragraph as text', () => {
|
|
const markdown = '&nbsp;'
|
|
const json = markdownManager.parse(markdown)
|
|
const content = json.content!
|
|
|
|
expect(content).toHaveLength(1)
|
|
expect(content[0].type).toBe('paragraph')
|
|
expect(content[0].content).toEqual([{ type: 'text', text: ' ' }])
|
|
|
|
const serialized = markdownManager.serialize(json)
|
|
expect(serialized).toBe('&nbsp;')
|
|
})
|
|
})
|
|
|
|
describe('markdown escape character handling', () => {
|
|
describe('parsing: escape tokens in markdown → text nodes', () => {
|
|
it('should parse a backslash-escaped asterisk as a literal asterisk', () => {
|
|
const json = markdownManager.parse('\\*text\\*')
|
|
|
|
expect(json.content).toHaveLength(1)
|
|
expect(json.content[0].type).toBe('paragraph')
|
|
expect(json.content[0].content).toHaveLength(1)
|
|
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '*text*' })
|
|
})
|
|
|
|
it('should parse a backslash-escaped underscore as a literal underscore', () => {
|
|
const json = markdownManager.parse('\\_text\\_')
|
|
|
|
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '_text_' })
|
|
})
|
|
|
|
it('should parse a backslash-escaped backtick as a literal backtick', () => {
|
|
const json = markdownManager.parse('\\`')
|
|
|
|
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '`' })
|
|
})
|
|
|
|
it('should parse a double-backslash as a literal backslash', () => {
|
|
const json = markdownManager.parse('\\\\')
|
|
|
|
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '\\' })
|
|
})
|
|
|
|
it('should parse escaped double-asterisks as literal double-asterisks', () => {
|
|
const json = markdownManager.parse('\\*\\*bold\\*\\*')
|
|
|
|
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '**bold**' })
|
|
})
|
|
|
|
it('should parse a backslash-escaped tilde as a literal tilde', () => {
|
|
const json = markdownManager.parse('\\~text\\~')
|
|
|
|
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '~text~' })
|
|
})
|
|
|
|
it('should parse escaped brackets as literal brackets', () => {
|
|
const json = markdownManager.parse('\\[link\\]')
|
|
|
|
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '[link]' })
|
|
})
|
|
})
|
|
|
|
describe('parsing: mixed escape tokens and real formatting', () => {
|
|
it('should preserve real italic marks alongside escaped asterisks', () => {
|
|
const json = markdownManager.parse('\\*escaped\\* and *real*')
|
|
|
|
expect(json.content).toHaveLength(1)
|
|
const content = json.content[0].content!
|
|
expect(content).toHaveLength(2)
|
|
// The unmarked escaped text gets merged into a single node
|
|
expect(content[0].type).toBe('text')
|
|
expect(content[0].text).toBe('*escaped* and ')
|
|
// The real italic is still separate
|
|
expect(content[1]).toEqual({
|
|
type: 'text',
|
|
text: 'real',
|
|
marks: [{ type: 'italic' }],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('serializing: text with special chars → backslash-escaped markdown', () => {
|
|
it('should escape asterisks in text nodes', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: '*literal*' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('\\*literal\\*')
|
|
})
|
|
|
|
it('should escape underscores in text nodes', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: '_underscore_' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('\\_underscore\\_')
|
|
})
|
|
|
|
it('should escape backticks in text nodes', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: '`code`' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('\\`code\\`')
|
|
})
|
|
|
|
it('should escape backslashes in text nodes', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: '\\' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('\\\\')
|
|
})
|
|
|
|
it('should escape brackets in text nodes', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: '[bracket]' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('\\[bracket\\]')
|
|
})
|
|
|
|
it('should escape tildes in text nodes', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: '~strike~' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('\\~strike\\~')
|
|
})
|
|
})
|
|
|
|
describe('round-trip: parse → serialize → parse stability', () => {
|
|
it('should round-trip a simple escaped asterisk string', () => {
|
|
const input = '\\*hello\\*'
|
|
const json = markdownManager.parse(input)
|
|
const md = markdownManager.serialize(json)
|
|
const json2 = markdownManager.parse(md)
|
|
|
|
expect(json2.content[0].content[0].text).toBe('*hello*')
|
|
})
|
|
|
|
it('should round-trip JSON with literal asterisk through serialize → parse', () => {
|
|
const input = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [{ type: 'text', text: '*literal*' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const md = markdownManager.serialize(input)
|
|
const parsed = markdownManager.parse(md)
|
|
|
|
expect(parsed.content[0].content[0].text).toBe('*literal*')
|
|
})
|
|
|
|
it('should round-trip a double-backslash', () => {
|
|
const input = '\\\\\\\\'
|
|
const json = markdownManager.parse(input)
|
|
const md = markdownManager.serialize(json)
|
|
const json2 = markdownManager.parse(md)
|
|
|
|
expect(json2.content[0].content[0].text).toBe('\\\\')
|
|
})
|
|
})
|
|
|
|
describe('edge cases: code blocks and nested syntax', () => {
|
|
it('should NOT backslash-escape inside inline code marks (parsing)', () => {
|
|
const json = markdownManager.parse('`\\*not italic\\*`')
|
|
|
|
expect(json.content[0].content[0]).toEqual({
|
|
type: 'text',
|
|
text: '\\*not italic\\*',
|
|
marks: [{ type: 'code' }],
|
|
})
|
|
})
|
|
|
|
it('should NOT backslash-escape inside code blocks when serializing', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'codeBlock',
|
|
attrs: { language: null },
|
|
content: [{ type: 'text', text: '*not bold*' }],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('```\n*not bold*\n```')
|
|
})
|
|
|
|
it('should NOT backslash-escape inside inline code marks when serializing', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '*not bold*',
|
|
marks: [{ type: 'code' }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
const markdown = markdownManager.serialize(json)
|
|
expect(markdown).toBe('`*not bold*`')
|
|
})
|
|
})
|
|
})
|
|
})
|