Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/content_editor')
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap16
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/code_block_spec.js157
-rw-r--r--spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js54
-rw-r--r--spec/frontend/content_editor/components/top_toolbar_spec.js29
-rw-r--r--spec/frontend/content_editor/components/wrappers/code_block_spec.js85
-rw-r--r--spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js30
-rw-r--r--spec/frontend/content_editor/extensions/footnote_definition_spec.js7
-rw-r--r--spec/frontend/content_editor/remark_markdown_processing_spec.js885
-rw-r--r--spec/frontend/content_editor/services/asset_resolver_spec.js10
-rw-r--r--spec/frontend/content_editor/services/code_block_language_loader_spec.js15
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js86
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,