diff options
Diffstat (limited to 'spec/frontend/content_editor')
11 files changed, 1240 insertions, 134 deletions
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap index 7abd6b422ad..b54f7cf17c8 100644 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap @@ -16,15 +16,13 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen <!----> <li role=\\"presentation\\" class=\\"gl-px-3!\\"> <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\"> - <div placeholder=\\"Link URL\\"> - <div role=\\"group\\" class=\\"input-group\\"> - <!----> - <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\"> - <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\"> - <!----> - <!----> <span class=\\"gl-button-text\\">Apply</span></button></div> - <!----> - </div> + <div role=\\"group\\" class=\\"input-group\\" placeholder=\\"Link URL\\"> + <!----> + <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\"> + <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\"> + <!----> + <!----> <span class=\\"gl-button-text\\">Apply</span></button></div> + <!----> </div> </form> </li> diff --git a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js index 3a15ea45f40..646d068e795 100644 --- a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js @@ -1,21 +1,33 @@ import { BubbleMenu } from '@tiptap/vue-2'; -import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; -import Vue from 'vue'; +import { + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlSearchBoxByType, + GlFormInput, +} from '@gitlab/ui'; +import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block.vue'; import eventHubFactory from '~/helpers/event_hub_factory'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import Diagram from '~/content_editor/extensions/diagram'; import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; import { createTestEditor, emitEditorEvent } from '../../test_utils'; +const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); + describe('content_editor/components/bubble_menus/code_block', () => { let wrapper; let tiptapEditor; + let contentEditor; let bubbleMenu; let eventHub; const buildEditor = () => { - tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight, Diagram] }); + contentEditor = { renderDiagram: jest.fn() }; eventHub = eventHubFactory(); }; @@ -23,8 +35,12 @@ describe('content_editor/components/bubble_menus/code_block', () => { wrapper = mountExtended(CodeBlockBubbleMenu, { provide: { tiptapEditor, + contentEditor, eventHub, }, + stubs: { + GlDropdownItem: stubComponent(GlDropdownItem), + }, }); }; @@ -36,7 +52,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { checked: x.props('isChecked'), })); - beforeEach(() => { + beforeEach(async () => { buildEditor(); buildWrapper(); }); @@ -73,6 +89,15 @@ describe('content_editor/components/bubble_menus/code_block', () => { expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Javascript'); }); + it('selects diagram sytnax for mermaid', async () => { + tiptapEditor.commands.insertContent('<pre lang="mermaid">test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Diagram (mermaid)'); + }); + it("selects Custom (syntax) if the language doesn't exist in the list", async () => { tiptapEditor.commands.insertContent('<pre lang="nomnoml">test</pre>'); bubbleMenu = wrapper.findComponent(BubbleMenu); @@ -104,22 +129,57 @@ describe('content_editor/components/bubble_menus/code_block', () => { }); }); + describe('preview button', () => { + it('does not appear for a regular code block', async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + expect(wrapper.findByTestId('preview-diagram').exists()).toBe(false); + }); + + it.each` + diagramType | diagramCode + ${'mermaid'} | ${'<pre lang="mermaid">graph TD;\n A-->B;</pre>'} + ${'nomnoml'} | ${'<img data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,WzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybl0=">'} + `('toggles preview for a $diagramType diagram', async ({ diagramType, diagramCode }) => { + tiptapEditor.commands.insertContent(diagramCode); + + await nextTick(); + await wrapper.findByTestId('preview-diagram').vm.$emit('click'); + + expect(tiptapEditor.getAttributes(Diagram.name)).toEqual({ + isDiagram: true, + language: diagramType, + showPreview: false, + }); + + await wrapper.findByTestId('preview-diagram').vm.$emit('click'); + + expect(tiptapEditor.getAttributes(Diagram.name)).toEqual({ + isDiagram: true, + language: diagramType, + showPreview: true, + }); + }); + }); + describe('when opened and search is changed', () => { beforeEach(async () => { tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js'); - await Vue.nextTick(); + await nextTick(); }); it('shows dropdown items', () => { - expect(findDropdownItemsData()).toEqual([ - { text: 'Javascript', visible: true, checked: true }, - { text: 'Java', visible: true, checked: false }, - { text: 'Javascript', visible: false, checked: false }, - { text: 'JSON', visible: true, checked: false }, - ]); + expect(findDropdownItemsData()).toEqual( + expect.arrayContaining([ + { text: 'Javascript', visible: true, checked: true }, + { text: 'Java', visible: true, checked: false }, + { text: 'Javascript', visible: false, checked: false }, + { text: 'JSON', visible: true, checked: false }, + ]), + ); }); describe('when dropdown item is clicked', () => { @@ -128,7 +188,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { findDropdownItems().at(1).vm.$emit('click'); - await Vue.nextTick(); + await nextTick(); }); it('loads language', () => { @@ -152,5 +212,78 @@ describe('content_editor/components/bubble_menus/code_block', () => { expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Java'); }); }); + + describe('Create custom type', () => { + beforeEach(async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + await wrapper.findComponent(GlDropdown).vm.show(); + await wrapper.findByTestId('create-custom-type').trigger('click'); + }); + + it('shows custom language input form and hides dropdown items', () => { + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(false); + expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(false); + expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(true); + }); + + describe('on clicking back', () => { + it('hides the custom language input form and shows dropdown items', async () => { + await wrapper.findByRole('button', { name: 'Go back' }).trigger('click'); + + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); + expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(true); + expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(false); + }); + }); + + describe('on clicking cancel', () => { + it('hides the custom language input form and shows dropdown items', async () => { + await wrapper.findByRole('button', { name: 'Cancel' }).trigger('click'); + + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); + expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(true); + expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(false); + }); + }); + + describe('on dropdown hide', () => { + it('hides the form', async () => { + wrapper.findComponent(GlFormInput).setValue('foobar'); + await wrapper.findComponent(GlDropdown).vm.$emit('hide'); + + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); + expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(true); + expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(false); + }); + }); + + describe('on clicking apply', () => { + beforeEach(async () => { + wrapper.findComponent(GlFormInput).setValue('foobar'); + await wrapper.findComponent(GlDropdownForm).vm.$emit('submit', createFakeEvent()); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }); + + it('hides the custom language input form and shows dropdown items', async () => { + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); + expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(true); + expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(false); + }); + + it('updates dropdown value to the custom language type', () => { + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (foobar)'); + }); + + it('updates tiptap editor to the custom language type', () => { + expect(tiptapEditor.getAttributes(CodeBlockHighlight.name)).toEqual( + expect.objectContaining({ + language: 'foobar', + }), + ); + }); + }); + }); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js new file mode 100644 index 00000000000..0334a18c9a1 --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js @@ -0,0 +1,54 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ToolbarMoreDropdown from '~/content_editor/components/toolbar_more_dropdown.vue'; +import Diagram from '~/content_editor/extensions/diagram'; +import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; + +describe('content_editor/components/toolbar_more_dropdown', () => { + let wrapper; + let tiptapEditor; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ + extensions: [Diagram, HorizontalRule], + }); + }; + + const buildWrapper = (propsData = {}) => { + wrapper = mountExtended(ToolbarMoreDropdown, { + provide: { + tiptapEditor, + }, + propsData, + }); + }; + + beforeEach(() => { + buildEditor(); + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + label | contentType | data + ${'Mermaid diagram'} | ${'diagram'} | ${{ language: 'mermaid' }} + ${'PlantUML diagram'} | ${'diagram'} | ${{ language: 'plantuml' }} + ${'Horizontal rule'} | ${'horizontalRule'} | ${undefined} + `('when option $label is clicked', ({ label, contentType, data }) => { + it(`inserts a ${contentType}`, async () => { + const commands = mockChainedCommands(tiptapEditor, ['setNode', 'focus', 'run']); + + const btn = wrapper.findByRole('menuitem', { name: label }); + await btn.trigger('click'); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.setNode).toHaveBeenCalledWith(contentType, data); + expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted('execute')).toEqual([[{ contentType }]]); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index ec58877470c..d98a9a52aff 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -23,20 +23,21 @@ describe('content_editor/components/top_toolbar', () => { }); describe.each` - testId | controlProps - ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} - ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} - ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} - ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} - ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} - ${'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' }} - ${'details'} | ${{ contentType: 'details', iconName: 'details-block', label: 'Add a collapsible section', editorCommand: 'toggleDetails' }} - ${'horizontal-rule'} | ${{ contentType: 'horizontalRule', iconName: 'dash', label: 'Add a horizontal rule', editorCommand: 'setHorizontalRule' }} - ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} - ${'text-styles'} | ${{}} - ${'link'} | ${{}} - ${'image'} | ${{}} + testId | controlProps + ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} + ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} + ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} + ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} + ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} + ${'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' }} + ${'details'} | ${{ contentType: 'details', iconName: 'details-block', label: 'Add a collapsible section', editorCommand: 'toggleDetails' }} + ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} + ${'text-styles'} | ${{}} + ${'link'} | ${{}} + ${'image'} | ${{}} + ${'table'} | ${{}} + ${'more'} | ${{}} `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js index a564959a3a6..17a365e12bb 100644 --- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js +++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js @@ -1,8 +1,14 @@ import { nextTick } from 'vue'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; -import { shallowMount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import SandboxedMermaid from '~/behaviors/components/sandboxed_mermaid.vue'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import Diagram from '~/content_editor/extensions/diagram'; import CodeBlockWrapper from '~/content_editor/components/wrappers/code_block.vue'; import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; +import { emitEditorEvent, createTestEditor } from '../../test_utils'; jest.mock('~/content_editor/services/code_block_language_loader'); @@ -10,22 +16,43 @@ describe('content/components/wrappers/code_block', () => { const language = 'yaml'; let wrapper; let updateAttributesFn; + let tiptapEditor; + let contentEditor; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight, Diagram] }); + contentEditor = { renderDiagram: jest.fn().mockResolvedValue('url/to/some/diagram') }; + eventHub = eventHubFactory(); + }; const createWrapper = async (nodeAttrs = { language }) => { updateAttributesFn = jest.fn(); - wrapper = shallowMount(CodeBlockWrapper, { + wrapper = mountExtended(CodeBlockWrapper, { propsData: { + editor: tiptapEditor, node: { attrs: nodeAttrs, }, updateAttributes: updateAttributesFn, }, + stubs: { + NodeViewContent: stubComponent(NodeViewContent), + NodeViewWrapper: stubComponent(NodeViewWrapper), + }, + provide: { + contentEditor, + tiptapEditor, + eventHub, + }, }); }; beforeEach(() => { - codeBlockLanguageLoader.findLanguageBySyntax.mockReturnValue({ syntax: language }); + buildEditor(); + + codeBlockLanguageLoader.findOrCreateLanguageBySyntax.mockReturnValue({ syntax: language }); }); afterEach(() => { @@ -68,4 +95,56 @@ describe('content/components/wrappers/code_block', () => { expect(updateAttributesFn).toHaveBeenCalledWith({ language }); }); + + describe('diagrams', () => { + beforeEach(() => { + jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(true); + }); + + it('does not render a preview if showPreview: false', async () => { + createWrapper({ language: 'plantuml', isDiagram: true, showPreview: false }); + + expect(wrapper.find({ ref: 'diagramContainer' }).exists()).toBe(false); + }); + + it('does not update preview when diagram is not active', async () => { + createWrapper({ language: 'plantuml', isDiagram: true, showPreview: true }); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + await nextTick(); + + expect(wrapper.find('img').attributes('src')).toBe('url/to/some/diagram'); + + jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(false); + + const alternateUrl = 'url/to/another/diagram'; + + contentEditor.renderDiagram.mockResolvedValue(alternateUrl); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + await nextTick(); + + expect(wrapper.find('img').attributes('src')).toBe('url/to/some/diagram'); + }); + + it('renders an image with preview for a plantuml/kroki diagram', async () => { + createWrapper({ language: 'plantuml', isDiagram: true, showPreview: true }); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + await nextTick(); + + expect(wrapper.find('img').attributes('src')).toBe('url/to/some/diagram'); + expect(wrapper.find(SandboxedMermaid).exists()).toBe(false); + }); + + it('renders an iframe with preview for a mermaid diagram', async () => { + createWrapper({ language: 'mermaid', isDiagram: true, showPreview: true }); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + await nextTick(); + + expect(wrapper.find(SandboxedMermaid).props('source')).toBe(''); + expect(wrapper.find('img').exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js b/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js new file mode 100644 index 00000000000..1ff750eb2ac --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js @@ -0,0 +1,30 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import FootnoteDefinitionWrapper from '~/content_editor/components/wrappers/footnote_definition.vue'; + +describe('content/components/wrappers/footnote_definition', () => { + let wrapper; + + const createWrapper = async (node = {}) => { + wrapper = shallowMountExtended(FootnoteDefinitionWrapper, { + propsData: { + node, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders footnote label as a readyonly element', () => { + const label = 'footnote'; + + createWrapper({ + attrs: { + label, + }, + }); + expect(wrapper.text()).toContain(label); + expect(wrapper.findByTestId('footnote-label').attributes().contenteditable).toBe('false'); + }); +}); diff --git a/spec/frontend/content_editor/extensions/footnote_definition_spec.js b/spec/frontend/content_editor/extensions/footnote_definition_spec.js new file mode 100644 index 00000000000..d3dbc56ae0e --- /dev/null +++ b/spec/frontend/content_editor/extensions/footnote_definition_spec.js @@ -0,0 +1,7 @@ +import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; + +describe('content_editor/extensions/footnote_definition', () => { + it('sets the isolation option to true', () => { + expect(FootnoteDefinition.config.isolating).toBe(true); + }); +}); diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js index 6348b97d918..60dc540e192 100644 --- a/spec/frontend/content_editor/remark_markdown_processing_spec.js +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -3,6 +3,8 @@ 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 FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; +import FootnoteReference from '~/content_editor/extensions/footnote_reference'; import HardBreak from '~/content_editor/extensions/hard_break'; import Heading from '~/content_editor/extensions/heading'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; @@ -11,11 +13,19 @@ 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 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 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 remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import markdownSerializer from '~/content_editor/services/markdown_serializer'; -import { createTestEditor } from './test_utils'; +import { createTestEditor, createDocBuilder } from './test_utils'; const tiptapEditor = createTestEditor({ extensions: [ @@ -24,6 +34,8 @@ const tiptapEditor = createTestEditor({ BulletList, Code, CodeBlockHighlight, + FootnoteDefinition, + FootnoteReference, HardBreak, Heading, HorizontalRule, @@ -33,9 +45,72 @@ const tiptapEditor = createTestEditor({ ListItem, OrderedList, Sourcemap, + Strike, + Table, + TableRow, + TableHeader, + TableCell, + TaskList, + TaskItem, ], }); +const { + builders: { + doc, + paragraph, + bold, + blockquote, + bulletList, + code, + codeBlock, + footnoteDefinition, + footnoteReference, + hardBreak, + heading, + horizontalRule, + image, + italic, + link, + listItem, + orderedList, + strike, + table, + tableRow, + tableHeader, + tableCell, + taskItem, + taskList, + }, +} = createDocBuilder({ + tiptapEditor, + names: { + blockquote: { nodeType: Blockquote.name }, + bold: { markType: Bold.name }, + bulletList: { nodeType: BulletList.name }, + code: { markType: Code.name }, + codeBlock: { nodeType: CodeBlockHighlight.name }, + footnoteDefinition: { nodeType: FootnoteDefinition.name }, + footnoteReference: { nodeType: FootnoteReference.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 }, + strike: { nodeType: Strike.name }, + table: { nodeType: Table.name }, + tableCell: { nodeType: TableCell.name }, + tableHeader: { nodeType: TableHeader.name }, + tableRow: { nodeType: TableRow.name }, + taskItem: { nodeType: TaskItem.name }, + taskList: { nodeType: TaskList.name }, + }, +}); + describe('Client side Markdown processing', () => { const deserialize = async (content) => { const { document } = await remarkMarkdownDeserializer().deserialize({ @@ -52,197 +127,887 @@ describe('Client side Markdown processing', () => { pristineDoc: document, }); - it.each([ + const sourceAttrs = (sourceMapKey, sourceMarkdown) => ({ + sourceMapKey, + sourceMarkdown, + }); + + const examples = [ { markdown: '__bold text__', + expectedDoc: doc( + paragraph( + sourceAttrs('0:13', '__bold text__'), + bold(sourceAttrs('0:13', '__bold text__'), 'bold text'), + ), + ), }, { markdown: '**bold text**', + expectedDoc: doc( + paragraph( + sourceAttrs('0:13', '**bold text**'), + bold(sourceAttrs('0:13', '**bold text**'), 'bold text'), + ), + ), }, { markdown: '<strong>bold text</strong>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:26', '<strong>bold text</strong>'), + bold(sourceAttrs('0:26', '<strong>bold text</strong>'), 'bold text'), + ), + ), }, { markdown: '<b>bold text</b>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:16', '<b>bold text</b>'), + bold(sourceAttrs('0:16', '<b>bold text</b>'), 'bold text'), + ), + ), }, { markdown: '_italic text_', + expectedDoc: doc( + paragraph( + sourceAttrs('0:13', '_italic text_'), + italic(sourceAttrs('0:13', '_italic text_'), 'italic text'), + ), + ), }, { markdown: '*italic text*', + expectedDoc: doc( + paragraph( + sourceAttrs('0:13', '*italic text*'), + italic(sourceAttrs('0:13', '*italic text*'), 'italic text'), + ), + ), }, { markdown: '<em>italic text</em>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:20', '<em>italic text</em>'), + italic(sourceAttrs('0:20', '<em>italic text</em>'), 'italic text'), + ), + ), }, { markdown: '<i>italic text</i>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:18', '<i>italic text</i>'), + italic(sourceAttrs('0:18', '<i>italic text</i>'), 'italic text'), + ), + ), }, { markdown: '`inline code`', + expectedDoc: doc( + paragraph( + sourceAttrs('0:13', '`inline code`'), + code(sourceAttrs('0:13', '`inline code`'), 'inline code'), + ), + ), }, { markdown: '**`inline code bold`**', + expectedDoc: doc( + paragraph( + sourceAttrs('0:22', '**`inline code bold`**'), + bold( + sourceAttrs('0:22', '**`inline code bold`**'), + code(sourceAttrs('2:20', '`inline code bold`'), 'inline code bold'), + ), + ), + ), + }, + { + markdown: '_`inline code italics`_', + expectedDoc: doc( + paragraph( + sourceAttrs('0:23', '_`inline code italics`_'), + italic( + sourceAttrs('0:23', '_`inline code italics`_'), + code(sourceAttrs('1:22', '`inline code italics`'), 'inline code italics'), + ), + ), + ), + }, + { + markdown: ` +<i class="foo"> + *bar* +</i> + `, + expectedDoc: doc( + paragraph( + sourceAttrs('0:28', '<i class="foo">\n *bar*\n</i>'), + italic(sourceAttrs('0:28', '<i class="foo">\n *bar*\n</i>'), '\n *bar*\n'), + ), + ), + }, + { + markdown: ` + +<img src="bar" alt="foo" /> + + `, + expectedDoc: doc( + paragraph( + sourceAttrs('0:27', '<img src="bar" alt="foo" />'), + image({ ...sourceAttrs('0:27', '<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }), + ), + ), }, { - markdown: '__`inline code italics`__', + markdown: ` +- List item 1 + +<img src="bar" alt="foo" /> + + `, + expectedDoc: doc( + bulletList( + sourceAttrs('0:13', '- List item 1'), + listItem( + sourceAttrs('0:13', '- List item 1'), + paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + ), + ), + paragraph( + sourceAttrs('15:42', '<img src="bar" alt="foo" />'), + image({ ...sourceAttrs('15:42', '<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }), + ), + ), }, { markdown: '[GitLab](https://gitlab.com "Go to GitLab")', + expectedDoc: doc( + paragraph( + sourceAttrs('0:43', '[GitLab](https://gitlab.com "Go to GitLab")'), + link( + { + ...sourceAttrs('0:43', '[GitLab](https://gitlab.com "Go to GitLab")'), + href: 'https://gitlab.com', + title: 'Go to GitLab', + }, + 'GitLab', + ), + ), + ), }, { markdown: '**[GitLab](https://gitlab.com "Go to GitLab")**', + expectedDoc: doc( + paragraph( + sourceAttrs('0:47', '**[GitLab](https://gitlab.com "Go to GitLab")**'), + bold( + sourceAttrs('0:47', '**[GitLab](https://gitlab.com "Go to GitLab")**'), + link( + { + ...sourceAttrs('2:45', '[GitLab](https://gitlab.com "Go to GitLab")'), + href: 'https://gitlab.com', + title: 'Go to GitLab', + }, + 'GitLab', + ), + ), + ), + ), + }, + { + markdown: 'www.commonmark.org', + expectedDoc: doc( + paragraph( + sourceAttrs('0:18', 'www.commonmark.org'), + link( + { + ...sourceAttrs('0:18', 'www.commonmark.org'), + href: 'http://www.commonmark.org', + }, + 'www.commonmark.org', + ), + ), + ), + }, + { + markdown: 'Visit www.commonmark.org/help for more information.', + expectedDoc: doc( + paragraph( + sourceAttrs('0:51', 'Visit www.commonmark.org/help for more information.'), + 'Visit ', + link( + { + ...sourceAttrs('6:29', '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( + sourceAttrs('0:66', 'hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.'), + 'hello@mail+xyz.example isn’t valid, but ', + link( + { + ...sourceAttrs('40:62', 'hello+xyz@mail.example'), + href: 'mailto:hello+xyz@mail.example', + }, + 'hello+xyz@mail.example', + ), + ' is.', + ), + ), + }, + { + markdown: '[https://gitlab.com>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:20', '[https://gitlab.com>'), + '[', + link( + { + ...sourceAttrs(), + href: 'https://gitlab.com', + }, + 'https://gitlab.com', + ), + '>', + ), + ), }, { markdown: ` This is a paragraph with a\\ hard line break`, + expectedDoc: doc( + paragraph( + sourceAttrs('0:43', 'This is a paragraph with a\\\nhard line break'), + 'This is a paragraph with a', + hardBreak(sourceAttrs('26:28', '\\\n')), + '\nhard line break', + ), + ), }, { markdown: '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")', + expectedDoc: doc( + paragraph( + sourceAttrs('0:57', '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'), + image({ + ...sourceAttrs('0:57', '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'), + alt: 'GitLab Logo', + src: 'https://gitlab.com/logo.png', + title: 'GitLab Logo', + }), + ), + ), }, { markdown: '---', + expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '---'))), }, { markdown: '***', + expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '***'))), }, { markdown: '___', + expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '___'))), }, { markdown: '<hr>', + expectedDoc: doc(horizontalRule(sourceAttrs('0:4', '<hr>'))), }, { markdown: '# Heading 1', + expectedDoc: doc(heading({ ...sourceAttrs('0:11', '# Heading 1'), level: 1 }, 'Heading 1')), }, { markdown: '## Heading 2', + expectedDoc: doc(heading({ ...sourceAttrs('0:12', '## Heading 2'), level: 2 }, 'Heading 2')), }, { markdown: '### Heading 3', + expectedDoc: doc(heading({ ...sourceAttrs('0:13', '### Heading 3'), level: 3 }, 'Heading 3')), }, { markdown: '#### Heading 4', + expectedDoc: doc( + heading({ ...sourceAttrs('0:14', '#### Heading 4'), level: 4 }, 'Heading 4'), + ), }, { markdown: '##### Heading 5', + expectedDoc: doc( + heading({ ...sourceAttrs('0:15', '##### Heading 5'), level: 5 }, 'Heading 5'), + ), }, { markdown: '###### Heading 6', + expectedDoc: doc( + heading({ ...sourceAttrs('0:16', '###### Heading 6'), level: 6 }, 'Heading 6'), + ), }, - { markdown: ` - Heading - one - ====== - `, +Heading +one +====== + `, + expectedDoc: doc( + heading({ ...sourceAttrs('0:18', 'Heading\none\n======'), level: 1 }, 'Heading\none'), + ), }, { markdown: ` - Heading - two - ------- - `, +Heading +two +------- + `, + expectedDoc: doc( + heading({ ...sourceAttrs('0:19', 'Heading\ntwo\n-------'), level: 2 }, 'Heading\ntwo'), + ), }, { markdown: ` - - List item 1 - - List item 2 - `, +- List item 1 +- List item 2 + `, + expectedDoc: doc( + bulletList( + sourceAttrs('0:27', '- List item 1\n- List item 2'), + listItem( + sourceAttrs('0:13', '- List item 1'), + paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('14:27', '- List item 2'), + paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - * List item 1 - * List item 2 - `, +* List item 1 +* List item 2 + `, + expectedDoc: doc( + bulletList( + sourceAttrs('0:27', '* List item 1\n* List item 2'), + listItem( + sourceAttrs('0:13', '* List item 1'), + paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('14:27', '* List item 2'), + paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - + List item 1 - + List item 2 - `, ++ List item 1 ++ List item 2 + `, + expectedDoc: doc( + bulletList( + sourceAttrs('0:27', '+ List item 1\n+ List item 2'), + listItem( + sourceAttrs('0:13', '+ List item 1'), + paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('14:27', '+ List item 2'), + paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - 1. List item 1 - 1. List item 2 - `, +1. List item 1 +1. List item 2 + `, + expectedDoc: doc( + orderedList( + sourceAttrs('0:29', '1. List item 1\n1. List item 2'), + listItem( + sourceAttrs('0:14', '1. List item 1'), + paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('15:29', '1. List item 2'), + paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - 1. List item 1 - 2. List item 2 - `, +1. List item 1 +2. List item 2 + `, + expectedDoc: doc( + orderedList( + sourceAttrs('0:29', '1. List item 1\n2. List item 2'), + listItem( + sourceAttrs('0:14', '1. List item 1'), + paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('15:29', '2. List item 2'), + paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - 1) List item 1 - 2) List item 2 - `, +1) List item 1 +2) List item 2 + `, + expectedDoc: doc( + orderedList( + sourceAttrs('0:29', '1) List item 1\n2) List item 2'), + listItem( + sourceAttrs('0:14', '1) List item 1'), + paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('15:29', '2) List item 2'), + paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - - List item 1 - - Sub list item 1 - `, +- List item 1 + - Sub list item 1 + `, + expectedDoc: doc( + bulletList( + sourceAttrs('0:33', '- List item 1\n - Sub list item 1'), + listItem( + sourceAttrs('0:33', '- List item 1\n - Sub list item 1'), + paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + bulletList( + sourceAttrs('16:33', '- Sub list item 1'), + listItem( + sourceAttrs('16:33', '- Sub list item 1'), + paragraph(sourceAttrs('18:33', 'Sub list item 1'), 'Sub list item 1'), + ), + ), + ), + ), + ), }, { markdown: ` - - List item 1 paragraph 1 +- List item 1 paragraph 1 - List item 1 paragraph 2 - - List item 2 - `, + List item 1 paragraph 2 +- List item 2 + `, + expectedDoc: doc( + bulletList( + sourceAttrs( + '0:66', + '- List item 1 paragraph 1\n\n List item 1 paragraph 2\n- List item 2', + ), + listItem( + sourceAttrs('0:52', '- List item 1 paragraph 1\n\n List item 1 paragraph 2'), + paragraph(sourceAttrs('2:25', 'List item 1 paragraph 1'), 'List item 1 paragraph 1'), + paragraph(sourceAttrs('29:52', 'List item 1 paragraph 2'), 'List item 1 paragraph 2'), + ), + listItem( + sourceAttrs('53:66', '- List item 2'), + paragraph(sourceAttrs('55:66', 'List item 2'), 'List item 2'), + ), + ), + ), }, { markdown: ` - > This is a blockquote - `, +- List item with an image ![bar](foo.png) +`, + expectedDoc: doc( + bulletList( + sourceAttrs('0:41', '- List item with an image ![bar](foo.png)'), + listItem( + sourceAttrs('0:41', '- List item with an image ![bar](foo.png)'), + paragraph( + sourceAttrs('2:41', 'List item with an image ![bar](foo.png)'), + 'List item with an image', + image({ ...sourceAttrs('26:41', '![bar](foo.png)'), alt: 'bar', src: 'foo.png' }), + ), + ), + ), + ), }, { markdown: ` - > - List item 1 - > - List item 2 - `, +> This is a blockquote + `, + expectedDoc: doc( + blockquote( + sourceAttrs('0:22', '> This is a blockquote'), + paragraph(sourceAttrs('2:22', 'This is a blockquote'), 'This is a blockquote'), + ), + ), }, { markdown: ` - const fn = () => 'GitLab'; - `, +> - List item 1 +> - List item 2 + `, + expectedDoc: doc( + blockquote( + sourceAttrs('0:31', '> - List item 1\n> - List item 2'), + bulletList( + sourceAttrs('2:31', '- List item 1\n> - List item 2'), + listItem( + sourceAttrs('2:15', '- List item 1'), + paragraph(sourceAttrs('4:15', 'List item 1'), 'List item 1'), + ), + listItem( + sourceAttrs('18:31', '- List item 2'), + paragraph(sourceAttrs('20:31', 'List item 2'), 'List item 2'), + ), + ), + ), + ), }, { markdown: ` - \`\`\`javascript - const fn = () => 'GitLab'; - \`\`\`\ - `, +code block + + const fn = () => 'GitLab'; + + `, + expectedDoc: doc( + paragraph(sourceAttrs('0:10', 'code block'), 'code block'), + codeBlock( + { + ...sourceAttrs('12:42', " const fn = () => 'GitLab';"), + class: 'code highlight', + language: null, + }, + "const fn = () => 'GitLab';", + ), + ), }, { markdown: ` - ~~~javascript - const fn = () => 'GitLab'; - ~~~ - `, +\`\`\`javascript +const fn = () => 'GitLab'; +\`\`\`\ + `, + expectedDoc: doc( + codeBlock( + { + ...sourceAttrs('0:44', "```javascript\nconst fn = () => 'GitLab';\n```"), + class: 'code highlight', + language: 'javascript', + }, + "const fn = () => 'GitLab';", + ), + ), }, { markdown: ` - \`\`\` - \`\`\`\ - `, +~~~javascript +const fn = () => 'GitLab'; +~~~ + `, + expectedDoc: doc( + codeBlock( + { + ...sourceAttrs('0:44', "~~~javascript\nconst fn = () => 'GitLab';\n~~~"), + class: 'code highlight', + language: 'javascript', + }, + "const fn = () => 'GitLab';", + ), + ), }, { markdown: ` - \`\`\`javascript - const fn = () => 'GitLab'; +\`\`\` +\`\`\`\ + `, + expectedDoc: doc( + codeBlock( + { + ...sourceAttrs('0:7', '```\n```'), + class: 'code highlight', + language: null, + }, + '', + ), + ), + }, + { + markdown: ` +\`\`\`javascript +const fn = () => 'GitLab'; - \`\`\`\ - `, +\`\`\`\ + `, + expectedDoc: doc( + codeBlock( + { + ...sourceAttrs('0:45', "```javascript\nconst fn = () => 'GitLab';\n\n```"), + class: 'code highlight', + language: 'javascript', + }, + "const fn = () => 'GitLab';\n", + ), + ), + }, + { + markdown: '~~Strikedthrough text~~', + expectedDoc: doc( + paragraph( + sourceAttrs('0:23', '~~Strikedthrough text~~'), + strike(sourceAttrs('0:23', '~~Strikedthrough text~~'), 'Strikedthrough text'), + ), + ), + }, + { + markdown: '<del>Strikedthrough text</del>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:30', '<del>Strikedthrough text</del>'), + strike(sourceAttrs('0:30', '<del>Strikedthrough text</del>'), 'Strikedthrough text'), + ), + ), + }, + { + markdown: '<strike>Strikedthrough text</strike>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:36', '<strike>Strikedthrough text</strike>'), + strike( + sourceAttrs('0:36', '<strike>Strikedthrough text</strike>'), + 'Strikedthrough text', + ), + ), + ), + }, + { + markdown: '<s>Strikedthrough text</s>', + expectedDoc: doc( + paragraph( + sourceAttrs('0:26', '<s>Strikedthrough text</s>'), + strike(sourceAttrs('0:26', '<s>Strikedthrough text</s>'), 'Strikedthrough text'), + ), + ), }, - ])('processes %s correctly', async ({ markdown }) => { + { + markdown: ` +- [ ] task list item 1 +- [ ] task list item 2 + `, + expectedDoc: doc( + taskList( + { + numeric: false, + ...sourceAttrs('0:45', '- [ ] task list item 1\n- [ ] task list item 2'), + }, + taskItem( + { + checked: false, + ...sourceAttrs('0:22', '- [ ] task list item 1'), + }, + paragraph(sourceAttrs('6:22', 'task list item 1'), 'task list item 1'), + ), + taskItem( + { + checked: false, + ...sourceAttrs('23:45', '- [ ] task list item 2'), + }, + paragraph(sourceAttrs('29:45', 'task list item 2'), 'task list item 2'), + ), + ), + ), + }, + { + markdown: ` +- [x] task list item 1 +- [x] task list item 2 + `, + expectedDoc: doc( + taskList( + { + numeric: false, + ...sourceAttrs('0:45', '- [x] task list item 1\n- [x] task list item 2'), + }, + taskItem( + { + checked: true, + ...sourceAttrs('0:22', '- [x] task list item 1'), + }, + paragraph(sourceAttrs('6:22', 'task list item 1'), 'task list item 1'), + ), + taskItem( + { + checked: true, + ...sourceAttrs('23:45', '- [x] task list item 2'), + }, + paragraph(sourceAttrs('29:45', 'task list item 2'), 'task list item 2'), + ), + ), + ), + }, + { + markdown: ` +1. [ ] task list item 1 +2. [ ] task list item 2 + `, + expectedDoc: doc( + taskList( + { + numeric: true, + ...sourceAttrs('0:47', '1. [ ] task list item 1\n2. [ ] task list item 2'), + }, + taskItem( + { + checked: false, + ...sourceAttrs('0:23', '1. [ ] task list item 1'), + }, + paragraph(sourceAttrs('7:23', 'task list item 1'), 'task list item 1'), + ), + taskItem( + { + checked: false, + ...sourceAttrs('24:47', '2. [ ] task list item 2'), + }, + paragraph(sourceAttrs('31:47', 'task list item 2'), 'task list item 2'), + ), + ), + ), + }, + { + markdown: ` +| a | b | +|---|---| +| c | d | +`, + expectedDoc: doc( + table( + sourceAttrs('0:29', '| a | b |\n|---|---|\n| c | d |'), + tableRow( + sourceAttrs('0:9', '| a | b |'), + tableHeader(sourceAttrs('0:5', '| a |'), paragraph(sourceAttrs('2:3', 'a'), 'a')), + tableHeader(sourceAttrs('5:9', ' b |'), paragraph(sourceAttrs('6:7', 'b'), 'b')), + ), + tableRow( + sourceAttrs('20:29', '| c | d |'), + tableCell(sourceAttrs('20:25', '| c |'), paragraph(sourceAttrs('22:23', 'c'), 'c')), + tableCell(sourceAttrs('25:29', ' d |'), paragraph(sourceAttrs('26:27', 'd'), 'd')), + ), + ), + ), + }, + { + markdown: ` +<table> + <tr> + <th colspan="2" rowspan="5">Header</th> + </tr> + <tr> + <td colspan="2" rowspan="5">Body</td> + </tr> +</table> +`, + expectedDoc: doc( + table( + sourceAttrs( + '0:132', + '<table>\n <tr>\n <th colspan="2" rowspan="5">Header</th>\n </tr>\n <tr>\n <td colspan="2" rowspan="5">Body</td>\n </tr>\n</table>', + ), + tableRow( + sourceAttrs('10:66', '<tr>\n <th colspan="2" rowspan="5">Header</th>\n </tr>'), + tableHeader( + { + ...sourceAttrs('19:58', '<th colspan="2" rowspan="5">Header</th>'), + colspan: 2, + rowspan: 5, + }, + paragraph(sourceAttrs('47:53', 'Header'), 'Header'), + ), + ), + tableRow( + sourceAttrs('69:123', '<tr>\n <td colspan="2" rowspan="5">Body</td>\n </tr>'), + tableCell( + { + ...sourceAttrs('78:115', '<td colspan="2" rowspan="5">Body</td>'), + colspan: 2, + rowspan: 5, + }, + paragraph(sourceAttrs('106:110', 'Body'), 'Body'), + ), + ), + ), + ), + }, + { + markdown: ` +This is a footnote [^footnote] + +Paragraph + +[^footnote]: Footnote definition + +Paragraph +`, + expectedDoc: doc( + paragraph( + sourceAttrs('0:30', 'This is a footnote [^footnote]'), + 'This is a footnote ', + footnoteReference({ + ...sourceAttrs('19:30', '[^footnote]'), + identifier: 'footnote', + label: 'footnote', + }), + ), + paragraph(sourceAttrs('32:41', 'Paragraph'), 'Paragraph'), + footnoteDefinition( + { + ...sourceAttrs('43:75', '[^footnote]: Footnote definition'), + identifier: 'footnote', + label: 'footnote', + }, + paragraph(sourceAttrs('56:75', 'Footnote definition'), 'Footnote definition'), + ), + paragraph(sourceAttrs('77:86', 'Paragraph'), 'Paragraph'), + ), + }, + ]; + + const runOnly = examples.find((example) => example.only === true); + const runExamples = runOnly ? [runOnly] : examples; + + it.each(runExamples)('processes %s correctly', async ({ markdown, expectedDoc }) => { const trimmed = markdown.trim(); const document = await deserialize(trimmed); + expect(expectedDoc).not.toBeFalsy(); + expect(document.toJSON()).toEqual(expectedDoc.toJSON()); expect(serialize(document)).toEqual(trimmed); }); }); diff --git a/spec/frontend/content_editor/services/asset_resolver_spec.js b/spec/frontend/content_editor/services/asset_resolver_spec.js index f4e7d9bf881..0a99f823be3 100644 --- a/spec/frontend/content_editor/services/asset_resolver_spec.js +++ b/spec/frontend/content_editor/services/asset_resolver_spec.js @@ -20,4 +20,14 @@ describe('content_editor/services/asset_resolver', () => { ); }); }); + + describe('renderDiagram', () => { + it('resolves a diagram code to a url containing the diagram image', async () => { + renderMarkdown.mockResolvedValue( + '<p><img data-diagram="nomnoml" src="url/to/some/diagram"></p>', + ); + + expect(await assetResolver.renderDiagram('test')).toBe('url/to/some/diagram'); + }); + }); }); diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js index 943de327762..795f5219a3f 100644 --- a/spec/frontend/content_editor/services/code_block_language_loader_spec.js +++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js @@ -18,25 +18,32 @@ describe('content_editor/services/code_block_language_loader', () => { languageLoader.lowlight = lowlight; }); - describe('findLanguageBySyntax', () => { + describe('findOrCreateLanguageBySyntax', () => { it.each` syntax | language ${'javascript'} | ${{ syntax: 'javascript', label: 'Javascript' }} ${'js'} | ${{ syntax: 'javascript', label: 'Javascript' }} ${'jsx'} | ${{ syntax: 'javascript', label: 'Javascript' }} `('returns a language by syntax and its variants', ({ syntax, language }) => { - expect(languageLoader.findLanguageBySyntax(syntax)).toMatchObject(language); + expect(languageLoader.findOrCreateLanguageBySyntax(syntax)).toMatchObject(language); }); it('returns Custom (syntax) if the language does not exist', () => { - expect(languageLoader.findLanguageBySyntax('foobar')).toMatchObject({ + expect(languageLoader.findOrCreateLanguageBySyntax('foobar')).toMatchObject({ syntax: 'foobar', label: 'Custom (foobar)', }); }); + it('returns Diagram (syntax) if the language does not exist, and isDiagram = true', () => { + expect(languageLoader.findOrCreateLanguageBySyntax('foobar', true)).toMatchObject({ + syntax: 'foobar', + label: 'Diagram (foobar)', + }); + }); + it('returns plaintext if no syntax is passed', () => { - expect(languageLoader.findLanguageBySyntax('')).toMatchObject({ + expect(languageLoader.findOrCreateLanguageBySyntax('')).toMatchObject({ syntax: 'plaintext', label: 'Plain text', }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 25b7483f234..13e9efaea59 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -13,7 +13,6 @@ import Figure from '~/content_editor/extensions/figure'; 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 HardBreak from '~/content_editor/extensions/hard_break'; import Heading from '~/content_editor/extensions/heading'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; @@ -53,7 +52,6 @@ const tiptapEditor = createTestEditor({ Emoji, FootnoteDefinition, FootnoteReference, - FootnotesSection, Figure, FigureCaption, HardBreak, @@ -92,7 +90,6 @@ const { emoji, footnoteDefinition, footnoteReference, - footnotesSection, figure, figureCaption, heading, @@ -131,7 +128,6 @@ const { figureCaption: { nodeType: FigureCaption.name }, footnoteDefinition: { nodeType: FootnoteDefinition.name }, footnoteReference: { nodeType: FootnoteReference.name }, - footnotesSection: { nodeType: FootnotesSection.name }, hardBreak: { nodeType: HardBreak.name }, heading: { nodeType: Heading.name }, horizontalRule: { nodeType: HorizontalRule.name }, @@ -200,7 +196,7 @@ describe('markdownSerializer', () => { it('correctly serializes a plain URL link', () => { expect(serialize(paragraph(link({ href: 'https://example.com' }, 'https://example.com')))).toBe( - '<https://example.com>', + 'https://example.com', ); }); @@ -1147,49 +1143,75 @@ there it('correctly serializes footnotes', () => { expect( serialize( - paragraph( - 'Oranges are orange ', - footnoteReference({ footnoteId: '1', footnoteNumber: '1' }), - ), - footnotesSection(footnoteDefinition(paragraph('Oranges are fruits'))), + paragraph('Oranges are orange ', footnoteReference({ label: '1', identifier: '1' })), + footnoteDefinition({ label: '1', identifier: '1' }, 'Oranges are fruits'), ), ).toBe( ` Oranges are orange [^1] [^1]: Oranges are fruits - `.trim(), +`.trimLeft(), ); }); + const defaultEditAction = (initialContent) => { + tiptapEditor.chain().setContent(initialContent.toJSON()).insertContent(' modified').run(); + }; + + const prependContentEditAction = (initialContent) => { + tiptapEditor + .chain() + .setContent(initialContent.toJSON()) + .setTextSelection(0) + .insertContent('modified ') + .run(); + }; + it.each` - mark | content | modifiedContent - ${'bold'} | ${'**bold**'} | ${'**bold modified**'} - ${'bold'} | ${'__bold__'} | ${'__bold modified__'} - ${'bold'} | ${'<strong>bold</strong>'} | ${'<strong>bold modified</strong>'} - ${'bold'} | ${'<b>bold</b>'} | ${'<b>bold modified</b>'} - ${'italic'} | ${'_italic_'} | ${'_italic modified_'} - ${'italic'} | ${'*italic*'} | ${'*italic modified*'} - ${'italic'} | ${'<em>italic</em>'} | ${'<em>italic modified</em>'} - ${'italic'} | ${'<i>italic</i>'} | ${'<i>italic modified</i>'} - ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'} - ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'} - ${'code'} | ${'`code`'} | ${'`code modified`'} - ${'code'} | ${'<code>code</code>'} | ${'<code>code modified</code>'} + mark | content | modifiedContent | 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} `( - 'preserves original $mark syntax when sourceMarkdown is available', - async ({ content, modifiedContent }) => { + 'preserves original $mark syntax when sourceMarkdown is available for $content', + async ({ content, modifiedContent, editAction }) => { const { document } = await remarkMarkdownDeserializer().deserialize({ schema: tiptapEditor.schema, content, }); - tiptapEditor - .chain() - .setContent(document.toJSON()) - // changing the document ensures that block preservation doesn’t yield false positives - .insertContent(' modified') - .run(); + editAction(document); const serialized = markdownSerializer({}).serialize({ pristineDoc: document, |