diff options
Diffstat (limited to 'spec/frontend/content_editor')
14 files changed, 669 insertions, 63 deletions
diff --git a/spec/frontend/content_editor/components/bubble_menus/link_spec.js b/spec/frontend/content_editor/components/bubble_menus/link_spec.js index ba6d8da9584..93204deb68c 100644 --- a/spec/frontend/content_editor/components/bubble_menus/link_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/link_spec.js @@ -182,7 +182,7 @@ describe('content_editor/components/bubble_menus/link', () => { it('updates prosemirror doc with new link', async () => { expect(tiptapEditor.getHTML()).toBe( - '<p>Download <a target="_blank" rel="noopener noreferrer nofollow" href="https://google.com" title="Search Google" canonicalsrc="https://google.com">PDF File</a></p>', + '<p>Download <a target="_blank" rel="noopener noreferrer nofollow" href="https://google.com" title="Search Google">PDF File</a></p>', ); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/media_spec.js b/spec/frontend/content_editor/components/bubble_menus/media_spec.js index 8839caea80e..fada4f06743 100644 --- a/spec/frontend/content_editor/components/bubble_menus/media_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/media_spec.js @@ -14,7 +14,7 @@ import { } from '../../test_constants'; const TIPTAP_IMAGE_HTML = `<p> - <img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon" data-canonical-src="https://gitlab.com/favicon.png"> + <img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon"> </p>`; const TIPTAP_AUDIO_HTML = `<p> diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 9ee3b017831..0ba2672100b 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -19,6 +19,7 @@ describe('ContentEditor', () => { const findEditorElement = () => wrapper.findByTestId('content-editor'); const findEditorContent = () => wrapper.findComponent(EditorContent); + const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver); const createWrapper = (propsData = {}) => { renderMarkdown = jest.fn(); @@ -119,4 +120,17 @@ describe('ContentEditor', () => { expect(wrapper.findComponent(FormattingBubbleMenu).exists()).toBe(true); }); + + it.each` + event + ${'loading'} + ${'loadingSuccess'} + ${'loadingError'} + `('broadcasts $event event triggered by editor-state-observer component', ({ event }) => { + createWrapper(); + + findEditorStateObserver().vm.$emit(event); + + expect(wrapper.emitted(event)).toHaveLength(1); + }); }); diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js index 351fd967719..62fec8d4e72 100644 --- a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js @@ -37,16 +37,17 @@ describe('content_editor/components/toolbar_more_dropdown', () => { }); describe.each` - name | contentType | command | params - ${'Code block'} | ${'codeBlock'} | ${'setNode'} | ${['codeBlock']} - ${'Details block'} | ${'details'} | ${'toggleList'} | ${['details', 'detailsContent']} - ${'Bullet list'} | ${'bulletList'} | ${'toggleList'} | ${['bulletList', 'listItem']} - ${'Ordered list'} | ${'orderedList'} | ${'toggleList'} | ${['orderedList', 'listItem']} - ${'Task list'} | ${'taskList'} | ${'toggleList'} | ${['taskList', 'taskItem']} - ${'Mermaid diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'mermaid' }]} - ${'PlantUML diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'plantuml' }]} - ${'Horizontal rule'} | ${'horizontalRule'} | ${'setHorizontalRule'} | ${[]} - `('when option $label is clicked', ({ name, command, contentType, params }) => { + name | contentType | command | params + ${'Code block'} | ${'codeBlock'} | ${'setNode'} | ${['codeBlock']} + ${'Details block'} | ${'details'} | ${'toggleList'} | ${['details', 'detailsContent']} + ${'Bullet list'} | ${'bulletList'} | ${'toggleList'} | ${['bulletList', 'listItem']} + ${'Ordered list'} | ${'orderedList'} | ${'toggleList'} | ${['orderedList', 'listItem']} + ${'Task list'} | ${'taskList'} | ${'toggleList'} | ${['taskList', 'taskItem']} + ${'Mermaid diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'mermaid' }]} + ${'PlantUML diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'plantuml' }]} + ${'Table of contents'} | ${'tableOfContents'} | ${'insertTableOfContents'} | ${[]} + ${'Horizontal rule'} | ${'horizontalRule'} | ${'setHorizontalRule'} | ${[]} + `('when option $name is clicked', ({ name, command, contentType, params }) => { let commands; let btn; diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index 2acb6e14ce0..8f194ff32e2 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -32,7 +32,7 @@ describe('content_editor/components/top_toolbar', () => { ${'link'} | ${{}} ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} - ${'task-list'} | ${{ contentType: 'taskList', iconName: 'list-task', label: 'Add a task list', editorCommand: 'toggleTaskList' }} + ${'task-list'} | ${{ contentType: 'taskList', iconName: 'list-task', label: 'Add a checklist', editorCommand: 'toggleTaskList' }} ${'image'} | ${{}} ${'table'} | ${{}} ${'more'} | ${{}} diff --git a/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap b/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap new file mode 100644 index 00000000000..fb091419ad9 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap @@ -0,0 +1,115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`content/components/wrappers/table_of_contents collects all headings and renders a nested list of headings 1`] = ` +<div + class="table-of-contents gl-border-1 gl-border-solid gl-border-gray-100 gl-mb-5 gl-p-4!" + data-testid="table-of-contents" +> + + Table of contents + + <li> + <a + href="#" + > + + Heading 1 + + </a> + + <ul> + <li> + <a + href="#" + > + + Heading 1.1 + + </a> + + <ul> + <li> + <a + href="#" + > + + Heading 1.1.1 + + </a> + + <!----> + </li> + </ul> + </li> + <li> + <a + href="#" + > + + Heading 1.2 + + </a> + + <ul> + <li> + <a + href="#" + > + + Heading 1.2.1 + + </a> + + <!----> + </li> + </ul> + </li> + <li> + <a + href="#" + > + + Heading 1.3 + + </a> + + <!----> + </li> + <li> + <a + href="#" + > + + Heading 1.4 + + </a> + + <ul> + <li> + <a + href="#" + > + + Heading 1.4.1 + + </a> + + <!----> + </li> + </ul> + </li> + </ul> + </li> + <li> + <a + href="#" + > + + Heading 2 + + </a> + + <!----> + </li> +</div> +`; diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js index 6017a145a87..1fdddce3962 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js @@ -1,12 +1,12 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { NodeViewWrapper } from '@tiptap/vue-2'; -import { selectedRect as getSelectedRect } from 'prosemirror-tables'; +import { selectedRect as getSelectedRect } from '@_ueberdosis/prosemirror-tables'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils'; -jest.mock('prosemirror-tables'); +jest.mock('@_ueberdosis/prosemirror-tables'); describe('content/components/wrappers/table_cell_base', () => { let wrapper; diff --git a/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js b/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js new file mode 100644 index 00000000000..bfda89a8b09 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js @@ -0,0 +1,84 @@ +import { nextTick } from 'vue'; +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import Heading from '~/content_editor/extensions/heading'; +import Diagram from '~/content_editor/extensions/diagram'; +import TableOfContentsWrapper from '~/content_editor/components/wrappers/table_of_contents.vue'; +import { createTestEditor, createDocBuilder, emitEditorEvent } from '../../test_utils'; + +describe('content/components/wrappers/table_of_contents', () => { + let wrapper; + let tiptapEditor; + let contentEditor; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [Heading, Diagram] }); + contentEditor = { renderDiagram: jest.fn().mockResolvedValue('url/to/some/diagram') }; + eventHub = eventHubFactory(); + }; + + const createWrapper = async () => { + wrapper = mountExtended(TableOfContentsWrapper, { + propsData: { + editor: tiptapEditor, + node: { + attrs: {}, + }, + }, + stubs: { + NodeViewWrapper: stubComponent(NodeViewWrapper), + }, + provide: { + contentEditor, + tiptapEditor, + eventHub, + }, + }); + }; + + beforeEach(async () => { + buildEditor(); + createWrapper(); + + const { + builders: { heading, doc }, + } = createDocBuilder({ + tiptapEditor, + names: { + heading: { nodeType: Heading.name }, + }, + }); + + const initialDoc = doc( + heading({ level: 1 }, 'Heading 1'), + heading({ level: 2 }, 'Heading 1.1'), + heading({ level: 3 }, 'Heading 1.1.1'), + heading({ level: 2 }, 'Heading 1.2'), + heading({ level: 3 }, 'Heading 1.2.1'), + heading({ level: 2 }, 'Heading 1.3'), + heading({ level: 2 }, 'Heading 1.4'), + heading({ level: 3 }, 'Heading 1.4.1'), + heading({ level: 1 }, 'Heading 2'), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + await emitEditorEvent({ event: 'update', tiptapEditor }); + await nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a node-view-wrapper as a ul element', () => { + expect(wrapper.findComponent(NodeViewWrapper).props().as).toBe('ul'); + }); + + it('collects all headings and renders a nested list of headings', () => { + expect(wrapper.findComponent(NodeViewWrapper).element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/content_editor/extensions/image_spec.js b/spec/frontend/content_editor/extensions/image_spec.js index 256f7bad309..f73b0143fd9 100644 --- a/spec/frontend/content_editor/extensions/image_spec.js +++ b/spec/frontend/content_editor/extensions/image_spec.js @@ -35,7 +35,7 @@ describe('content_editor/extensions/image', () => { tiptapEditor.commands.setContent(initialDoc.toJSON()); expect(tiptapEditor.getHTML()).toEqual( - '<p><img src="/-/wikis/uploads/image.jpg" alt="image" title="this is an image" data-canonical-src="uploads/image.jpg"></p>', + '<p><img src="/-/wikis/uploads/image.jpg" alt="image" title="this is an image"></p>', ); }); }); diff --git a/spec/frontend/content_editor/markdown_processing_spec_helper.js b/spec/frontend/content_editor/markdown_processing_spec_helper.js index 41442dd8388..228d009e42c 100644 --- a/spec/frontend/content_editor/markdown_processing_spec_helper.js +++ b/spec/frontend/content_editor/markdown_processing_spec_helper.js @@ -2,7 +2,6 @@ import fs from 'fs'; import jsYaml from 'js-yaml'; import { memoize } from 'lodash'; import { createContentEditor } from '~/content_editor'; -import { setTestTimeoutOnce } from 'helpers/timeout'; const getFocusedMarkdownExamples = memoize( () => process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || [], @@ -76,9 +75,6 @@ export const describeMarkdownProcessing = (description, markdownYamlPath) => { } it(exampleName, async () => { - if (name === 'frontmatter_toml') { - setTestTimeoutOnce(2000); - } await testSerializesHtmlToMarkdownForElement(example); }); }); diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js index 48adceaab58..7ae0a7c13c1 100644 --- a/spec/frontend/content_editor/remark_markdown_processing_spec.js +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -5,6 +5,7 @@ import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; 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'; @@ -15,6 +16,7 @@ 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'; @@ -37,6 +39,7 @@ const tiptapEditor = createTestEditor({ CodeBlockHighlight, FootnoteDefinition, FootnoteReference, + Frontmatter, HardBreak, Heading, HorizontalRule, @@ -45,6 +48,7 @@ const tiptapEditor = createTestEditor({ Link, ListItem, OrderedList, + ReferenceDefinition, Sourcemap, Strike, Table, @@ -69,6 +73,7 @@ const { div, footnoteDefinition, footnoteReference, + frontmatter, hardBreak, heading, horizontalRule, @@ -78,6 +83,7 @@ const { listItem, orderedList, pre, + referenceDefinition, strike, table, tableRow, @@ -96,6 +102,7 @@ const { codeBlock: { nodeType: CodeBlockHighlight.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 }, @@ -105,6 +112,7 @@ const { 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 }, @@ -253,7 +261,12 @@ describe('Client side Markdown processing', () => { expectedDoc: doc( paragraph( source('<img src="bar" alt="foo" />'), - image({ ...source('<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }), + image({ + ...source('<img src="bar" alt="foo" />'), + alt: 'foo', + canonicalSrc: 'bar', + src: 'bar', + }), ), ), }, @@ -271,7 +284,12 @@ describe('Client side Markdown processing', () => { ), paragraph( source('<img src="bar" alt="foo" />'), - image({ ...source('<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }), + image({ + ...source('<img src="bar" alt="foo" />'), + alt: 'foo', + src: 'bar', + canonicalSrc: 'bar', + }), ), ), }, @@ -284,6 +302,7 @@ describe('Client side Markdown processing', () => { { ...source('[GitLab](https://gitlab.com "Go to GitLab")'), href: 'https://gitlab.com', + canonicalSrc: 'https://gitlab.com', title: 'Go to GitLab', }, 'GitLab', @@ -302,6 +321,7 @@ describe('Client side Markdown processing', () => { { ...source('[GitLab](https://gitlab.com "Go to GitLab")'), href: 'https://gitlab.com', + canonicalSrc: 'https://gitlab.com', title: 'Go to GitLab', }, 'GitLab', @@ -318,6 +338,7 @@ describe('Client side Markdown processing', () => { link( { ...source('www.commonmark.org'), + canonicalSrc: 'http://www.commonmark.org', href: 'http://www.commonmark.org', }, 'www.commonmark.org', @@ -334,6 +355,7 @@ describe('Client side Markdown processing', () => { link( { ...source('www.commonmark.org/help'), + canonicalSrc: 'http://www.commonmark.org/help', href: 'http://www.commonmark.org/help', }, 'www.commonmark.org/help', @@ -351,6 +373,7 @@ describe('Client side Markdown processing', () => { link( { ...source('hello+xyz@mail.example'), + canonicalSrc: 'mailto:hello+xyz@mail.example', href: 'mailto:hello+xyz@mail.example', }, 'hello+xyz@mail.example', @@ -369,6 +392,7 @@ describe('Client side Markdown processing', () => { { sourceMapKey: null, sourceMarkdown: null, + canonicalSrc: 'https://gitlab.com', href: 'https://gitlab.com', }, 'https://gitlab.com', @@ -398,6 +422,7 @@ hard line break`, 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', }), @@ -591,7 +616,12 @@ two paragraph( source('List item with an image ![bar](foo.png)'), 'List item with an image', - image({ ...source('![bar](foo.png)'), alt: 'bar', src: 'foo.png' }), + image({ + ...source('![bar](foo.png)'), + alt: 'bar', + canonicalSrc: 'foo.png', + src: 'foo.png', + }), ), ), ), @@ -940,8 +970,17 @@ Paragraph paragraph( source('[![moon](moon.jpg)](/uri)'), link( - { ...source('[![moon](moon.jpg)](/uri)'), href: '/uri' }, - image({ ...source('![moon](moon.jpg)'), src: 'moon.jpg', alt: 'moon' }), + { + ...source('[![moon](moon.jpg)](/uri)'), + canonicalSrc: '/uri', + href: '/uri', + }, + image({ + ...source('![moon](moon.jpg)'), + canonicalSrc: 'moon.jpg', + src: 'moon.jpg', + alt: 'moon', + }), ), ), ), @@ -971,12 +1010,26 @@ Paragraph source('~[moon](moon.jpg) and [sun](sun.jpg)~'), strike( source('~[moon](moon.jpg) and [sun](sun.jpg)~'), - link({ ...source('[moon](moon.jpg)'), href: 'moon.jpg' }, 'moon'), + 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' }, 'sun'), + link( + { + ...source('[sun](sun.jpg)'), + href: 'sun.jpg', + canonicalSrc: 'sun.jpg', + }, + 'sun', + ), ), ), ), @@ -1079,6 +1132,107 @@ _world_. ), ), }, + { + 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' }", + ), + ), + }, ]; const runOnly = examples.find((example) => example.only === true); @@ -1090,7 +1244,7 @@ _world_. const trimmed = markdown.trim(); const document = await deserialize(trimmed); - expect(expectedDoc).not.toBeFalsy(); + expect(expectedDoc).not.toBe(false); expect(document.toJSON()).toEqual(expectedDoc.toJSON()); expect(serialize(document)).toEqual(expectedMarkdown ?? trimmed); }, @@ -1155,4 +1309,72 @@ body { 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 = `<img src="${urlInput}">`; + + return { + input, + expectedDoc: doc( + paragraph( + source(input), + image({ + ...source(input), + src: urlOutput, + canonicalSrc: urlOutput, + }), + ), + ), + }; + }; + + const docWithLinkFactory = (urlInput, urlOutput) => { + const input = `<a href="${urlInput}">foo</a>`; + + 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()); + }); + }); + }); }); diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js index 116a26cf7d5..4a57c7b1942 100644 --- a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js +++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js @@ -16,6 +16,7 @@ import FigureCaption from '~/content_editor/extensions/figure_caption'; import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; import FootnoteReference from '~/content_editor/extensions/footnote_reference'; import FootnotesSection from '~/content_editor/extensions/footnotes_section'; +import Frontmatter from '~/content_editor/extensions/frontmatter'; import HardBreak from '~/content_editor/extensions/hard_break'; import Heading from '~/content_editor/extensions/heading'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; @@ -26,6 +27,7 @@ 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 ReferenceDefinition from '~/content_editor/extensions/reference_definition'; import Strike from '~/content_editor/extensions/strike'; import Table from '~/content_editor/extensions/table'; import TableCell from '~/content_editor/extensions/table_cell'; @@ -51,6 +53,7 @@ const tiptapEditor = createTestEditor({ FootnoteDefinition, FootnoteReference, FootnotesSection, + Frontmatter, Figure, FigureCaption, HardBreak, @@ -63,6 +66,7 @@ const tiptapEditor = createTestEditor({ Link, ListItem, OrderedList, + ReferenceDefinition, Strike, Table, TableCell, diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 509cda3046c..0e5281be9bf 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -24,6 +24,7 @@ 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'; @@ -63,6 +64,7 @@ const tiptapEditor = createTestEditor({ Link, ListItem, OrderedList, + ReferenceDefinition, Sourcemap, Strike, Table, @@ -104,6 +106,7 @@ const { listItem, orderedList, paragraph, + referenceDefinition, strike, table, tableCell, @@ -139,6 +142,7 @@ const { listItem: { nodeType: ListItem.name }, orderedList: { nodeType: OrderedList.name }, paragraph: { nodeType: Paragraph.name }, + referenceDefinition: { nodeType: ReferenceDefinition.name }, strike: { markType: Strike.name }, table: { nodeType: Table.name }, tableCell: { nodeType: TableCell.name }, @@ -243,6 +247,37 @@ describe('markdownSerializer', () => { ).toBe('[download file](file.zip "click here to download")'); }); + it('correctly serializes link references', () => { + expect( + serialize( + paragraph( + link( + { + href: 'gitlab-url', + isReference: true, + }, + 'GitLab', + ), + ), + ), + ).toBe('[GitLab][gitlab-url]'); + }); + + it('correctly serializes image references', () => { + expect( + serialize( + paragraph( + image({ + canonicalSrc: 'gitlab-url', + src: 'image.svg', + alt: 'GitLab', + isReference: true, + }), + ), + ), + ).toBe('![GitLab][gitlab-url]'); + }); + it('correctly serializes strikethrough', () => { expect(serialize(paragraph(strike('deleted content')))).toBe('~~deleted content~~'); }); @@ -1163,6 +1198,38 @@ Oranges are orange [^1] ); }); + it('correctly serializes reference definition', () => { + expect( + serialize( + referenceDefinition('[gitlab]: https://gitlab.com'), + referenceDefinition('[foobar]: foobar.com'), + ), + ).toBe( + ` +[gitlab]: https://gitlab.com +[foobar]: foobar.com`.trimLeft(), + ); + }); + + it('correctly adds a space between a reference definition and a block content', () => { + expect( + serialize( + paragraph('paragraph'), + referenceDefinition('[gitlab]: https://gitlab.com'), + referenceDefinition('[foobar]: foobar.com'), + heading({ level: 2 }, 'heading'), + ), + ).toBe( + ` +paragraph + +[gitlab]: https://gitlab.com +[foobar]: foobar.com + +## heading`.trimLeft(), + ); + }); + const defaultEditAction = (initialContent) => { tiptapEditor.chain().setContent(initialContent.toJSON()).insertContent(' modified').run(); }; @@ -1177,42 +1244,49 @@ Oranges are orange [^1] }; it.each` - mark | markdown | modifiedMarkdown | editAction - ${'bold'} | ${'**bold**'} | ${'**bold modified**'} | ${defaultEditAction} - ${'bold'} | ${'__bold__'} | ${'__bold modified__'} | ${defaultEditAction} - ${'bold'} | ${'<strong>bold</strong>'} | ${'<strong>bold modified</strong>'} | ${defaultEditAction} - ${'bold'} | ${'<b>bold</b>'} | ${'<b>bold modified</b>'} | ${defaultEditAction} - ${'italic'} | ${'_italic_'} | ${'_italic modified_'} | ${defaultEditAction} - ${'italic'} | ${'*italic*'} | ${'*italic modified*'} | ${defaultEditAction} - ${'italic'} | ${'<em>italic</em>'} | ${'<em>italic modified</em>'} | ${defaultEditAction} - ${'italic'} | ${'<i>italic</i>'} | ${'<i>italic modified</i>'} | ${defaultEditAction} - ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'} | ${defaultEditAction} - ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'} | ${defaultEditAction} - ${'link'} | ${'link www.gitlab.com'} | ${'modified link www.gitlab.com'} | ${prependContentEditAction} - ${'link'} | ${'link https://www.gitlab.com'} | ${'modified link https://www.gitlab.com'} | ${prependContentEditAction} - ${'link'} | ${'link(https://www.gitlab.com)'} | ${'modified link(https://www.gitlab.com)'} | ${prependContentEditAction} - ${'link'} | ${'link(engineering@gitlab.com)'} | ${'modified link(engineering@gitlab.com)'} | ${prependContentEditAction} - ${'link'} | ${'link <https://www.gitlab.com>'} | ${'modified link <https://www.gitlab.com>'} | ${prependContentEditAction} - ${'link'} | ${'link [https://www.gitlab.com>'} | ${'modified link \\[https://www.gitlab.com>'} | ${prependContentEditAction} - ${'link'} | ${'link <https://www.gitlab.com'} | ${'modified link <https://www.gitlab.com'} | ${prependContentEditAction} - ${'link'} | ${'link https://www.gitlab.com>'} | ${'modified link https://www.gitlab.com>'} | ${prependContentEditAction} - ${'link'} | ${'link **https://www.gitlab.com]**'} | ${'modified link [**https://www.gitlab.com\\]**](https://www.gitlab.com%5D)'} | ${prependContentEditAction} - ${'code'} | ${'`code`'} | ${'`code modified`'} | ${defaultEditAction} - ${'code'} | ${'<code>code</code>'} | ${'<code>code modified</code>'} | ${defaultEditAction} - ${'strike'} | ${'~~striked~~'} | ${'~~striked modified~~'} | ${defaultEditAction} - ${'strike'} | ${'<del>striked</del>'} | ${'<del>striked modified</del>'} | ${defaultEditAction} - ${'strike'} | ${'<strike>striked</strike>'} | ${'<strike>striked modified</strike>'} | ${defaultEditAction} - ${'strike'} | ${'<s>striked</s>'} | ${'<s>striked modified</s>'} | ${defaultEditAction} - ${'list'} | ${'- list item'} | ${'- list item modified'} | ${defaultEditAction} - ${'list'} | ${'* list item'} | ${'* list item modified'} | ${defaultEditAction} - ${'list'} | ${'+ list item'} | ${'+ list item modified'} | ${defaultEditAction} - ${'list'} | ${'- list item 1\n- list item 2'} | ${'- list item 1\n- list item 2 modified'} | ${defaultEditAction} - ${'list'} | ${'2) list item'} | ${'2) list item modified'} | ${defaultEditAction} - ${'list'} | ${'1. list item'} | ${'1. list item modified'} | ${defaultEditAction} - ${'taskList'} | ${'2) [ ] task list item'} | ${'2) [ ] task list item modified'} | ${defaultEditAction} - ${'taskList'} | ${'2) [x] task list item'} | ${'2) [x] task list item modified'} | ${defaultEditAction} + mark | markdown | modifiedMarkdown | editAction + ${'bold'} | ${'**bold**'} | ${'**bold modified**'} | ${defaultEditAction} + ${'bold'} | ${'__bold__'} | ${'__bold modified__'} | ${defaultEditAction} + ${'bold'} | ${'<strong>bold</strong>'} | ${'<strong>bold modified</strong>'} | ${defaultEditAction} + ${'bold'} | ${'<b>bold</b>'} | ${'<b>bold modified</b>'} | ${defaultEditAction} + ${'italic'} | ${'_italic_'} | ${'_italic modified_'} | ${defaultEditAction} + ${'italic'} | ${'*italic*'} | ${'*italic modified*'} | ${defaultEditAction} + ${'italic'} | ${'<em>italic</em>'} | ${'<em>italic modified</em>'} | ${defaultEditAction} + ${'italic'} | ${'<i>italic</i>'} | ${'<i>italic modified</i>'} | ${defaultEditAction} + ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'} | ${defaultEditAction} + ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'} | ${defaultEditAction} + ${'link'} | ${'link www.gitlab.com'} | ${'modified link www.gitlab.com'} | ${prependContentEditAction} + ${'link'} | ${'link https://www.gitlab.com'} | ${'modified link https://www.gitlab.com'} | ${prependContentEditAction} + ${'link'} | ${'link(https://www.gitlab.com)'} | ${'modified link(https://www.gitlab.com)'} | ${prependContentEditAction} + ${'link'} | ${'link(engineering@gitlab.com)'} | ${'modified link(engineering@gitlab.com)'} | ${prependContentEditAction} + ${'link'} | ${'link <https://www.gitlab.com>'} | ${'modified link <https://www.gitlab.com>'} | ${prependContentEditAction} + ${'link'} | ${'link [https://www.gitlab.com>'} | ${'modified link \\[https://www.gitlab.com>'} | ${prependContentEditAction} + ${'link'} | ${'link <https://www.gitlab.com'} | ${'modified link <https://www.gitlab.com'} | ${prependContentEditAction} + ${'link'} | ${'link https://www.gitlab.com>'} | ${'modified link https://www.gitlab.com>'} | ${prependContentEditAction} + ${'link'} | ${'link https://www.gitlab.com/path'} | ${'modified link https://www.gitlab.com/path'} | ${prependContentEditAction} + ${'link'} | ${'link https://www.gitlab.com?query=search'} | ${'modified link https://www.gitlab.com?query=search'} | ${prependContentEditAction} + ${'link'} | ${'link https://www.gitlab.com/#fragment'} | ${'modified link https://www.gitlab.com/#fragment'} | ${prependContentEditAction} + ${'link'} | ${'link https://www.gitlab.com/?query=search'} | ${'modified link https://www.gitlab.com/?query=search'} | ${prependContentEditAction} + ${'link'} | ${'link https://www.gitlab.com#fragment'} | ${'modified link https://www.gitlab.com#fragment'} | ${prependContentEditAction} + ${'link'} | ${'link **https://www.gitlab.com]**'} | ${'modified link **https://www.gitlab.com\\]**'} | ${prependContentEditAction} + ${'code'} | ${'`code`'} | ${'`code modified`'} | ${defaultEditAction} + ${'code'} | ${'<code>code</code>'} | ${'<code>code modified</code>'} | ${defaultEditAction} + ${'strike'} | ${'~~striked~~'} | ${'~~striked modified~~'} | ${defaultEditAction} + ${'strike'} | ${'<del>striked</del>'} | ${'<del>striked modified</del>'} | ${defaultEditAction} + ${'strike'} | ${'<strike>striked</strike>'} | ${'<strike>striked modified</strike>'} | ${defaultEditAction} + ${'strike'} | ${'<s>striked</s>'} | ${'<s>striked modified</s>'} | ${defaultEditAction} + ${'list'} | ${'- list item'} | ${'- list item modified'} | ${defaultEditAction} + ${'list'} | ${'* list item'} | ${'* list item modified'} | ${defaultEditAction} + ${'list'} | ${'+ list item'} | ${'+ list item modified'} | ${defaultEditAction} + ${'list'} | ${'- list item 1\n- list item 2'} | ${'- list item 1\n- list item 2 modified'} | ${defaultEditAction} + ${'list'} | ${'2) list item'} | ${'2) list item modified'} | ${defaultEditAction} + ${'list'} | ${'1. list item'} | ${'1. list item modified'} | ${defaultEditAction} + ${'taskList'} | ${'2) [ ] task list item'} | ${'2) [ ] task list item modified'} | ${defaultEditAction} + ${'taskList'} | ${'2) [x] task list item'} | ${'2) [x] task list item modified'} | ${defaultEditAction} + ${'image'} | ${'![image](image.png)'} | ${'![image](image.png) modified'} | ${defaultEditAction} + ${'footnoteReference'} | ${'[^1] footnote\n\n[^1]: footnote definition'} | ${'modified [^1] footnote\n\n[^1]: footnote definition'} | ${prependContentEditAction} `( - 'preserves original $mark syntax when sourceMarkdown is available for $content', + 'preserves original $mark syntax when sourceMarkdown is available for $markdown', async ({ markdown, modifiedMarkdown, editAction }) => { const { document } = await remarkMarkdownDeserializer().deserialize({ schema: tiptapEditor.schema, diff --git a/spec/frontend/content_editor/services/table_of_contents_utils_spec.js b/spec/frontend/content_editor/services/table_of_contents_utils_spec.js new file mode 100644 index 00000000000..7f63c2171c2 --- /dev/null +++ b/spec/frontend/content_editor/services/table_of_contents_utils_spec.js @@ -0,0 +1,96 @@ +import Heading from '~/content_editor/extensions/heading'; +import { toTree, getHeadings } from '~/content_editor/services/table_of_contents_utils'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/services/table_of_content_utils', () => { + describe('toTree', () => { + it('should fills in gaps in heading levels and convert headings to a tree', () => { + expect( + toTree([ + { level: 3, text: '3' }, + { level: 2, text: '2' }, + ]), + ).toEqual([ + expect.objectContaining({ + level: 1, + text: '', + subHeadings: [ + expect.objectContaining({ + level: 2, + text: '', + subHeadings: [expect.objectContaining({ level: 3, text: '3', subHeadings: [] })], + }), + expect.objectContaining({ level: 2, text: '2', subHeadings: [] }), + ], + }), + ]); + }); + }); + + describe('getHeadings', () => { + const tiptapEditor = createTestEditor({ + extensions: [Heading], + }); + + const { + builders: { heading, doc }, + } = createDocBuilder({ + tiptapEditor, + names: { + heading: { nodeType: Heading.name }, + }, + }); + + it('gets all headings as a tree in a tiptap document', () => { + const initialDoc = doc( + heading({ level: 1 }, 'Heading 1'), + heading({ level: 2 }, 'Heading 1.1'), + heading({ level: 3 }, 'Heading 1.1.1'), + heading({ level: 2 }, 'Heading 1.2'), + heading({ level: 3 }, 'Heading 1.2.1'), + heading({ level: 2 }, 'Heading 1.3'), + heading({ level: 2 }, 'Heading 1.4'), + heading({ level: 3 }, 'Heading 1.4.1'), + heading({ level: 1 }, 'Heading 2'), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + expect(getHeadings(tiptapEditor)).toEqual([ + expect.objectContaining({ + level: 1, + text: 'Heading 1', + subHeadings: [ + expect.objectContaining({ + level: 2, + text: 'Heading 1.1', + subHeadings: [ + expect.objectContaining({ level: 3, text: 'Heading 1.1.1', subHeadings: [] }), + ], + }), + expect.objectContaining({ + level: 2, + text: 'Heading 1.2', + subHeadings: [ + expect.objectContaining({ level: 3, text: 'Heading 1.2.1', subHeadings: [] }), + ], + }), + expect.objectContaining({ level: 2, text: 'Heading 1.3', subHeadings: [] }), + expect.objectContaining({ + level: 2, + text: 'Heading 1.4', + subHeadings: [ + expect.objectContaining({ level: 3, text: 'Heading 1.4.1', subHeadings: [] }), + ], + }), + ], + }), + expect.objectContaining({ + level: 1, + text: 'Heading 2', + subHeadings: [], + }), + ]); + }); + }); +}); |