diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 6c8c562af8e460ca5b2a767633b3f3aaf3119504..d665f24bba16dea63e56670e62f8f0447287c20f 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -48,7 +48,6 @@ import Text from '../extensions/text'; import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { - isPlainURL, renderCodeBlock, renderHardBreak, renderTable, @@ -62,36 +61,29 @@ import { renderHTMLNode, renderContent, preserveUnchanged, + bold, + italic, + link, + code, } from './serialization_helpers'; const defaultSerializerConfig = { marks: { - [Bold.name]: defaultMarkdownSerializer.marks.strong, - [Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }, - [Code.name]: defaultMarkdownSerializer.marks.code, + [Bold.name]: bold, + [Italic.name]: italic, + [Code.name]: code, [Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true }, [Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true }, [InlineDiff.name]: { mixable: true, - open(state, mark) { + open(_, mark) { return mark.attrs.type === 'addition' ? '{+' : '{-'; }, - close(state, mark) { + close(_, mark) { return mark.attrs.type === 'addition' ? '+}' : '-}'; }, }, - [Link.name]: { - open(state, mark, parent, index) { - return isPlainURL(mark, parent, index, 1) ? '<' : '['; - }, - close(state, mark, parent, index) { - const href = mark.attrs.canonicalSrc || mark.attrs.href; - - return isPlainURL(mark, parent, index, -1) - ? '>' - : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; - }, - }, + [Link.name]: link, [MathInline.name]: { open: (...args) => `$${defaultMarkdownSerializer.marks.code.open(...args)}`, close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`, diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 99aaee8f312814fc374b7902cf8a7b81c1f8754d..089d30edec7e595d4e303b11017bede1f29c5405 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -363,3 +363,120 @@ export function preserveUnchanged(render) { } }; } + +const generateBoldTags = (open = true) => { + return (_, mark) => { + const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1]; + + switch (type) { + case '**': + case '__': + return type; + // eslint-disable-next-line @gitlab/require-i18n-strings + case '<strong': + case '<b': + return (open ? openTag : closeTag)(type.substring(1)); + default: + return '**'; + } + }; +}; + +export const bold = { + open: generateBoldTags(), + close: generateBoldTags(false), + mixable: true, + expelEnclosingWhitespace: true, +}; + +const generateItalicTag = (open = true) => { + return (_, mark) => { + const type = /^(\*|_|<em|<i).*/.exec(mark.attrs.sourceMarkdown)?.[1]; + + switch (type) { + case '*': + case '_': + return type; + // eslint-disable-next-line @gitlab/require-i18n-strings + case '<em': + case '<i': + return (open ? openTag : closeTag)(type.substring(1)); + default: + return '_'; + } + }; +}; + +export const italic = { + open: generateItalicTag(), + close: generateItalicTag(false), + mixable: true, + expelEnclosingWhitespace: true, +}; + +const generateCodeTag = (open = true) => { + return (_, mark) => { + const type = /^(`|<code).*/.exec(mark.attrs.sourceMarkdown)?.[1]; + + if (type === '<code') { + return (open ? openTag : closeTag)(type.substring(1)); + } + + return '`'; + }; +}; + +export const code = { + open: generateCodeTag(), + close: generateCodeTag(false), + mixable: true, + expelEnclosingWhitespace: true, +}; + +const LINK_HTML = 'linkHtml'; +const LINK_MARKDOWN = 'linkMarkdown'; + +const linkType = (sourceMarkdown) => { + const expression = /^(\[|<a).*/.exec(sourceMarkdown)?.[1]; + + if (!expression || expression === '[') { + return LINK_MARKDOWN; + } + + return LINK_HTML; +}; + +export const link = { + open(state, mark, parent, index) { + if (isPlainURL(mark, parent, index, 1)) { + return '<'; + } + + const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + + if (linkType(sourceMarkdown) === LINK_MARKDOWN) { + return '['; + } + + const attrs = { href: state.esc(href || canonicalSrc) }; + + if (title) { + attrs.title = title; + } + + return openTag('a', attrs); + }, + close(state, mark, parent, index) { + if (isPlainURL(mark, parent, index, -1)) { + return '>'; + } + + const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + + if (linkType(sourceMarkdown) === LINK_HTML) { + return closeTag('a'); + } + + return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`; + }, +}; diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 6fa42ddbd2d610661bc648d790c0836c058ff0f4..25b7483f234be39cf2cf3a0e57f36f90ee2650a2 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -24,6 +24,7 @@ import Link from '~/content_editor/extensions/link'; import ListItem from '~/content_editor/extensions/list_item'; import OrderedList from '~/content_editor/extensions/ordered_list'; import Paragraph from '~/content_editor/extensions/paragraph'; +import Sourcemap from '~/content_editor/extensions/sourcemap'; import Strike from '~/content_editor/extensions/strike'; import Table from '~/content_editor/extensions/table'; import TableCell from '~/content_editor/extensions/table_cell'; @@ -32,6 +33,7 @@ import TableRow from '~/content_editor/extensions/table_row'; import TaskItem from '~/content_editor/extensions/task_item'; import TaskList from '~/content_editor/extensions/task_list'; import markdownSerializer from '~/content_editor/services/markdown_serializer'; +import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; import { createTestEditor, createDocBuilder } from '../test_utils'; jest.mock('~/emoji'); @@ -63,6 +65,7 @@ const tiptapEditor = createTestEditor({ Link, ListItem, OrderedList, + Sourcemap, Strike, Table, TableCell, @@ -1158,4 +1161,42 @@ Oranges are orange [^1] `.trim(), ); }); + + 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>'} + `( + 'preserves original $mark syntax when sourceMarkdown is available', + async ({ content, modifiedContent }) => { + 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(); + + const serialized = markdownSerializer({}).serialize({ + pristineDoc: document, + doc: tiptapEditor.state.doc, + }); + + expect(serialized).toEqual(modifiedContent); + }, + ); });