import Audio from '~/content_editor/extensions/audio'; import Bold from '~/content_editor/extensions/bold'; import Blockquote from '~/content_editor/extensions/blockquote'; import BulletList from '~/content_editor/extensions/bullet_list'; import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import Diagram from '~/content_editor/extensions/diagram'; import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; import FootnoteReference from '~/content_editor/extensions/footnote_reference'; import Frontmatter from '~/content_editor/extensions/frontmatter'; import HardBreak from '~/content_editor/extensions/hard_break'; import HTMLNodes from '~/content_editor/extensions/html_nodes'; import Heading from '~/content_editor/extensions/heading'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; import Image from '~/content_editor/extensions/image'; import Italic from '~/content_editor/extensions/italic'; import Link from '~/content_editor/extensions/link'; import ListItem from '~/content_editor/extensions/list_item'; import OrderedList from '~/content_editor/extensions/ordered_list'; import Paragraph from '~/content_editor/extensions/paragraph'; import ReferenceDefinition from '~/content_editor/extensions/reference_definition'; import Sourcemap from '~/content_editor/extensions/sourcemap'; import Strike from '~/content_editor/extensions/strike'; import Table from '~/content_editor/extensions/table'; import TableHeader from '~/content_editor/extensions/table_header'; import TableOfContents from '~/content_editor/extensions/table_of_contents'; import TableRow from '~/content_editor/extensions/table_row'; import TableCell from '~/content_editor/extensions/table_cell'; import TaskList from '~/content_editor/extensions/task_list'; import TaskItem from '~/content_editor/extensions/task_item'; import Video from '~/content_editor/extensions/video'; import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import markdownSerializer from '~/content_editor/services/markdown_serializer'; import { SAFE_VIDEO_EXT, SAFE_AUDIO_EXT, DIAGRAM_LANGUAGES } from '~/content_editor/constants'; import { createTestEditor, createDocBuilder } from './test_utils'; const tiptapEditor = createTestEditor({ extensions: [ Audio, Blockquote, Bold, BulletList, Code, CodeBlockHighlight, Diagram, FootnoteDefinition, FootnoteReference, Frontmatter, HardBreak, Heading, HorizontalRule, Image, Italic, Link, ListItem, OrderedList, ReferenceDefinition, Sourcemap, Strike, Table, TableRow, TableHeader, TableCell, TableOfContents, TaskList, TaskItem, Video, ...HTMLNodes, ], }); const { builders: { doc, paragraph, audio, bold, blockquote, bulletList, code, codeBlock, div, diagram, footnoteDefinition, footnoteReference, frontmatter, hardBreak, heading, horizontalRule, image, italic, link, listItem, orderedList, pre, referenceDefinition, strike, table, tableRow, tableHeader, tableCell, tableOfContents, taskItem, taskList, video, }, } = createDocBuilder({ tiptapEditor, names: { audio: { nodeType: Audio.name }, blockquote: { nodeType: Blockquote.name }, bold: { markType: Bold.name }, bulletList: { nodeType: BulletList.name }, code: { markType: Code.name }, codeBlock: { nodeType: CodeBlockHighlight.name }, diagram: { nodeType: Diagram.name }, footnoteDefinition: { nodeType: FootnoteDefinition.name }, footnoteReference: { nodeType: FootnoteReference.name }, frontmatter: { nodeType: Frontmatter.name }, hardBreak: { nodeType: HardBreak.name }, heading: { nodeType: Heading.name }, horizontalRule: { nodeType: HorizontalRule.name }, image: { nodeType: Image.name }, italic: { nodeType: Italic.name }, link: { markType: Link.name }, listItem: { nodeType: ListItem.name }, orderedList: { nodeType: OrderedList.name }, paragraph: { nodeType: Paragraph.name }, referenceDefinition: { nodeType: ReferenceDefinition.name }, strike: { nodeType: Strike.name }, table: { nodeType: Table.name }, tableCell: { nodeType: TableCell.name }, tableHeader: { nodeType: TableHeader.name }, tableRow: { nodeType: TableRow.name }, tableOfContents: { nodeType: TableOfContents.name }, taskItem: { nodeType: TaskItem.name }, taskList: { nodeType: TaskList.name }, video: { nodeType: Video.name }, ...HTMLNodes.reduce( (builders, htmlNode) => ({ ...builders, [htmlNode.name]: { nodeType: htmlNode.name }, }), {}, ), }, }); describe('Client side Markdown processing', () => { const deserialize = async (markdown) => { const { document } = await remarkMarkdownDeserializer().deserialize({ schema: tiptapEditor.schema, markdown, }); return document; }; const serialize = (document) => markdownSerializer({}).serialize({ doc: document, pristineDoc: document, }); const source = (sourceMarkdown) => ({ sourceMapKey: expect.any(String), sourceMarkdown, }); const examples = [ { markdown: '__bold text__', expectedDoc: doc( paragraph(source('__bold text__'), bold(source('__bold text__'), 'bold text')), ), }, { markdown: '**bold text**', expectedDoc: doc( paragraph(source('**bold text**'), bold(source('**bold text**'), 'bold text')), ), }, { markdown: 'bold text', expectedDoc: doc( paragraph( source('bold text'), bold(source('bold text'), 'bold text'), ), ), }, { markdown: 'bold text', expectedDoc: doc( paragraph(source('bold text'), bold(source('bold text'), 'bold text')), ), }, { markdown: '_italic text_', expectedDoc: doc( paragraph(source('_italic text_'), italic(source('_italic text_'), 'italic text')), ), }, { markdown: '*italic text*', expectedDoc: doc( paragraph(source('*italic text*'), italic(source('*italic text*'), 'italic text')), ), }, { markdown: 'italic text', expectedDoc: doc( paragraph( source('italic text'), italic(source('italic text'), 'italic text'), ), ), }, { markdown: 'italic text', expectedDoc: doc( paragraph( source('italic text'), italic(source('italic text'), 'italic text'), ), ), }, { markdown: '`inline code`', expectedDoc: doc( paragraph(source('`inline code`'), code(source('`inline code`'), 'inline code')), ), }, { markdown: '**`inline code bold`**', expectedDoc: doc( paragraph( source('**`inline code bold`**'), bold( source('**`inline code bold`**'), code(source('`inline code bold`'), 'inline code bold'), ), ), ), }, { markdown: '_`inline code italics`_', expectedDoc: doc( paragraph( source('_`inline code italics`_'), italic( source('_`inline code italics`_'), code(source('`inline code italics`'), 'inline code italics'), ), ), ), }, { markdown: ` *bar* `, expectedDoc: doc( paragraph( source('\n *bar*\n'), italic(source('\n *bar*\n'), '\n *bar*\n'), ), ), }, { markdown: ` foo `, expectedDoc: doc( paragraph( source('foo'), image({ ...source('foo'), alt: 'foo', canonicalSrc: 'bar', src: 'bar', }), ), ), }, { markdown: ` - List item 1 foo `, expectedDoc: doc( bulletList( source('- List item 1'), listItem(source('- List item 1'), paragraph(source('List item 1'), 'List item 1')), ), paragraph( source('foo'), image({ ...source('foo'), alt: 'foo', src: 'bar', canonicalSrc: 'bar', }), ), ), }, { markdown: '[GitLab](https://gitlab.com "Go to GitLab")', expectedDoc: doc( paragraph( source('[GitLab](https://gitlab.com "Go to GitLab")'), link( { ...source('[GitLab](https://gitlab.com "Go to GitLab")'), href: 'https://gitlab.com', canonicalSrc: 'https://gitlab.com', title: 'Go to GitLab', }, 'GitLab', ), ), ), }, { markdown: '**[GitLab](https://gitlab.com "Go to GitLab")**', expectedDoc: doc( paragraph( source('**[GitLab](https://gitlab.com "Go to GitLab")**'), bold( source('**[GitLab](https://gitlab.com "Go to GitLab")**'), link( { ...source('[GitLab](https://gitlab.com "Go to GitLab")'), href: 'https://gitlab.com', canonicalSrc: 'https://gitlab.com', title: 'Go to GitLab', }, 'GitLab', ), ), ), ), }, { markdown: 'www.commonmark.org', expectedDoc: doc( paragraph( source('www.commonmark.org'), link( { ...source('www.commonmark.org'), canonicalSrc: 'http://www.commonmark.org', href: 'http://www.commonmark.org', }, 'www.commonmark.org', ), ), ), }, { markdown: 'Visit www.commonmark.org/help for more information.', expectedDoc: doc( paragraph( source('Visit www.commonmark.org/help for more information.'), 'Visit ', link( { ...source('www.commonmark.org/help'), canonicalSrc: 'http://www.commonmark.org/help', href: 'http://www.commonmark.org/help', }, 'www.commonmark.org/help', ), ' for more information.', ), ), }, { markdown: 'hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.', expectedDoc: doc( paragraph( source('hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.'), 'hello@mail+xyz.example isn’t valid, but ', link( { ...source('hello+xyz@mail.example'), canonicalSrc: 'mailto:hello+xyz@mail.example', href: 'mailto:hello+xyz@mail.example', }, 'hello+xyz@mail.example', ), ' is.', ), ), }, { markdown: '[https://gitlab.com>', expectedDoc: doc( paragraph( source('[https://gitlab.com>'), '[', link( { sourceMapKey: null, sourceMarkdown: null, canonicalSrc: 'https://gitlab.com', href: 'https://gitlab.com', }, 'https://gitlab.com', ), '>', ), ), }, { markdown: ` This is a paragraph with a\\ hard line break`, expectedDoc: doc( paragraph( source('This is a paragraph with a\\\nhard line break'), 'This is a paragraph with a', hardBreak(source('\\\n')), '\nhard line break', ), ), }, { markdown: '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")', expectedDoc: doc( paragraph( source('![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'), image({ ...source('![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'), alt: 'GitLab Logo', canonicalSrc: 'https://gitlab.com/logo.png', src: 'https://gitlab.com/logo.png', title: 'GitLab Logo', }), ), ), }, { markdown: '---', expectedDoc: doc(horizontalRule(source('---'))), }, { markdown: '***', expectedDoc: doc(horizontalRule(source('***'))), }, { markdown: '___', expectedDoc: doc(horizontalRule(source('___'))), }, { markdown: '
', expectedDoc: doc(horizontalRule(source('
'))), }, { markdown: '# Heading 1', expectedDoc: doc(heading({ ...source('# Heading 1'), level: 1 }, 'Heading 1')), }, { markdown: '## Heading 2', expectedDoc: doc(heading({ ...source('## Heading 2'), level: 2 }, 'Heading 2')), }, { markdown: '### Heading 3', expectedDoc: doc(heading({ ...source('### Heading 3'), level: 3 }, 'Heading 3')), }, { markdown: '#### Heading 4', expectedDoc: doc(heading({ ...source('#### Heading 4'), level: 4 }, 'Heading 4')), }, { markdown: '##### Heading 5', expectedDoc: doc(heading({ ...source('##### Heading 5'), level: 5 }, 'Heading 5')), }, { markdown: '###### Heading 6', expectedDoc: doc(heading({ ...source('###### Heading 6'), level: 6 }, 'Heading 6')), }, { markdown: ` Heading one ====== `, expectedDoc: doc(heading({ ...source('Heading\none\n======'), level: 1 }, 'Heading\none')), }, { markdown: ` Heading two ------- `, expectedDoc: doc(heading({ ...source('Heading\ntwo\n-------'), level: 2 }, 'Heading\ntwo')), }, { markdown: ` - List item 1 - List item 2 `, expectedDoc: doc( bulletList( source('- List item 1\n- List item 2'), listItem(source('- List item 1'), paragraph(source('List item 1'), 'List item 1')), listItem(source('- List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, { markdown: ` * List item 1 * List item 2 `, expectedDoc: doc( bulletList( source('* List item 1\n* List item 2'), listItem(source('* List item 1'), paragraph(source('List item 1'), 'List item 1')), listItem(source('* List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, { markdown: ` + List item 1 + List item 2 `, expectedDoc: doc( bulletList( source('+ List item 1\n+ List item 2'), listItem(source('+ List item 1'), paragraph(source('List item 1'), 'List item 1')), listItem(source('+ List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, { markdown: ` 1. List item 1 1. List item 2 `, expectedDoc: doc( orderedList( source('1. List item 1\n1. List item 2'), listItem(source('1. List item 1'), paragraph(source('List item 1'), 'List item 1')), listItem(source('1. List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, { markdown: ` 1. List item 1 2. List item 2 `, expectedDoc: doc( orderedList( source('1. List item 1\n2. List item 2'), listItem(source('1. List item 1'), paragraph(source('List item 1'), 'List item 1')), listItem(source('2. List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, { markdown: ` 1) List item 1 2) List item 2 `, expectedDoc: doc( orderedList( source('1) List item 1\n2) List item 2'), listItem(source('1) List item 1'), paragraph(source('List item 1'), 'List item 1')), listItem(source('2) List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, { markdown: ` - List item 1 - Sub list item 1 `, expectedDoc: doc( bulletList( source('- List item 1\n - Sub list item 1'), listItem( source('- List item 1\n - Sub list item 1'), paragraph(source('List item 1'), 'List item 1'), bulletList( source('- Sub list item 1'), listItem( source('- Sub list item 1'), paragraph(source('Sub list item 1'), 'Sub list item 1'), ), ), ), ), ), }, { markdown: ` - List item 1 paragraph 1 List item 1 paragraph 2 - List item 2 `, expectedDoc: doc( bulletList( source('- List item 1 paragraph 1\n\n List item 1 paragraph 2\n- List item 2'), listItem( source('- List item 1 paragraph 1\n\n List item 1 paragraph 2'), paragraph(source('List item 1 paragraph 1'), 'List item 1 paragraph 1'), paragraph(source('List item 1 paragraph 2'), 'List item 1 paragraph 2'), ), listItem(source('- List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, { markdown: ` - List item with an image ![bar](foo.png) `, expectedDoc: doc( bulletList( source('- List item with an image ![bar](foo.png)'), listItem( source('- List item with an image ![bar](foo.png)'), paragraph( source('List item with an image ![bar](foo.png)'), 'List item with an image', image({ ...source('![bar](foo.png)'), alt: 'bar', canonicalSrc: 'foo.png', src: 'foo.png', }), ), ), ), ), }, { markdown: ` > This is a blockquote `, expectedDoc: doc( blockquote( source('> This is a blockquote'), paragraph(source('This is a blockquote'), 'This is a blockquote'), ), ), }, { markdown: ` > - List item 1 > - List item 2 `, expectedDoc: doc( blockquote( source('> - List item 1\n> - List item 2'), bulletList( source('- List item 1\n> - List item 2'), listItem(source('- List item 1'), paragraph(source('List item 1'), 'List item 1')), listItem(source('- List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), ), }, { markdown: ` code block const fn = () => 'GitLab'; `, expectedDoc: doc( paragraph(source('code block'), 'code block'), codeBlock( { ...source(" const fn = () => 'GitLab';"), class: 'code highlight', language: null, }, "const fn = () => 'GitLab';", ), ), }, { markdown: ` \`\`\`javascript const fn = () => 'GitLab'; \`\`\`\ `, expectedDoc: doc( codeBlock( { ...source("```javascript\nconst fn = () => 'GitLab';\n```"), class: 'code highlight', language: 'javascript', }, "const fn = () => 'GitLab';", ), ), }, { markdown: ` ~~~javascript const fn = () => 'GitLab'; ~~~ `, expectedDoc: doc( codeBlock( { ...source("~~~javascript\nconst fn = () => 'GitLab';\n~~~"), class: 'code highlight', language: 'javascript', }, "const fn = () => 'GitLab';", ), ), }, { markdown: ` \`\`\` \`\`\`\ `, expectedDoc: doc( codeBlock( { ...source('```\n```'), class: 'code highlight', language: null, }, '', ), ), }, { markdown: ` \`\`\`javascript const fn = () => 'GitLab'; \`\`\`\ `, expectedDoc: doc( codeBlock( { ...source("```javascript\nconst fn = () => 'GitLab';\n\n```"), class: 'code highlight', language: 'javascript', }, "const fn = () => 'GitLab';\n", ), ), }, { markdown: '~~Strikedthrough text~~', expectedDoc: doc( paragraph( source('~~Strikedthrough text~~'), strike(source('~~Strikedthrough text~~'), 'Strikedthrough text'), ), ), }, { markdown: 'Strikedthrough text', expectedDoc: doc( paragraph( source('Strikedthrough text'), strike(source('Strikedthrough text'), 'Strikedthrough text'), ), ), }, { markdown: 'Strikedthrough text', expectedDoc: doc( paragraph( source('Strikedthrough text'), strike(source('Strikedthrough text'), 'Strikedthrough text'), ), ), }, { markdown: 'Strikedthrough text', expectedDoc: doc( paragraph( source('Strikedthrough text'), strike(source('Strikedthrough text'), 'Strikedthrough text'), ), ), }, { markdown: ` - [ ] task list item 1 - [ ] task list item 2 `, expectedDoc: doc( taskList( { numeric: false, ...source('- [ ] task list item 1\n- [ ] task list item 2'), }, taskItem( { checked: false, ...source('- [ ] task list item 1'), }, paragraph(source('task list item 1'), 'task list item 1'), ), taskItem( { checked: false, ...source('- [ ] task list item 2'), }, paragraph(source('task list item 2'), 'task list item 2'), ), ), ), }, { markdown: ` - [x] task list item 1 - [x] task list item 2 `, expectedDoc: doc( taskList( { numeric: false, ...source('- [x] task list item 1\n- [x] task list item 2'), }, taskItem( { checked: true, ...source('- [x] task list item 1'), }, paragraph(source('task list item 1'), 'task list item 1'), ), taskItem( { checked: true, ...source('- [x] task list item 2'), }, paragraph(source('task list item 2'), 'task list item 2'), ), ), ), }, { markdown: ` 1. [ ] task list item 1 2. [ ] task list item 2 `, expectedDoc: doc( taskList( { numeric: true, ...source('1. [ ] task list item 1\n2. [ ] task list item 2'), }, taskItem( { checked: false, ...source('1. [ ] task list item 1'), }, paragraph(source('task list item 1'), 'task list item 1'), ), taskItem( { checked: false, ...source('2. [ ] task list item 2'), }, paragraph(source('task list item 2'), 'task list item 2'), ), ), ), }, { markdown: ` | a | b | |---|---| | c | d | `, expectedDoc: doc( table( source('| a | b |\n|---|---|\n| c | d |'), tableRow( source('| a | b |'), tableHeader(source('| a |'), paragraph(source('a'), 'a')), tableHeader(source(' b |'), paragraph(source('b'), 'b')), ), tableRow( source('| c | d |'), tableCell(source('| c |'), paragraph(source('c'), 'c')), tableCell(source(' d |'), paragraph(source('d'), 'd')), ), ), ), }, { markdown: `
Header
Body
`, expectedDoc: doc( table( source( '\n \n \n \n \n \n \n
Header
Body
', ), tableRow( source('\n Header\n '), tableHeader( { ...source('Header'), colspan: 2, rowspan: 5, }, paragraph(source('Header'), 'Header'), ), ), tableRow( source('\n Body\n '), tableCell( { ...source('Body'), colspan: 2, rowspan: 5, }, paragraph(source('Body'), 'Body'), ), ), ), ), }, { markdown: ` This is a footnote [^footnote] Paragraph [^footnote]: Footnote definition Paragraph `, expectedDoc: doc( paragraph( source('This is a footnote [^footnote]'), 'This is a footnote ', footnoteReference({ ...source('[^footnote]'), identifier: 'footnote', label: 'footnote', }), ), paragraph(source('Paragraph'), 'Paragraph'), footnoteDefinition( { ...source('[^footnote]: Footnote definition'), identifier: 'footnote', label: 'footnote', }, paragraph(source('Footnote definition'), 'Footnote definition'), ), paragraph(source('Paragraph'), 'Paragraph'), ), }, { markdown: `
div
`, expectedDoc: doc(div(source('
div
'), paragraph(source('div'), 'div'))), }, { markdown: ` [![moon](moon.jpg)](/uri) `, expectedDoc: doc( paragraph( source('[![moon](moon.jpg)](/uri)'), link( { ...source('[![moon](moon.jpg)](/uri)'), canonicalSrc: '/uri', href: '/uri', }, image({ ...source('![moon](moon.jpg)'), canonicalSrc: 'moon.jpg', src: 'moon.jpg', alt: 'moon', }), ), ), ), }, { markdown: ` *foo* `, expectedDoc: doc( paragraph( source('*foo*'), strike(source('\n\n*foo*\n\n'), italic(source('*foo*'), 'foo')), ), ), expectedMarkdown: '*foo*', }, { markdown: ` ~[moon](moon.jpg) and [sun](sun.jpg)~ `, expectedDoc: doc( paragraph( source('~[moon](moon.jpg) and [sun](sun.jpg)~'), strike( source('~[moon](moon.jpg) and [sun](sun.jpg)~'), link( { ...source('[moon](moon.jpg)'), canonicalSrc: 'moon.jpg', href: 'moon.jpg', }, 'moon', ), ), strike(source('~[moon](moon.jpg) and [sun](sun.jpg)~'), ' and '), strike( source('~[moon](moon.jpg) and [sun](sun.jpg)~'), link( { ...source('[sun](sun.jpg)'), href: 'sun.jpg', canonicalSrc: 'sun.jpg', }, 'sun', ), ), ), ), }, { markdown: ` **Paragraph 1** _Paragraph 2_ `, expectedDoc: doc( paragraph( source('**Paragraph 1**'), strike( source('\n\n**Paragraph 1**\n\n_Paragraph 2_\n\n'), bold(source('**Paragraph 1**'), 'Paragraph 1'), ), ), paragraph( source('_Paragraph 2_'), strike( source('\n\n**Paragraph 1**\n\n_Paragraph 2_\n\n'), italic(source('_Paragraph 2_'), 'Paragraph 2'), ), ), ), expectedMarkdown: `**Paragraph 1** _Paragraph 2_`, }, /* TODO * Implement proper editing support for HTML comments in the Content Editor * https://gitlab.com/gitlab-org/gitlab/-/issues/342173 */ { markdown: '', expectedDoc: doc(paragraph()), expectedMarkdown: '', }, { markdown: ` `, expectedDoc: doc(paragraph()), expectedMarkdown: '', }, { markdown: ` *bar* *baz* `, expectedDoc: doc( paragraph(source('*bar*'), '*bar*\n'), paragraph(source('*baz*'), italic(source('*baz*'), 'baz')), ), expectedMarkdown: `*bar* *baz*`, }, { markdown: `
**Hello**,

_world_.
`, expectedDoc: doc( table( source('
\n
\n**Hello**,\n\n_world_.\n
\n
'), tableRow( source('\n
\n**Hello**,\n\n_world_.\n
\n'), tableCell( source('\n
\n**Hello**,\n\n_world_.\n
\n'), pre( source('
\n**Hello**,\n\n_world_.\n
'), paragraph(source('**Hello**,'), '**Hello**,\n'), paragraph(source('_world_.\n'), italic(source('_world_'), 'world'), '.\n'), ), paragraph(), ), ), ), ), }, { markdown: ` [GitLab][gitlab-url] [gitlab-url]: https://gitlab.com "GitLab" `, expectedDoc: doc( paragraph( source('[GitLab][gitlab-url]'), link( { ...source('[GitLab][gitlab-url]'), href: 'https://gitlab.com', canonicalSrc: 'gitlab-url', title: 'GitLab', isReference: true, }, 'GitLab', ), ), referenceDefinition( { ...source('[gitlab-url]: https://gitlab.com "GitLab"'), identifier: 'gitlab-url', url: 'https://gitlab.com', title: 'GitLab', }, '[gitlab-url]: https://gitlab.com "GitLab"', ), ), }, { markdown: ` ![GitLab Logo][gitlab-logo] [gitlab-logo]: https://gitlab.com/gitlab-logo.png "GitLab Logo" `, expectedDoc: doc( paragraph( source('![GitLab Logo][gitlab-logo]'), image({ ...source('![GitLab Logo][gitlab-logo]'), src: 'https://gitlab.com/gitlab-logo.png', canonicalSrc: 'gitlab-logo', alt: 'GitLab Logo', title: 'GitLab Logo', isReference: true, }), ), referenceDefinition( { ...source('[gitlab-logo]: https://gitlab.com/gitlab-logo.png "GitLab Logo"'), identifier: 'gitlab-logo', url: 'https://gitlab.com/gitlab-logo.png', title: 'GitLab Logo', }, '[gitlab-logo]: https://gitlab.com/gitlab-logo.png "GitLab Logo"', ), ), }, { markdown: ` --- title: 'layout' --- `, expectedDoc: doc( frontmatter( { ...source("---\ntitle: 'layout'\n---"), language: 'yaml' }, "title: 'layout'", ), ), }, { markdown: ` +++ title: 'layout' +++ `, expectedDoc: doc( frontmatter( { ...source("+++\ntitle: 'layout'\n+++"), language: 'toml' }, "title: 'layout'", ), ), }, { markdown: ` ;;; { title: 'layout' } ;;; `, expectedDoc: doc( frontmatter( { ...source(";;;\n{ title: 'layout' }\n;;;"), language: 'json' }, "{ title: 'layout' }", ), ), }, ...SAFE_AUDIO_EXT.map((extension) => { const src = `http://test.host/video.${extension}`; const markdown = `![audio](${src})`; return { markdown, expectedDoc: doc( paragraph( source(markdown), audio({ ...source(markdown), canonicalSrc: src, src, alt: 'audio', }), ), ), }; }), ...SAFE_VIDEO_EXT.map((extension) => { const src = `http://test.host/video.${extension}`; const markdown = `![video](${src})`; return { markdown, expectedDoc: doc( paragraph( source(markdown), video({ ...source(markdown), canonicalSrc: src, src, alt: 'video', }), ), ), }; }), ...DIAGRAM_LANGUAGES.map((language) => { const markdown = `\`\`\`${language} content \`\`\``; return { markdown, expectedDoc: doc(diagram({ ...source(markdown), language }, 'content')), }; }), { markdown: '[[_TOC_]]', expectedDoc: doc(tableOfContents(source('[[_TOC_]]'))), }, { markdown: '[TOC]', expectedDoc: doc(tableOfContents(source('[TOC]'))), }, ]; const runOnly = examples.find((example) => example.only === true); const runExamples = runOnly ? [runOnly] : examples; it.each(runExamples)( 'processes %s correctly', async ({ markdown, expectedDoc, expectedMarkdown }) => { const trimmed = markdown.trim(); const document = await deserialize(trimmed); expect(expectedDoc).not.toBe(false); expect(document.toJSON()).toEqual(expectedDoc.toJSON()); expect(serialize(document)).toEqual(expectedMarkdown ?? trimmed); }, ); /** * DISCLAIMER: THIS IS A SECURITY ORIENTED TEST THAT ENSURES * THE CLIENT-SIDE PARSER IGNORES DANGEROUS TAGS THAT ARE NOT * EXPLICITELY SUPPORTED. * * PLEASE CONSIDER THIS INFORMATION WHILE MODIFYING THESE TESTS */ it.each([ { markdown: ` `, expectedHtml: '

', }, { markdown: ` Hello `, expectedHtml: '

', }, { markdown: `

Header

`, expectedHtml: '

Header

', }, { markdown: ` Header and other text `, expectedHtml: '

Header and other text

', }, { markdown: ` `, expectedHtml: '

', }, { markdown: '
div
', expectedHtml: '

div

', }, ])( 'removes unknown tags and unsupported attributes from HTML output', async ({ markdown, expectedHtml }) => { const document = await deserialize(markdown); tiptapEditor.commands.setContent(document.toJSON()); expect(tiptapEditor.getHTML()).toEqual(expectedHtml); }, ); describe('attribute sanitization', () => { // eslint-disable-next-line no-script-url const protocolBasedInjectionSimpleNoSpaces = "javascript:alert('XSS');"; // eslint-disable-next-line no-script-url const protocolBasedInjectionSimpleSpacesBefore = "javascript: alert('XSS');"; const docWithImageFactory = (urlInput, urlOutput) => { const input = ``; return { input, expectedDoc: doc( paragraph( source(input), image({ ...source(input), src: urlOutput, canonicalSrc: urlOutput, }), ), ), }; }; const docWithLinkFactory = (urlInput, urlOutput) => { const input = `foo`; return { input, expectedDoc: doc( paragraph( source(input), link({ ...source(input), href: urlOutput, canonicalSrc: urlOutput }, 'foo'), ), ), }; }; it.each` desc | urlInput | urlOutput ${'protocol-based JS injection: simple, no spaces'} | ${protocolBasedInjectionSimpleNoSpaces} | ${null} ${'protocol-based JS injection: simple, spaces before'} | ${"javascript :alert('XSS');"} | ${null} ${'protocol-based JS injection: simple, spaces after'} | ${protocolBasedInjectionSimpleSpacesBefore} | ${null} ${'protocol-based JS injection: simple, spaces before and after'} | ${"javascript : alert('XSS');"} | ${null} ${'protocol-based JS injection: UTF-8 encoding'} | ${'javascript:'} | ${null} ${'protocol-based JS injection: long UTF-8 encoding'} | ${'javascript:'} | ${null} ${'protocol-based JS injection: long UTF-8 encoding without semicolons'} | ${'javascript:alert('XSS')'} | ${null} ${'protocol-based JS injection: hex encoding'} | ${'javascript:'} | ${null} ${'protocol-based JS injection: long hex encoding'} | ${'javascript:'} | ${null} ${'protocol-based JS injection: hex encoding without semicolons'} | ${'javascript:alert('XSS')'} | ${null} ${'protocol-based JS injection: Unicode'} | ${"\u0001java\u0003script:alert('XSS')"} | ${null} ${'protocol-based JS injection: spaces and entities'} | ${" javascript:alert('XSS');"} | ${null} ${'vbscript'} | ${'vbscript:alert(document.domain)'} | ${null} ${'protocol-based JS injection: preceding colon'} | ${":javascript:alert('XSS');"} | ${":javascript:alert('XSS');"} ${'protocol-based JS injection: null char'} | ${"java\0script:alert('XSS')"} | ${"java�script:alert('XSS')"} ${'protocol-based JS injection: invalid URL char'} | ${"java\\script:alert('XSS')"} | ${"java\\script:alert('XSS')"} `('sanitize $desc:\n\tURL "$urlInput" becomes "$urlOutput"', ({ urlInput, urlOutput }) => { const exampleFactories = [docWithImageFactory, docWithLinkFactory]; exampleFactories.forEach(async (exampleFactory) => { const { input, expectedDoc } = exampleFactory(urlInput, urlOutput); const document = await deserialize(input); expect(document.toJSON()).toEqual(expectedDoc.toJSON()); }); }); }); });