From c0bc64e25edb4d7c3ac1d89de720f94782be5d2e Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Sat, 20 Jan 2024 00:09:13 +0000 Subject: Add latest changes from gitlab-org/gitlab@master --- .../javascripts/behaviors/markdown/copy_as_gfm.js | 10 +- .../behaviors/markdown/editor_extensions.js | 95 ----------- .../javascripts/behaviors/markdown/marks/bold.js | 17 -- .../javascripts/behaviors/markdown/marks/code.js | 12 -- .../behaviors/markdown/marks/inline_diff.js | 29 ---- .../behaviors/markdown/marks/inline_html.js | 35 ----- .../javascripts/behaviors/markdown/marks/italic.js | 11 -- .../javascripts/behaviors/markdown/marks/link.js | 47 ------ .../javascripts/behaviors/markdown/marks/math.js | 31 ---- .../javascripts/behaviors/markdown/marks/strike.js | 37 ----- .../javascripts/behaviors/markdown/nodes/audio.js | 4 - .../behaviors/markdown/nodes/blockquote.js | 19 --- .../behaviors/markdown/nodes/bullet_list.js | 15 -- .../behaviors/markdown/nodes/code_block.js | 94 ----------- .../markdown/nodes/description_details.js | 20 --- .../behaviors/markdown/nodes/description_list.js | 19 --- .../behaviors/markdown/nodes/description_term.js | 18 --- .../behaviors/markdown/nodes/details.js | 18 --- .../javascripts/behaviors/markdown/nodes/doc.js | 6 - .../javascripts/behaviors/markdown/nodes/emoji.js | 43 ----- .../behaviors/markdown/nodes/hard_break.js | 14 -- .../behaviors/markdown/nodes/heading.js | 27 ---- .../behaviors/markdown/nodes/horizontal_rule.js | 14 -- .../javascripts/behaviors/markdown/nodes/image.js | 48 ------ .../behaviors/markdown/nodes/list_item.js | 16 -- .../behaviors/markdown/nodes/ordered_list.js | 25 --- .../behaviors/markdown/nodes/ordered_task_list.js | 21 --- .../behaviors/markdown/nodes/paragraph.js | 15 -- .../behaviors/markdown/nodes/playable.js | 56 ------- .../behaviors/markdown/nodes/reference.js | 44 ------ .../behaviors/markdown/nodes/summary.js | 17 -- .../javascripts/behaviors/markdown/nodes/table.js | 15 -- .../behaviors/markdown/nodes/table_body.js | 14 -- .../behaviors/markdown/nodes/table_cell.js | 25 --- .../behaviors/markdown/nodes/table_head.js | 14 -- .../behaviors/markdown/nodes/table_header_row.js | 36 ----- .../behaviors/markdown/nodes/table_of_contents.js | 26 ---- .../behaviors/markdown/nodes/table_row.js | 28 ---- .../behaviors/markdown/nodes/task_list.js | 20 --- .../behaviors/markdown/nodes/task_list_item.js | 71 --------- .../javascripts/behaviors/markdown/nodes/text.js | 11 -- .../javascripts/behaviors/markdown/nodes/video.js | 4 - .../javascripts/behaviors/markdown/schema.js | 25 ++- .../javascripts/behaviors/markdown/serializer.js | 21 +-- .../extensions/code_block_highlight.js | 42 +++++ .../content_editor/extensions/code_suggestion.js | 11 +- .../javascripts/content_editor/extensions/emoji.js | 40 +++-- .../javascripts/content_editor/extensions/index.js | 62 ++++++++ .../content_editor/extensions/inline_diff.js | 8 +- .../content_editor/extensions/math_inline.js | 10 +- .../content_editor/extensions/strike.js | 23 ++- .../content_editor/extensions/task_item.js | 2 +- .../services/create_content_editor.js | 139 ++--------------- .../content_editor/services/markdown_serializer.js | 173 +++++++-------------- .../services/serialization_helpers.js | 79 ++++++++-- app/models/organizations/organization_setting.rb | 8 + .../output_example_snapshots/prosemirror_json.yml | 45 ++++-- lib/gitlab/current_settings.rb | 10 +- lib/gitlab/fake_application_settings.rb | 4 + locale/gitlab.pot | 3 - spec/features/markdown/copy_as_gfm_spec.rb | 47 +++--- spec/frontend/behaviors/copy_as_gfm_spec.js | 4 +- .../behaviors/shortcuts/shortcuts_issuable_spec.js | 4 +- .../extensions/code_suggestion_spec.js | 6 +- .../services/markdown_serializer_spec.js | 95 ++++++++++- spec/lib/gitlab/current_settings_spec.rb | 56 ++++++- .../organizations/organization_setting_spec.rb | 38 +++++ .../lib/glfm/update_example_snapshots_spec.rb | 5 +- 68 files changed, 606 insertions(+), 1495 deletions(-) delete mode 100644 app/assets/javascripts/behaviors/markdown/editor_extensions.js delete mode 100644 app/assets/javascripts/behaviors/markdown/marks/bold.js delete mode 100644 app/assets/javascripts/behaviors/markdown/marks/code.js delete mode 100644 app/assets/javascripts/behaviors/markdown/marks/inline_diff.js delete mode 100644 app/assets/javascripts/behaviors/markdown/marks/inline_html.js delete mode 100644 app/assets/javascripts/behaviors/markdown/marks/italic.js delete mode 100644 app/assets/javascripts/behaviors/markdown/marks/link.js delete mode 100644 app/assets/javascripts/behaviors/markdown/marks/math.js delete mode 100644 app/assets/javascripts/behaviors/markdown/marks/strike.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/audio.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/blockquote.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/code_block.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/description_details.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/description_list.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/description_term.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/details.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/doc.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/emoji.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/hard_break.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/heading.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/image.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/list_item.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/paragraph.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/playable.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/reference.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/summary.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/table.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/table_body.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/table_cell.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/table_head.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/table_row.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/task_list.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/text.js delete mode 100644 app/assets/javascripts/behaviors/markdown/nodes/video.js create mode 100644 app/assets/javascripts/content_editor/extensions/index.js diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index 72aae254584..7e5a51a825f 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -161,6 +161,8 @@ export class CopyAsGFM { codeElement.appendChild(documentFragment); } + [...codeElement.querySelectorAll('.idiff')].forEach((el) => el.classList.remove('idiff')); + return codeElement; } @@ -176,10 +178,10 @@ export class CopyAsGFM { wrapEl.appendChild(node.cloneNode(true)); const doc = DOMParser.fromSchema(schema.default).parse(wrapEl); - const res = markdownSerializer.default.serialize(doc, { - tightLists: true, - }); - return res; + return markdownSerializer.default.serialize( + { doc }, + { useCanonicalSrc: false, skipEmptyNodes: true }, + ); }) .catch(() => {}); } diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js deleted file mode 100644 index 165031c3e7d..00000000000 --- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js +++ /dev/null @@ -1,95 +0,0 @@ -import Bold from './marks/bold'; -import Code from './marks/code'; -import InlineDiff from './marks/inline_diff'; -import InlineHTML from './marks/inline_html'; -import Italic from './marks/italic'; -import Link from './marks/link'; -import MathMark from './marks/math'; -import Strike from './marks/strike'; -import Audio from './nodes/audio'; -import Blockquote from './nodes/blockquote'; -import BulletList from './nodes/bullet_list'; -import CodeBlock from './nodes/code_block'; -import DescriptionDetails from './nodes/description_details'; -import DescriptionList from './nodes/description_list'; -import DescriptionTerm from './nodes/description_term'; -import Details from './nodes/details'; -import Doc from './nodes/doc'; - -import Emoji from './nodes/emoji'; -import HardBreak from './nodes/hard_break'; -import Heading from './nodes/heading'; -import HorizontalRule from './nodes/horizontal_rule'; -import Image from './nodes/image'; - -import ListItem from './nodes/list_item'; -import OrderedList from './nodes/ordered_list'; -import OrderedTaskList from './nodes/ordered_task_list'; -import Paragraph from './nodes/paragraph'; -import Reference from './nodes/reference'; -import Summary from './nodes/summary'; -import Table from './nodes/table'; -import TableBody from './nodes/table_body'; -import TableCell from './nodes/table_cell'; -import TableHead from './nodes/table_head'; -import TableHeaderRow from './nodes/table_header_row'; -import TableOfContents from './nodes/table_of_contents'; -import TableRow from './nodes/table_row'; - -import TaskList from './nodes/task_list'; -import TaskListItem from './nodes/task_list_item'; -import Text from './nodes/text'; -import Video from './nodes/video'; - -// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform -// GitLab Flavored Markdown (GFM) to HTML. -// The nodes and marks referenced here transform that same HTML to GFM to be copied to the clipboard. -// Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML -// from GFM should have a node or mark here. -// The GFM-to-HTML-to-GFM cycle is tested in spec/features/markdown/copy_as_gfm_spec.rb. - -export default { - nodes: [ - Doc(), - Paragraph(), - Text(), - - Blockquote(), - CodeBlock(), - HardBreak(), - Heading(), - HorizontalRule(), - Image(), - - Table(), - TableHead(), - TableBody(), - TableHeaderRow(), - TableRow(), - TableCell(), - - Emoji(), - Reference(), - - TableOfContents(), - Video(), - Audio(), - - BulletList(), - OrderedList(), - ListItem(), - - DescriptionList(), - DescriptionTerm(), - DescriptionDetails(), - - TaskList(), - OrderedTaskList(), - TaskListItem(), - - Summary(), - Details(), - ], - - marks: [Bold(), Italic(), Strike(), InlineDiff(), Link(), Code(), MathMark(), InlineHTML()], -}; diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js deleted file mode 100644 index dd730947a5f..00000000000 --- a/app/assets/javascripts/behaviors/markdown/marks/bold.js +++ /dev/null @@ -1,17 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => { - return { - name: 'bold', - schema: { - parseDOM: [ - { - tag: 'strong', - }, - ], - toDOM: () => ['strong', 0], - }, - toMarkdown: defaultMarkdownSerializer.marks.strong, - }; -}; diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js deleted file mode 100644 index ea5af8b4a1f..00000000000 --- a/app/assets/javascripts/behaviors/markdown/marks/code.js +++ /dev/null @@ -1,12 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'code', - schema: { - excludes: '_', - parseDOM: [{ tag: 'code' }], - toDOM: () => ['code', 0], - }, - toMarkdown: defaultMarkdownSerializer.marks.code, -}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js deleted file mode 100644 index 69d345c81e4..00000000000 --- a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js +++ /dev/null @@ -1,29 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::InlineDiffFilter -export default () => ({ - name: 'inline_diff', - schema: { - attrs: { - addition: { - default: true, - }, - }, - parseDOM: [ - { tag: 'span.idiff.addition', attrs: { addition: true } }, - { tag: 'span.idiff.deletion', attrs: { addition: false } }, - ], - toDOM: (node) => [ - 'span', - { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` }, - 0, - ], - }, - toMarkdown: { - mixable: true, - open(_, mark) { - return mark.attrs.addition ? '{+' : '{-'; - }, - close(_, mark) { - return mark.attrs.addition ? '+}' : '-}'; - }, - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js deleted file mode 100644 index 4520598e0ab..00000000000 --- a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js +++ /dev/null @@ -1,35 +0,0 @@ -import { escape } from 'lodash'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'inline_html', - schema: { - excludes: '', - attrs: { - tag: {}, - title: { default: null }, - }, - parseDOM: [ - { - tag: 'sup, sub, kbd, q, samp, var', - getAttrs: (el) => ({ tag: el.nodeName.toLowerCase() }), - }, - { - tag: 'abbr', - getAttrs: (el) => ({ tag: 'abbr', title: el.getAttribute('title') }), - }, - ], - toDOM: (node) => [node.attrs.tag, { title: node.attrs.title }, 0], - }, - toMarkdown: { - mixable: true, - open(state, mark) { - return `<${mark.attrs.tag}${ - mark.attrs.title ? ` title="${state.esc(escape(mark.attrs.title))}"` : '' - }>`; - }, - close(_, mark) { - return ``; - }, - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js deleted file mode 100644 index 3ec8f0071e9..00000000000 --- a/app/assets/javascripts/behaviors/markdown/marks/italic.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'italic', - schema: { - parseDOM: [{ tag: 'em' }], - toDOM: () => ['em', 0], - }, - toMarkdown: defaultMarkdownSerializer.marks.em, -}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js deleted file mode 100644 index 977453fee01..00000000000 --- a/app/assets/javascripts/behaviors/markdown/marks/link.js +++ /dev/null @@ -1,47 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'link', - schema: { - attrs: { - href: { - default: null, - }, - target: { - default: null, - }, - }, - inclusive: false, - parseDOM: [ - { - tag: 'a[href]', - getAttrs: (dom) => ({ - href: dom.getAttribute('href'), - target: dom.getAttribute('target'), - }), - }, - ], - toDOM: (node) => [ - 'a', - { - ...node.attrs, - // eslint-disable-next-line @gitlab/require-i18n-strings - rel: 'noopener noreferrer nofollow', - target: node.attrs.target, - }, - 0, - ], - }, - toMarkdown: { - mixable: true, - open(state, mark, parent, index) { - const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index); - return open === '<' ? '' : open; - }, - close(state, mark, parent, index) { - const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index); - return close === '>' ? '' : close; - }, - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js deleted file mode 100644 index a50a649b6eb..00000000000 --- a/app/assets/javascripts/behaviors/markdown/marks/math.js +++ /dev/null @@ -1,31 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; -import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter -export default () => ({ - name: 'math', - schema: { - parseDOM: [ - // Matches HTML generated by Banzai::Filter::MathFilter - { - tag: 'code.code.math[data-math-style=inline]', - priority: HIGHER_PARSE_RULE_PRIORITY, - }, - // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js - { - tag: 'span.katex', - contentElement: 'annotation[encoding="application/x-tex"]', - }, - ], - toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0], - }, - toMarkdown: { - escape: false, - open(state, mark, parent, index) { - return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`; - }, - close(state, mark, parent, index) { - return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`; - }, - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js deleted file mode 100644 index afab266b645..00000000000 --- a/app/assets/javascripts/behaviors/markdown/marks/strike.js +++ /dev/null @@ -1,37 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'strike', - schema: { - attrs: { - strike: { - default: false, - }, - inapplicable: { - default: false, - }, - }, - parseDOM: [ - { tag: 'li.inapplicable > s', attrs: { inapplicable: true } }, - { tag: 'li.inapplicable > p:first-of-type > s', attrs: { inapplicable: true } }, - { tag: 's', attrs: { strike: true } }, - { tag: 'del' }, - ], - toDOM: () => ['s', 0], - }, - toMarkdown: { - open(_, mark) { - if (mark.attrs.strike) { - return ''; - } - return mark.attrs.inapplicable ? '' : '~~'; - }, - close(_, mark) { - if (mark.attrs.strike) { - return ''; - } - return mark.attrs.inapplicable ? '' : '~~'; - }, - mixable: true, - expelEnclosingWhitespace: true, - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/audio.js b/app/assets/javascripts/behaviors/markdown/nodes/audio.js deleted file mode 100644 index 97ab86c6d23..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/audio.js +++ /dev/null @@ -1,4 +0,0 @@ -import playable from './playable'; - -// Transforms generated HTML back to GFM for Banzai::Filter::AudioLinkFilter -export default () => playable({ mediaType: 'audio' }); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js deleted file mode 100644 index 6a4552d47e4..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js +++ /dev/null @@ -1,19 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'blockquote', - schema: { - content: 'block*', - group: 'block', - defining: true, - draggable: false, - parseDOM: [{ tag: 'blockquote' }], - toDOM: () => ['blockquote', 0], - }, - toMarkdown(state, node) { - if (!node.childCount) return; - - defaultMarkdownSerializer.nodes.blockquote(state, node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js deleted file mode 100644 index 95cd3605da5..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js +++ /dev/null @@ -1,15 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'bullet_list', - schema: { - content: 'list_item+', - group: 'block', - parseDOM: [{ tag: 'ul' }], - toDOM: () => ['ul', 0], - }, - toMarkdown(state, node) { - defaultMarkdownSerializer.nodes.bullet_list(state, node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js deleted file mode 100644 index b862d111de7..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js +++ /dev/null @@ -1,94 +0,0 @@ -const PLAINTEXT_LANG = 'plaintext'; - -// Transforms generated HTML back to GFM for: -// - Banzai::Filter::SyntaxHighlightFilter -// - Banzai::Filter::MathFilter -// - Banzai::Filter::MermaidFilter -// - Banzai::Filter::SuggestionFilter -export default () => ({ - name: 'code_block', - schema: { - content: 'text*', - marks: '', - group: 'block', - code: true, - defining: true, - attrs: { - lang: { default: PLAINTEXT_LANG }, - }, - parseDOM: [ - // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, or Banzai::Filter::SuggestionFilter - { - tag: 'pre.code.highlight', - preserveWhitespace: 'full', - getAttrs: (el) => { - const lang = el.getAttribute('lang'); - if (!lang || lang === '') return {}; - - return { lang }; - }, - }, - // Matches HTML generated by Banzai::Filter::MathFilter, - // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js - { - tag: 'span.katex-display', - preserveWhitespace: 'full', - contentElement: 'annotation[encoding="application/x-tex"]', - attrs: { lang: 'math' }, - }, - // Matches HTML generated by Banzai::Filter::MermaidFilter, - // after being transformed by app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js - { - tag: 'svg.mermaid', - preserveWhitespace: 'full', - contentElement: 'text.source', - attrs: { lang: 'mermaid' }, - }, - // Matches HTML generated by Banzai::Filter::SuggestionFilter, - // after being transformed by app/assets/javascripts/vue_shared/components/markdown/suggestions.vue - { - tag: '.md-suggestion', - skip: true, - }, - { - tag: '.md-suggestion-header', - ignore: true, - }, - { - tag: '.md-suggestion-diff', - preserveWhitespace: 'full', - getContent: (el, schema) => - [...el.querySelectorAll('.line_content.new span')].map((span) => - schema.text(span.innerText), - ), - attrs: { lang: 'suggestion' }, - }, - ], - toDOM: (node) => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]], - }, - - toMarkdown(state, node) { - if (!node.childCount) return; - - const { - textContent: text, - attrs: { lang }, - } = node; - - // Prefixes lines with 4 spaces if the code contains a line that starts with triple backticks - if (lang === PLAINTEXT_LANG && text.match(/^```/gm)) { - state.wrapBlock(' ', null, node, () => state.text(text, false)); - return; - } - - state.write('```'); - if (lang !== PLAINTEXT_LANG) state.write(lang); - - state.ensureNewLine(); - state.text(text, false); - state.ensureNewLine(); - - state.write('```'); - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js deleted file mode 100644 index 20760286045..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js +++ /dev/null @@ -1,20 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'description_details', - - schema: { - content: 'text*', - marks: '', - defining: true, - parseDOM: [{ tag: 'dd' }], - toDOM: () => ['dd', 0], - }, - - toMarkdown(state, node) { - state.flushClose(1); - state.write('
'); - state.text(node.textContent, false); - state.write('
'); - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js deleted file mode 100644 index c5305c48423..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js +++ /dev/null @@ -1,19 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'description_list', - schema: { - content: '(description_term+ description_details+)+', - group: 'block', - parseDOM: [{ tag: 'dl' }], - toDOM: () => ['dl', 0], - }, - - toMarkdown(state, node) { - state.write('
\n'); - state.wrapBlock(' ', null, node, () => state.renderContent(node)); - state.flushClose(1); - state.ensureNewLine(); - state.write('
'); - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js deleted file mode 100644 index f78f7f13fc4..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js +++ /dev/null @@ -1,18 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'description_term', - schema: { - content: 'text*', - marks: '', - defining: true, - parseDOM: [{ tag: 'dt' }], - toDOM: () => ['dt', 0], - }, - toMarkdown(state, node) { - state.flushClose(state.closed && state.closed.type === node.type ? 1 : 2); - state.write('
'); - state.text(node.textContent, false); - state.write('
'); - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/details.js b/app/assets/javascripts/behaviors/markdown/nodes/details.js deleted file mode 100644 index 9fb0d60b93a..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/details.js +++ /dev/null @@ -1,18 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'details', - schema: { - content: 'summary block*', - group: 'block', - parseDOM: [{ tag: 'details' }], - toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0], - }, - toMarkdown(state, node) { - state.write('
\n'); - state.renderContent(node); - state.flushClose(1); - state.ensureNewLine(); - state.write('
'); - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/doc.js b/app/assets/javascripts/behaviors/markdown/nodes/doc.js deleted file mode 100644 index 3101e6e0e3a..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/doc.js +++ /dev/null @@ -1,6 +0,0 @@ -export default () => ({ - name: 'doc', - schema: { - content: 'block+', - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js deleted file mode 100644 index 086c277bad4..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js +++ /dev/null @@ -1,43 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::EmojiFilter -export default () => ({ - name: 'emoji', - schema: { - inline: true, - group: 'inline', - attrs: { - name: {}, - title: {}, - moji: {}, - }, - parseDOM: [ - { - tag: 'gl-emoji', - getAttrs: (el) => ({ - name: el.dataset.name, - title: el.getAttribute('title'), - moji: el.textContent, - }), - }, - { - tag: 'img.emoji', - getAttrs: (el) => { - const name = el.getAttribute('title').replace(/^:|:$/g, ''); - - return { - name, - title: name, - moji: name, - }; - }, - }, - ], - toDOM: (node) => [ - 'gl-emoji', - { 'data-name': node.attrs.name, title: node.attrs.title }, - node.attrs.moji, - ], - }, - toMarkdown(state, node) { - state.write(`:${node.attrs.name}:`); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js deleted file mode 100644 index 1668af9c3f4..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js +++ /dev/null @@ -1,14 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'hard_break', - schema: { - inline: true, - group: 'inline', - selectable: false, - parseDOM: [{ tag: 'br' }], - toDOM: () => ['br'], - }, - toMarkdown(state) { - if (!state.atBlank()) state.write(' \n'); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js deleted file mode 100644 index 21b4ec69b70..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/heading.js +++ /dev/null @@ -1,27 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default ({ levels = [1, 2, 3, 4, 5, 6] } = {}) => ({ - name: 'heading', - schema: { - attrs: { - level: { - default: 1, - }, - }, - content: 'inline*', - group: 'block', - defining: true, - draggable: false, - parseDOM: levels.map((level) => ({ - tag: `h${level}`, - attrs: { level }, - })), - toDOM: (node) => [`h${node.attrs.level}`, 0], - }, - toMarkdown(state, node) { - if (!node.childCount) return; - - defaultMarkdownSerializer.nodes.heading(state, node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js deleted file mode 100644 index 2d7074e567f..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js +++ /dev/null @@ -1,14 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'horizontal_rule', - schema: { - group: 'block', - parseDOM: [{ tag: 'hr' }], - toDOM: () => ['hr'], - }, - toMarkdown(state, node) { - defaultMarkdownSerializer.nodes.horizontal_rule(state, node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js deleted file mode 100644 index 370cc347a05..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/image.js +++ /dev/null @@ -1,48 +0,0 @@ -import { placeholderImage } from '~/lazy_loader'; -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; -import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; - -export default () => ({ - name: 'image', - schema: { - attrs: { - src: {}, - alt: { - default: null, - }, - title: { - default: null, - }, - }, - group: 'inline', - inline: true, - draggable: true, - parseDOM: [ - // Matches HTML generated by Banzai::Filter::ImageLinkFilter - { - tag: 'a.no-attachment-icon', - priority: HIGHER_PARSE_RULE_PRIORITY, - skip: true, - }, - // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter - { - tag: 'img[src]:not(.emoji)', - getAttrs: (el) => { - const imageSrc = el.src; - const imageUrl = - imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || ''; - - return { - src: imageUrl, - title: el.getAttribute('title'), - alt: el.getAttribute('alt'), - }; - }, - }, - ], - toDOM: (node) => ['img', node.attrs], - }, - toMarkdown(state, node) { - defaultMarkdownSerializer.nodes.image(state, node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js deleted file mode 100644 index 97c1f07427d..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js +++ /dev/null @@ -1,16 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'list_item', - schema: { - content: 'paragraph block*', - defining: true, - draggable: false, - parseDOM: [{ tag: 'li' }], - toDOM: () => ['li', 0], - }, - toMarkdown(state, node) { - defaultMarkdownSerializer.nodes.list_item(state, node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js deleted file mode 100644 index f2f3eff266a..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js +++ /dev/null @@ -1,25 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'ordered_list', - schema: { - attrs: { - order: { - default: 1, - }, - }, - content: 'list_item+', - group: 'block', - parseDOM: [ - { - tag: 'ol', - getAttrs: (dom) => ({ - order: dom.hasAttribute('start') ? dom.getAttribute('start') + 1 : 1, - }), - }, - ], - toDOM: (node) => (node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0]), - }, - toMarkdown(state, node) { - state.renderList(node, ' ', () => '1. '); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js deleted file mode 100644 index 53a6a0d9e07..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js +++ /dev/null @@ -1,21 +0,0 @@ -import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; - -// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter -export default () => ({ - name: 'ordered_task_list', - schema: { - group: 'block', - content: '(task_list_item|list_item)+', - parseDOM: [ - { - priority: HIGHER_PARSE_RULE_PRIORITY, - tag: 'ol.task-list', - }, - ], - toDOM: () => ['ol', { class: 'task-list' }, 0], - }, - - toMarkdown(state, node) { - state.renderList(node, ' ', () => '1. '); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js deleted file mode 100644 index 310feebb390..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js +++ /dev/null @@ -1,15 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'paragraph', - schema: { - content: 'inline*', - group: 'block', - parseDOM: [{ tag: 'p' }], - toDOM: () => ['p', 0], - }, - toMarkdown(state, node) { - defaultMarkdownSerializer.nodes.paragraph(state, node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/playable.js b/app/assets/javascripts/behaviors/markdown/nodes/playable.js deleted file mode 100644 index 7559c2a6a8a..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/playable.js +++ /dev/null @@ -1,56 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -/** - * Abstract base class for playable media, like video and audio. - * Must not be instantiated directly. Subclasses must set - * the `mediaType` property in their constructors. - * @abstract - */ -export default ({ mediaType, extraElementAttrs = {} }) => { - const attrs = { - src: {}, - alt: { - default: null, - }, - }; - const parseDOM = [ - { - // eslint-disable-next-line @gitlab/require-i18n-strings - tag: `.${mediaType}-container`, - getAttrs: (el) => ({ - src: el.querySelector(mediaType).src, - alt: el.querySelector(mediaType).dataset.title, - }), - }, - ]; - const toDOM = (node) => [ - 'span', - { class: `media-container ${mediaType}-container` }, - [ - mediaType, - { - src: node.attrs.src, - controls: true, - 'data-setup': '{}', - 'data-title': node.attrs.alt, - ...extraElementAttrs, - }, - ], - ['a', { href: node.attrs.src }, node.attrs.alt], - ]; - - return { - name: mediaType, - schema: { - attrs, - group: 'inline', - inline: true, - draggable: true, - parseDOM, - toDOM, - }, - toMarkdown(state, node) { - defaultMarkdownSerializer.nodes.image(state, node); - }, - }; -}; diff --git a/app/assets/javascripts/behaviors/markdown/nodes/reference.js b/app/assets/javascripts/behaviors/markdown/nodes/reference.js deleted file mode 100644 index 9ae6ab07004..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/reference.js +++ /dev/null @@ -1,44 +0,0 @@ -import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; - -// Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses -export default () => ({ - name: 'reference', - schema: { - inline: true, - group: 'inline', - atom: true, - attrs: { - className: {}, - referenceType: {}, - originalText: { default: null }, - href: {}, - text: {}, - }, - parseDOM: [ - { - tag: 'a.gfm:not([data-link=true])', - priority: HIGHER_PARSE_RULE_PRIORITY, - getAttrs: (el) => ({ - className: el.className, - referenceType: el.dataset.referenceType, - originalText: el.dataset.original, - href: el.getAttribute('href'), - text: el.textContent, - }), - }, - ], - toDOM: (node) => [ - 'a', - { - class: node.attrs.className, - href: node.attrs.href, - 'data-reference-type': node.attrs.referenceType, - 'data-original': node.attrs.originalText, - }, - node.attrs.text, - ], - }, - toMarkdown(state, node) { - state.write(node.attrs.originalText || node.attrs.text); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/summary.js b/app/assets/javascripts/behaviors/markdown/nodes/summary.js deleted file mode 100644 index eb91b3c981e..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/summary.js +++ /dev/null @@ -1,17 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'summary', - schema: { - content: 'text*', - marks: '', - defining: true, - parseDOM: [{ tag: 'summary' }], - toDOM: () => ['summary', 0], - }, - toMarkdown(state, node) { - state.write(''); - state.text(node.textContent, false); - state.write(''); - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table.js b/app/assets/javascripts/behaviors/markdown/nodes/table.js deleted file mode 100644 index c766f7f1fba..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/table.js +++ /dev/null @@ -1,15 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'table', - schema: { - content: 'table_head table_body', - group: 'block', - isolating: true, - parseDOM: [{ tag: 'table' }], - toDOM: () => ['table', 0], - }, - toMarkdown(state, node) { - state.renderContent(node); - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js deleted file mode 100644 index 0a49fb558ae..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js +++ /dev/null @@ -1,14 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'table_body', - schema: { - content: 'table_row+', - parseDOM: [{ tag: 'tbody' }], - toDOM: () => ['tbody', 0], - }, - toMarkdown: (state, node) => { - state.flushClose(1); - state.renderContent(node); - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js deleted file mode 100644 index f46344ba43c..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js +++ /dev/null @@ -1,25 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'table_cell', - schema: { - attrs: { - header: { default: false }, - align: { default: null }, - }, - content: 'inline*', - isolating: true, - parseDOM: [ - { - tag: 'td, th', - getAttrs: (el) => ({ - header: el.tagName === 'TH', - align: el.getAttribute('align') || el.style.textAlign, - }), - }, - ], - toDOM: (node) => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0], - }, - toMarkdown: (state, node) => { - state.renderInline(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js deleted file mode 100644 index 2e9b53ee0ac..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js +++ /dev/null @@ -1,14 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'table_head', - schema: { - content: 'table_header_row', - parseDOM: [{ tag: 'thead' }], - toDOM: () => ['thead', 0], - }, - toMarkdown: (state, node) => { - state.flushClose(1); - state.renderContent(node); - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js deleted file mode 100644 index d8aa497066c..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js +++ /dev/null @@ -1,36 +0,0 @@ -import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; -import TableRow from './table_row'; - -const CENTER_ALIGN = 'center'; - -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'table_header_row', - schema: { - content: 'table_cell+', - parseDOM: [ - { - tag: 'thead tr', - priority: HIGHER_PARSE_RULE_PRIORITY, - }, - ], - toDOM: () => ['tr', 0], - }, - toMarkdown: (state, node) => { - const cellWidths = TableRow().toMarkdown(state, node); - - state.flushClose(1); - - state.write('|'); - node.forEach((cell, _, i) => { - if (i) state.write('|'); - - state.write(cell.attrs.align === CENTER_ALIGN ? ':' : '-'); - state.write(state.repeat('-', cellWidths[i])); - state.write(cell.attrs.align === CENTER_ALIGN || cell.attrs.align === 'right' ? ':' : '-'); - }); - state.write('|'); - - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js deleted file mode 100644 index 4a0256c4644..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js +++ /dev/null @@ -1,26 +0,0 @@ -import { __ } from '~/locale'; -import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; - -// Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter -export default () => ({ - name: 'table_of_contents', - schema: { - group: 'block', - atom: true, - parseDOM: [ - { - tag: 'ul.section-nav', - priority: HIGHER_PARSE_RULE_PRIORITY, - }, - { - tag: 'p.table-of-contents', - priority: HIGHER_PARSE_RULE_PRIORITY, - }, - ], - toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')], - }, - toMarkdown: (state, node) => { - state.write('[[_TOC_]]'); - state.closeBlock(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js deleted file mode 100644 index 3830dae4f0d..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js +++ /dev/null @@ -1,28 +0,0 @@ -// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter -export default () => ({ - name: 'table_row', - schema: { - content: 'table_cell+', - parseDOM: [{ tag: 'tr' }], - toDOM: () => ['tr', 0], - }, - toMarkdown: (state, node) => { - const cellWidths = []; - - state.flushClose(1); - - state.write('| '); - node.forEach((cell, _, i) => { - if (i) state.write(' | '); - - const { length } = state.out; - state.render(cell, node, i); - cellWidths.push(state.out.length - length); - }); - state.write(' |'); - - state.closeBlock(node); - - return cellWidths; - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js deleted file mode 100644 index 3c3812ad8f7..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js +++ /dev/null @@ -1,20 +0,0 @@ -import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; - -// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter -export default () => ({ - name: 'task_list', - schema: { - group: 'block', - content: '(task_list_item|list_item)+', - parseDOM: [ - { - priority: HIGHER_PARSE_RULE_PRIORITY, - tag: 'ul.task-list', - }, - ], - toDOM: () => ['ul', { class: 'task-list' }, 0], - }, - toMarkdown(state, node) { - state.renderList(node, ' ', () => '* '); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js deleted file mode 100644 index e1c1bd58ee2..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js +++ /dev/null @@ -1,71 +0,0 @@ -import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; - -// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter -export default () => ({ - name: 'task_list_item', - schema: { - attrs: { - state: { - default: null, - }, - }, - defining: true, - draggable: false, - content: 'paragraph block*', - parseDOM: [ - { - priority: HIGHER_PARSE_RULE_PRIORITY, - tag: 'li.task-list-item', - getAttrs: (el) => { - const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox'); - if (checkbox?.matches('[data-inapplicable]')) { - return { state: 'inapplicable' }; - } - if (checkbox?.checked) { - return { state: 'done' }; - } - - return {}; - }, - }, - ], - toDOM(node) { - return [ - 'li', - { - class: () => { - if (node.attrs.state === 'inapplicable') { - return 'task-list-item inapplicable'; - } - - return 'task-list-item'; - }, - }, - [ - 'input', - { - type: 'checkbox', - class: 'task-list-item-checkbox', - checked: node.attrs.state === 'done', - 'data-inapplicable': node.attrs.state === 'inapplicable', - }, - ], - ['div', { class: 'todo-content' }, 0], - ]; - }, - }, - toMarkdown(state, node) { - switch (node.attrs.state) { - case 'done': - state.write('[x] '); - break; - case 'inapplicable': - state.write('[~] '); - break; - default: - state.write('[ ] '); - break; - } - state.renderContent(node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js deleted file mode 100644 index 0e1f0bc0e40..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/text.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; - -export default () => ({ - name: 'text', - schema: { - group: 'inline', - }, - toMarkdown(state, node) { - defaultMarkdownSerializer.nodes.text(state, node); - }, -}); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/video.js b/app/assets/javascripts/behaviors/markdown/nodes/video.js deleted file mode 100644 index aa1088826da..00000000000 --- a/app/assets/javascripts/behaviors/markdown/nodes/video.js +++ /dev/null @@ -1,4 +0,0 @@ -import playable from './playable'; - -// Transforms generated HTML back to GFM for Banzai::Filter::VideoLinkFilter -export default () => playable({ mediaType: 'video', extraElementAttrs: { width: '400' } }); diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js index 31bab23c8b0..baccddfc7cd 100644 --- a/app/assets/javascripts/behaviors/markdown/schema.js +++ b/app/assets/javascripts/behaviors/markdown/schema.js @@ -1,20 +1,13 @@ +import { flatMap } from 'lodash'; +import { Editor } from '@tiptap/vue-2'; +import OrderedMap from 'orderedmap'; import { Schema } from '@tiptap/pm/model'; -import editorExtensions from './editor_extensions'; +import * as extensions from '~/content_editor/extensions'; -const nodes = editorExtensions.nodes.reduce( - (ns, { name, schema }) => ({ - ...ns, - [name]: schema, - }), - {}, -); +const { schema } = new Editor({ extensions: flatMap(extensions) }); -const marks = editorExtensions.marks.reduce( - (ms, { name, schema }) => ({ - ...ms, - [name]: schema, - }), - {}, -); +const schemaSpec = { ...schema.spec }; +schemaSpec.marks = OrderedMap.from(schemaSpec.marks).remove('span'); +schemaSpec.nodes = OrderedMap.from(schemaSpec.nodes).remove('div').remove('pre'); -export default new Schema({ nodes, marks }); +export default new Schema(schemaSpec); diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js index e3e8a380cd5..fa21bd4630d 100644 --- a/app/assets/javascripts/behaviors/markdown/serializer.js +++ b/app/assets/javascripts/behaviors/markdown/serializer.js @@ -1,20 +1,3 @@ -import { MarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; -import editorExtensions from './editor_extensions'; +import MarkdownSerializer from '~/content_editor/services/markdown_serializer'; -const nodes = editorExtensions.nodes.reduce( - (ns, { name, toMarkdown }) => ({ - ...ns, - [name]: toMarkdown, - }), - {}, -); - -const marks = editorExtensions.marks.reduce( - (ms, { name, toMarkdown }) => ({ - ...ms, - [name]: toMarkdown, - }), - {}, -); - -export default new MarkdownSerializer(nodes, marks); +export default new MarkdownSerializer(); diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index da5ac7eb158..e75178789da 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -65,6 +65,48 @@ export default CodeBlockLowlight.extend({ tag: 'pre.js-syntax-highlight', preserveWhitespace: 'full', }, + // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, + // Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, + // or Banzai::Filter::SuggestionFilter + { + tag: 'pre.code.highlight', + preserveWhitespace: 'full', + }, + // Matches HTML generated by Banzai::Filter::MathFilter, + // after being transformed by ~/behaviors/markdown/render_math.js + { + tag: 'span.katex-display', + preserveWhitespace: 'full', + contentElement: 'annotation[encoding="application/x-tex"]', + attrs: { language: 'math' }, + }, + // Matches HTML generated by Banzai::Filter::MermaidFilter, + // after being transformed by ~/behaviors/markdown/render_sandboxed_mermaid.js + { + tag: 'svg.mermaid', + preserveWhitespace: 'full', + contentElement: 'text.source', + attrs: { language: 'mermaid' }, + }, + // Matches HTML generated by Banzai::Filter::SuggestionFilter, + // after being transformed by ~/vue_shared/components/markdown/suggestions.vue + { + tag: '.md-suggestion', + skip: true, + }, + { + tag: '.md-suggestion-header', + ignore: true, + }, + { + tag: '.md-suggestion-diff', + preserveWhitespace: 'full', + getContent: (el, schema) => + [...el.querySelectorAll('.line_content.new span')].map((span) => + schema.text(span.innerText), + ), + attrs: { language: 'suggestion' }, + }, ]; }, renderHTML({ HTMLAttributes }) { diff --git a/app/assets/javascripts/content_editor/extensions/code_suggestion.js b/app/assets/javascripts/content_editor/extensions/code_suggestion.js index c70a96769fb..45090d57559 100644 --- a/app/assets/javascripts/content_editor/extensions/code_suggestion.js +++ b/app/assets/javascripts/content_editor/extensions/code_suggestion.js @@ -14,7 +14,7 @@ export default CodeBlockHighlight.extend({ addOptions() { return { lowlight, - config: {}, + codeSuggestionsConfig: {}, }; }, @@ -38,10 +38,13 @@ export default CodeBlockHighlight.extend({ // do not insert a new suggestion if already inside a suggestion if (editor.isActive('codeSuggestion')) return false; - const rawPath = ext.options.config.diffFile.view_path.replace('/blob/', '/raw/'); + const rawPath = ext.options.codeSuggestionsConfig.diffFile.view_path.replace( + '/blob/', + '/raw/', + ); const allLines = (await memoizedGet(rawPath)).split('\n'); - const { line } = ext.options.config; - let { lines } = ext.options.config; + const { line } = ext.options.codeSuggestionsConfig; + let { lines } = ext.options.codeSuggestionsConfig; if (!lines.length) lines = [line]; diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js index 96e03dfe598..4cfe2c6b06c 100644 --- a/app/assets/javascripts/content_editor/extensions/emoji.js +++ b/app/assets/javascripts/content_editor/extensions/emoji.js @@ -1,5 +1,6 @@ import { Node, InputRule } from '@tiptap/core'; import { initEmojiMap, getEmojiMap } from '~/emoji'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; export default Node.create({ name: 'emoji', @@ -12,21 +13,10 @@ export default Node.create({ addAttributes() { return { - moji: { - default: null, - parseHTML: (element) => element.textContent, - }, - name: { - default: null, - parseHTML: (element) => element.dataset.name, - }, - title: { - default: null, - }, - unicodeVersion: { - default: '6.0', - parseHTML: (element) => element.dataset.unicodeVersion, - }, + moji: { default: null }, + name: { default: null }, + title: { default: null }, + unicodeVersion: { default: '6.0' }, }; }, @@ -34,6 +24,26 @@ export default Node.create({ return [ { tag: 'gl-emoji', + getAttrs: (el) => ({ + name: el.dataset.name, + title: el.getAttribute('title'), + moji: el.textContent, + unicodeVersion: el.dataset.unicodeVersion || '6.0', + }), + }, + { + tag: 'img.emoji', + getAttrs: (el) => { + const name = el.getAttribute('title').replace(/^:|:$/g, ''); + + return { + name, + title: name, + moji: name, + unicodeVersion: 'custom', + }; + }, + priority: PARSE_HTML_PRIORITY_HIGHEST, }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/index.js b/app/assets/javascripts/content_editor/extensions/index.js new file mode 100644 index 00000000000..c611d4aff07 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/index.js @@ -0,0 +1,62 @@ +export { default as Attachment } from './attachment'; +export { default as Audio } from './audio'; +export { default as Blockquote } from './blockquote'; +export { default as Bold } from './bold'; +export { default as BulletList } from './bullet_list'; +export { default as Code } from './code'; +export { default as CodeBlockHighlight } from './code_block_highlight'; +export { default as CodeSuggestion } from './code_suggestion'; +export { default as ColorChip } from './color_chip'; +export { default as CopyPaste } from './copy_paste'; +export { default as DescriptionItem } from './description_item'; +export { default as DescriptionList } from './description_list'; +export { default as Details } from './details'; +export { default as DetailsContent } from './details_content'; +export { default as Diagram } from './diagram'; +export { default as DrawioDiagram } from './drawio_diagram'; +export { default as Document } from './document'; +export { default as Dropcursor } from './dropcursor'; +export { default as Emoji } from './emoji'; +export { default as ExternalKeydownHandler } from './external_keydown_handler'; +export { default as Figure } from './figure'; +export { default as FigureCaption } from './figure_caption'; +export { default as FootnoteDefinition } from './footnote_definition'; +export { default as FootnoteReference } from './footnote_reference'; +export { default as FootnotesSection } from './footnotes_section'; +export { default as Frontmatter } from './frontmatter'; +export { default as Gapcursor } from './gapcursor'; +export { default as HardBreak } from './hard_break'; +export { default as Heading } from './heading'; +export { default as History } from './history'; +export { default as Highlight } from './highlight'; +export { default as HorizontalRule } from './horizontal_rule'; +export { default as HTMLMarks } from './html_marks'; +export { default as HTMLNodes } from './html_nodes'; +export { default as Image } from './image'; +export { default as InlineDiff } from './inline_diff'; +export { default as Italic } from './italic'; +export { default as Link } from './link'; +export { default as ListItem } from './list_item'; +export { default as Loading } from './loading'; +export { default as MathInline } from './math_inline'; +export { default as OrderedList } from './ordered_list'; +export { default as Paragraph } from './paragraph'; +export { default as Reference } from './reference'; +export { default as ReferenceLabel } from './reference_label'; +export { default as ReferenceDefinition } from './reference_definition'; +export { default as Selection } from './selection'; +export { default as Sourcemap } from './sourcemap'; +export { default as Strike } from './strike'; +export { default as Subscript } from './subscript'; +export { default as Suggestions } from './suggestions'; +export { default as Superscript } from './superscript'; +export { default as Table } from './table'; +export { default as TableCell } from './table_cell'; +export { default as TableHeader } from './table_header'; +export { default as TableOfContents } from './table_of_contents'; +export { default as TableRow } from './table_row'; +export { default as TaskItem } from './task_item'; +export { default as TaskList } from './task_list'; +export { default as Text } from './text'; +export { default as Video } from './video'; +export { default as WordBreak } from './word_break'; diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js index f76943a0669..905b1f2d42e 100644 --- a/app/assets/javascripts/content_editor/extensions/inline_diff.js +++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js @@ -13,7 +13,6 @@ export default Mark.create({ return { type: { default: 'addition', - parseHTML: (element) => (element.classList.contains('deletion') ? 'deletion' : 'addition'), }, }; }, @@ -21,7 +20,12 @@ export default Mark.create({ parseHTML() { return [ { - tag: 'span.idiff', + tag: 'span.idiff.addition', + attrs: { type: 'addition' }, + }, + { + tag: 'span.idiff.deletion', + attrs: { type: 'deletion' }, }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/math_inline.js b/app/assets/javascripts/content_editor/extensions/math_inline.js index 4844f6feb29..b95ef10550f 100644 --- a/app/assets/javascripts/content_editor/extensions/math_inline.js +++ b/app/assets/javascripts/content_editor/extensions/math_inline.js @@ -1,15 +1,21 @@ import { Mark, markInputRule } from '@tiptap/core'; import { __ } from '~/locale'; -import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import { PARSE_HTML_PRIORITY_HIGH } from '../constants'; export default Mark.create({ name: 'mathInline', parseHTML() { return [ + // Matches HTML generated by Banzai::Filter::MathFilter { tag: 'code.math[data-math-style=inline]', - priority: PARSE_HTML_PRIORITY_HIGHEST, + priority: PARSE_HTML_PRIORITY_HIGH, + }, + // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js + { + tag: 'span.katex', + contentElement: 'annotation[encoding="application/x-tex"]', }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/strike.js b/app/assets/javascripts/content_editor/extensions/strike.js index b6c9a968fc2..ea1abd35b87 100644 --- a/app/assets/javascripts/content_editor/extensions/strike.js +++ b/app/assets/javascripts/content_editor/extensions/strike.js @@ -1 +1,22 @@ -export { Strike as default } from '@tiptap/extension-strike'; +import { Strike } from '@tiptap/extension-strike'; + +export default Strike.extend({ + addAttributes() { + return { + ...this.parent?.(), + + htmlTag: { + default: null, + renderHTML: () => '', + }, + }; + }, + + parseHTML() { + return [ + { tag: 'del' }, + { tag: 's', attrs: { htmlTag: 's' } }, + { tag: 'strike', attrs: { htmlTag: 'strike' } }, + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js index 1e19878be9b..50ea6f393d9 100644 --- a/app/assets/javascripts/content_editor/extensions/task_item.js +++ b/app/assets/javascripts/content_editor/extensions/task_item.js @@ -42,7 +42,7 @@ export default TaskItem.extend({ priority: PARSE_HTML_PRIORITY_HIGHEST, }, { - tag: 'li.task-list-item.inapplicable s', + tag: 'li.inapplicable > s, li.inapplicable > p:first-of-type > s', skip: true, priority: PARSE_HTML_PRIORITY_HIGHEST, }, diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 5c48c0b1d43..b7082910161 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -1,69 +1,8 @@ import { Editor } from '@tiptap/vue-2'; -import { isFunction } from 'lodash'; +import { isFunction, flatMap } from 'lodash'; import eventHubFactory from '~/helpers/event_hub_factory'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; -import Attachment from '../extensions/attachment'; -import Audio from '../extensions/audio'; -import Blockquote from '../extensions/blockquote'; -import Bold from '../extensions/bold'; -import BulletList from '../extensions/bullet_list'; -import Code from '../extensions/code'; -import CodeBlockHighlight from '../extensions/code_block_highlight'; -import CodeSuggestion from '../extensions/code_suggestion'; -import ColorChip from '../extensions/color_chip'; -import CopyPaste from '../extensions/copy_paste'; -import DescriptionItem from '../extensions/description_item'; -import DescriptionList from '../extensions/description_list'; -import Details from '../extensions/details'; -import DetailsContent from '../extensions/details_content'; -import Diagram from '../extensions/diagram'; -import DrawioDiagram from '../extensions/drawio_diagram'; -import Document from '../extensions/document'; -import Dropcursor from '../extensions/dropcursor'; -import Emoji from '../extensions/emoji'; -import ExternalKeydownHandler from '../extensions/external_keydown_handler'; -import Figure from '../extensions/figure'; -import FigureCaption from '../extensions/figure_caption'; -import FootnoteDefinition from '../extensions/footnote_definition'; -import FootnoteReference from '../extensions/footnote_reference'; -import FootnotesSection from '../extensions/footnotes_section'; -import Frontmatter from '../extensions/frontmatter'; -import Gapcursor from '../extensions/gapcursor'; -import HardBreak from '../extensions/hard_break'; -import Heading from '../extensions/heading'; -import History from '../extensions/history'; -import Highlight from '../extensions/highlight'; -import HorizontalRule from '../extensions/horizontal_rule'; -import HTMLMarks from '../extensions/html_marks'; -import HTMLNodes from '../extensions/html_nodes'; -import Image from '../extensions/image'; -import InlineDiff from '../extensions/inline_diff'; -import Italic from '../extensions/italic'; -import Link from '../extensions/link'; -import ListItem from '../extensions/list_item'; -import Loading from '../extensions/loading'; -import MathInline from '../extensions/math_inline'; -import OrderedList from '../extensions/ordered_list'; -import Paragraph from '../extensions/paragraph'; -import Reference from '../extensions/reference'; -import ReferenceLabel from '../extensions/reference_label'; -import ReferenceDefinition from '../extensions/reference_definition'; -import Selection from '../extensions/selection'; -import Sourcemap from '../extensions/sourcemap'; -import Strike from '../extensions/strike'; -import Subscript from '../extensions/subscript'; -import Suggestions from '../extensions/suggestions'; -import Superscript from '../extensions/superscript'; -import Table from '../extensions/table'; -import TableCell from '../extensions/table_cell'; -import TableHeader from '../extensions/table_header'; -import TableOfContents from '../extensions/table_of_contents'; -import TableRow from '../extensions/table_row'; -import TaskItem from '../extensions/task_item'; -import TaskList from '../extensions/task_list'; -import Text from '../extensions/text'; -import Video from '../extensions/video'; -import WordBreak from '../extensions/word_break'; +import * as builtInExtensions from '../extensions'; import { ContentEditor } from './content_editor'; import MarkdownSerializer from './markdown_serializer'; import createGlApiMarkdownDeserializer from './gl_api_markdown_deserializer'; @@ -107,68 +46,18 @@ export const createContentEditor = ({ render: renderMarkdown, }); - const builtInContentEditorExtensions = [ - Attachment.configure({ uploadsPath, renderMarkdown, eventHub }), - Audio, - Blockquote, - Bold, - BulletList, - Code, - ColorChip, - CodeBlockHighlight, - CodeSuggestion.configure({ config: codeSuggestionsConfig }), - DescriptionItem, - DescriptionList, - Details, - DetailsContent, - Document, - Diagram, - Dropcursor, - Emoji, - Figure, - FigureCaption, - FootnoteDefinition, - FootnoteReference, - FootnotesSection, - Frontmatter, - Gapcursor, - HardBreak, - Heading, - History, - Highlight, - HorizontalRule, - ...HTMLMarks, - ...HTMLNodes, - Image, - InlineDiff, - Italic, - ExternalKeydownHandler.configure({ eventHub }), - Link, - ListItem, - Loading, - MathInline, - OrderedList, - Paragraph, - CopyPaste.configure({ eventHub, renderMarkdown, serializer }), - Reference.configure({ assetResolver }), - ReferenceLabel, - ReferenceDefinition, - Selection, - Sourcemap, - Strike, - Subscript, - Superscript, - TableCell, - TableHeader, - TableOfContents, - TableRow, - Table, - TaskItem, - TaskList, - Text, - Video, - WordBreak, - ]; + const { Suggestions, DrawioDiagram, ...otherExtensions } = builtInExtensions; + + const builtInContentEditorExtensions = flatMap(otherExtensions).map((ext) => + ext.configure({ + uploadsPath, + renderMarkdown, + eventHub, + codeSuggestionsConfig, + serializer, + assetResolver, + }), + ); const allExtensions = [...builtInContentEditorExtensions, ...extensions]; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 3b759de57f2..5688f30dcc3 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -2,56 +2,7 @@ import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer, } from '~/lib/prosemirror_markdown_serializer'; -import Audio from '../extensions/audio'; -import Blockquote from '../extensions/blockquote'; -import Bold from '../extensions/bold'; -import BulletList from '../extensions/bullet_list'; -import Code from '../extensions/code'; -import CodeBlockHighlight from '../extensions/code_block_highlight'; -import CodeSuggestion from '../extensions/code_suggestion'; -import DescriptionItem from '../extensions/description_item'; -import DescriptionList from '../extensions/description_list'; -import Details from '../extensions/details'; -import DetailsContent from '../extensions/details_content'; -import DrawioDiagram from '../extensions/drawio_diagram'; -import Diagram from '../extensions/diagram'; -import Emoji from '../extensions/emoji'; -import Figure from '../extensions/figure'; -import FigureCaption from '../extensions/figure_caption'; -import FootnoteDefinition from '../extensions/footnote_definition'; -import FootnoteReference from '../extensions/footnote_reference'; -import Frontmatter from '../extensions/frontmatter'; -import HardBreak from '../extensions/hard_break'; -import Heading from '../extensions/heading'; -import HorizontalRule from '../extensions/horizontal_rule'; -import Highlight from '../extensions/highlight'; -import HTMLMarks from '../extensions/html_marks'; -import HTMLNodes from '../extensions/html_nodes'; -import Image from '../extensions/image'; -import InlineDiff from '../extensions/inline_diff'; -import Italic from '../extensions/italic'; -import Link from '../extensions/link'; -import ListItem from '../extensions/list_item'; -import Loading from '../extensions/loading'; -import MathInline from '../extensions/math_inline'; -import OrderedList from '../extensions/ordered_list'; -import Paragraph from '../extensions/paragraph'; -import Reference from '../extensions/reference'; -import ReferenceLabel from '../extensions/reference_label'; -import ReferenceDefinition from '../extensions/reference_definition'; -import Strike from '../extensions/strike'; -import Subscript from '../extensions/subscript'; -import Superscript from '../extensions/superscript'; -import Table from '../extensions/table'; -import TableCell from '../extensions/table_cell'; -import TableHeader from '../extensions/table_header'; -import TableOfContents from '../extensions/table_of_contents'; -import TableRow from '../extensions/table_row'; -import TaskItem from '../extensions/task_item'; -import TaskList from '../extensions/task_list'; -import Text from '../extensions/text'; -import Video from '../extensions/video'; -import WordBreak from '../extensions/word_break'; +import * as extensions from '../extensions'; import { renderCodeBlock, renderHardBreak, @@ -62,6 +13,8 @@ import { closeTag, renderOrderedList, renderImage, + renderHeading, + renderBlockquote, renderPlayable, renderHTMLNode, renderContent, @@ -78,13 +31,13 @@ import { const defaultSerializerConfig = { marks: { - [Bold.name]: bold, - [Italic.name]: italic, - [Code.name]: code, - [Subscript.name]: { open: '', close: '', mixable: true }, - [Superscript.name]: { open: '', close: '', mixable: true }, - [Highlight.name]: { open: '', close: '', mixable: true }, - [InlineDiff.name]: { + [extensions.Bold.name]: bold, + [extensions.Italic.name]: italic, + [extensions.Code.name]: code, + [extensions.Subscript.name]: { open: '', close: '', mixable: true }, + [extensions.Superscript.name]: { open: '', close: '', mixable: true }, + [extensions.Highlight.name]: { open: '', close: '', mixable: true }, + [extensions.InlineDiff.name]: { mixable: true, open(_, mark) { return mark.attrs.type === 'addition' ? '{+' : '{-'; @@ -93,14 +46,14 @@ const defaultSerializerConfig = { return mark.attrs.type === 'addition' ? '+}' : '-}'; }, }, - [Link.name]: link, - [MathInline.name]: { + [extensions.Link.name]: link, + [extensions.MathInline.name]: { open: (...args) => `$${defaultMarkdownSerializer.marks.code.open(...args)}`, close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`, escape: false, }, - [Strike.name]: strike, - ...HTMLMarks.reduce( + [extensions.Strike.name]: strike, + ...extensions.HTMLMarks.reduce( (acc, { name }) => ({ ...acc, [name]: { @@ -116,38 +69,27 @@ const defaultSerializerConfig = { }, nodes: { - [Audio.name]: preserveUnchanged({ + [extensions.Audio.name]: preserveUnchanged({ render: renderPlayable, inline: true, }), - [Blockquote.name]: preserveUnchanged((state, node) => { - if (node.attrs.multiline) { - state.write('>>>'); - state.ensureNewLine(); - state.renderContent(node); - state.ensureNewLine(); - state.write('>>>'); - state.closeBlock(node); - } else { - state.wrapBlock('> ', null, node, () => state.renderContent(node)); - } - }), - [BulletList.name]: preserveUnchanged(renderBulletList), - [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), - [Diagram.name]: preserveUnchanged(renderCodeBlock), - [CodeSuggestion.name]: preserveUnchanged(renderCodeBlock), - [DrawioDiagram.name]: preserveUnchanged({ + [extensions.Blockquote.name]: preserveUnchanged(renderBlockquote), + [extensions.BulletList.name]: preserveUnchanged(renderBulletList), + [extensions.CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), + [extensions.Diagram.name]: preserveUnchanged(renderCodeBlock), + [extensions.CodeSuggestion.name]: preserveUnchanged(renderCodeBlock), + [extensions.DrawioDiagram.name]: preserveUnchanged({ render: renderImage, inline: true, }), - [DescriptionList.name]: renderHTMLNode('dl', true), - [DescriptionItem.name]: (state, node, parent, index) => { + [extensions.DescriptionList.name]: renderHTMLNode('dl', true), + [extensions.DescriptionItem.name]: (state, node, parent, index) => { if (index === 1) state.ensureNewLine(); renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node); if (index === parent.childCount - 1) state.ensureNewLine(); }, - [Details.name]: renderHTMLNode('details', true), - [DetailsContent.name]: (state, node, parent, index) => { + [extensions.Details.name]: renderHTMLNode('details', true), + [extensions.DetailsContent.name]: (state, node, parent, index) => { if (!index) renderHTMLNode('summary')(state, node); else { if (index === 1) state.ensureNewLine(); @@ -155,23 +97,23 @@ const defaultSerializerConfig = { if (index === parent.childCount - 1) state.ensureNewLine(); } }, - [Emoji.name]: (state, node) => { + [extensions.Emoji.name]: (state, node) => { const { name } = node.attrs; state.write(`:${name}:`); }, - [FootnoteDefinition.name]: preserveUnchanged((state, node) => { + [extensions.FootnoteDefinition.name]: preserveUnchanged((state, node) => { state.write(`[^${node.attrs.identifier}]: `); state.renderInline(node); state.ensureNewLine(); }), - [FootnoteReference.name]: preserveUnchanged({ + [extensions.FootnoteReference.name]: preserveUnchanged({ render: (state, node) => { state.write(`[^${node.attrs.identifier}]`); }, inline: true, }), - [Frontmatter.name]: preserveUnchanged((state, node) => { + [extensions.Frontmatter.name]: preserveUnchanged((state, node) => { const { language } = node.attrs; const syntax = { toml: '+++', @@ -185,22 +127,24 @@ const defaultSerializerConfig = { state.write(syntax); state.closeBlock(node); }), - [Figure.name]: renderHTMLNode('figure'), - [FigureCaption.name]: renderHTMLNode('figcaption'), - [HardBreak.name]: preserveUnchanged(renderHardBreak), - [Heading.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.heading), - [HorizontalRule.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.horizontal_rule), - [Image.name]: preserveUnchanged({ + [extensions.Figure.name]: renderHTMLNode('figure'), + [extensions.FigureCaption.name]: renderHTMLNode('figcaption'), + [extensions.HardBreak.name]: preserveUnchanged(renderHardBreak), + [extensions.Heading.name]: preserveUnchanged(renderHeading), + [extensions.HorizontalRule.name]: preserveUnchanged( + defaultMarkdownSerializer.nodes.horizontal_rule, + ), + [extensions.Image.name]: preserveUnchanged({ render: renderImage, inline: true, }), - [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), - [Loading.name]: () => {}, - [OrderedList.name]: preserveUnchanged(renderOrderedList), - [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), - [Reference.name]: renderReference, - [ReferenceLabel.name]: renderReferenceLabel, - [ReferenceDefinition.name]: preserveUnchanged({ + [extensions.ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), + [extensions.Loading.name]: () => {}, + [extensions.OrderedList.name]: preserveUnchanged(renderOrderedList), + [extensions.Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), + [extensions.Reference.name]: renderReference, + [extensions.ReferenceLabel.name]: renderReferenceLabel, + [extensions.ReferenceDefinition.name]: preserveUnchanged({ render: (state, node, parent, index, same, sourceMarkdown) => { const nextSibling = parent.maybeChild(index + 1); @@ -211,7 +155,7 @@ const defaultSerializerConfig = { * because it isn’t necessary and a more compact text format * is preferred. */ - if (!nextSibling || nextSibling.type.name !== ReferenceDefinition.name) { + if (!nextSibling || nextSibling.type.name !== extensions.ReferenceDefinition.name) { state.closeBlock(node); } else { state.ensureNewLine(); @@ -219,34 +163,35 @@ const defaultSerializerConfig = { }, overwriteSourcePreservationStrategy: true, }), - [TableOfContents.name]: preserveUnchanged((state, node) => { + [extensions.TableOfContents.name]: preserveUnchanged((state, node) => { state.write('[[_TOC_]]'); state.closeBlock(node); }), - [Table.name]: preserveUnchanged(renderTable), - [TableCell.name]: renderTableCell, - [TableHeader.name]: renderTableCell, - [TableRow.name]: renderTableRow, - [TaskItem.name]: preserveUnchanged((state, node) => { + [extensions.Table.name]: preserveUnchanged(renderTable), + [extensions.TableCell.name]: renderTableCell, + [extensions.TableHeader.name]: renderTableCell, + [extensions.TableRow.name]: renderTableRow, + [extensions.TaskItem.name]: preserveUnchanged((state, node) => { let symbol = ' '; if (node.attrs.inapplicable) symbol = '~'; else if (node.attrs.checked) symbol = 'x'; state.write(`[${symbol}] `); + if (!node.textContent) state.write(' '); state.renderContent(node); }), - [TaskList.name]: preserveUnchanged((state, node) => { + [extensions.TaskList.name]: preserveUnchanged((state, node) => { if (node.attrs.numeric) renderOrderedList(state, node); else renderBulletList(state, node); }), - [Text.name]: defaultMarkdownSerializer.nodes.text, - [Video.name]: preserveUnchanged({ + [extensions.Text.name]: defaultMarkdownSerializer.nodes.text, + [extensions.Video.name]: preserveUnchanged({ render: renderPlayable, inline: true, }), - [WordBreak.name]: (state) => state.write(''), - ...HTMLNodes.reduce((serializers, htmlNode) => { + [extensions.WordBreak.name]: (state) => state.write(''), + ...extensions.HTMLNodes.reduce((serializers, htmlNode) => { return { ...serializers, [htmlNode.name]: (state, node) => renderHTMLNode(htmlNode.options.tagName)(state, node), @@ -310,7 +255,7 @@ export default class MarkdownSerializer { * changed. * @returns A String that represents the serialized document as Markdown */ - serialize({ doc, pristineDoc }) { + serialize({ doc, pristineDoc }, { useCanonicalSrc = true, skipEmptyNodes = false } = {}) { const changeTracker = createChangeTracker(doc, pristineDoc); const serializer = new ProseMirrorMarkdownSerializer( { @@ -325,6 +270,8 @@ export default class MarkdownSerializer { return serializer.serialize(doc, { tightLists: true, + useCanonicalSrc, + skipEmptyNodes, changeTracker, escapeExtraCharacters: /<|>/g, }); diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 7a2fbf8fcab..4bf61e34120 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -1,4 +1,5 @@ -import { uniq, isString, omit, isFunction } from 'lodash'; +import { uniq, omit, isFunction } from 'lodash'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; import { removeLastSlashInUrlPath, removeUrlProtocol } from '../../lib/utils/url_utility'; const defaultAttrs = { @@ -110,8 +111,8 @@ function htmlEncode(str = '') { .replace(/&/g, '&') .replace(//g, '>') - .replace(/'/g, ''') - .replace(/"/g, '"'); + .replace(/'/g, ''') + .replace(/"/g, '"'); } const shouldIgnoreAttr = (tagName, attrKey, attrValue) => @@ -337,15 +338,22 @@ export function renderHardBreak(state, node, parent, index) { } } +function getMediaSrc(node, useCanonicalSrc = true) { + const { canonicalSrc, src } = node.attrs; + + if (useCanonicalSrc) return canonicalSrc || src || ''; + return src || ''; +} + export function renderImage(state, node) { - const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs; - const realSrc = canonicalSrc || src || ''; + const { alt, title, width, height, isReference } = node.attrs; + const realSrc = getMediaSrc(node, state.options.useCanonicalSrc); // eslint-disable-next-line @gitlab/require-i18n-strings if (realSrc.startsWith('data:') || realSrc.startsWith('blob:')) return; - if (isString(src) || isString(canonicalSrc)) { + if (realSrc) { const quotedTitle = title ? ` ${state.quote(title)}` : ''; - const sourceExpression = isReference ? `[${canonicalSrc}]` : `(${realSrc}${quotedTitle})`; + const sourceExpression = isReference ? `[${realSrc}]` : `(${realSrc}${quotedTitle})`; const sizeAttributes = []; if (width) { @@ -365,12 +373,44 @@ export function renderPlayable(state, node) { renderImage(state, node); } +export function renderHeading(state, node) { + if (state.options.skipEmptyNodes && !node.childCount) return; + + defaultMarkdownSerializer.nodes.heading(state, node); +} + +export function renderBlockquote(state, node) { + if (state.options.skipEmptyNodes) { + if (!node.childCount) return; + if (node.childCount === 1) { + const child = node.child(0); + if (child.type.name === 'paragraph' && !child.childCount) return; + } + } + + if (node.attrs.multiline) { + state.write('>>>'); + state.ensureNewLine(); + state.renderContent(node); + state.ensureNewLine(); + state.write('>>>'); + state.closeBlock(node); + } else { + state.wrapBlock('> ', null, node, () => state.renderContent(node)); + } +} + export function renderCodeBlock(state, node) { + if (state.options.skipEmptyNodes && !node.childCount) return; + + let { language } = node.attrs; + if (language === 'plaintext') language = ''; + const numBackticks = Math.max(2, node.textContent.match(/```+/g)?.[0]?.length || 0) + 1; const backticks = state.repeat('`', numBackticks); state.write( `${backticks}${ - (node.attrs.language || '') + (node.attrs.langParams ? `:${node.attrs.langParams}` : '') + (language || '') + (node.attrs.langParams ? `:${node.attrs.langParams}` : '') }\n`, ); state.text(node.textContent, false); @@ -641,13 +681,20 @@ const isAutoLink = (linkMark, parent) => { */ const isBracketAutoLink = (sourceMarkdown) => /^<.+?>$/.test(sourceMarkdown); +function getLinkHref(mark, useCanonicalSrc = true) { + const { canonicalSrc, href } = mark.attrs; + + if (useCanonicalSrc) return canonicalSrc || href || ''; + return href || ''; +} + export const link = { open(state, mark, parent) { if (isAutoLink(mark, parent)) { return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '<' : ''; } - const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + const { href, title, sourceMarkdown } = mark.attrs; // eslint-disable-next-line @gitlab/require-i18n-strings if (href.startsWith('data:') || href.startsWith('blob:')) return ''; @@ -656,7 +703,9 @@ export const link = { return '['; } - const attrs = { href: state.esc(href || canonicalSrc || '') }; + const attrs = { + href: state.esc(getLinkHref(mark, state.options.useCanonicalSrc)), + }; if (title) { attrs.title = title; @@ -669,25 +718,29 @@ export const link = { return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : ''; } - const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs; + const { href, title, sourceMarkdown, isReference } = mark.attrs; // eslint-disable-next-line @gitlab/require-i18n-strings if (href.startsWith('data:') || href.startsWith('blob:')) return ''; if (isReference) { - return `][${state.esc(canonicalSrc || href || '')}]`; + return `][${state.esc(getLinkHref(mark, state.options.useCanonicalSrc))}]`; } if (linkType(sourceMarkdown) === LINK_HTML) { return closeTag('a'); } - return `](${state.esc(canonicalSrc || href || '')}${title ? ` ${state.quote(title)}` : ''})`; + return `](${state.esc(getLinkHref(mark, state.options.useCanonicalSrc))}${ + title ? ` ${state.quote(title)}` : '' + })`; }, }; const generateStrikeTag = (wrapTagName = openTag) => { return (_, mark) => { + if (mark.attrs.htmlTag) return wrapTagName(mark.attrs.htmlTag); + const type = /^(~~| 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**' + '> 1. [x] [**$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~**](http://google.com)' ) verify( @@ -114,9 +114,9 @@ RSpec.describe 'Copy as GFM', :js, feature_category: :team_planning do GFM <<~GFM, 1. [ ] Unchecked ordered task - 1. [x] Checked ordered task - 1. [~] Inapplicable ordered task - 1. [~] Inapplicable ordered task with ~~del~~ and strike embedded + 2. [x] Checked ordered task + 3. [~] Inapplicable ordered task + 4. [~] Inapplicable ordered task with ~~del~~ and strike embedded GFM <<~GFM * [ ] Unchecked loose list task @@ -496,15 +496,14 @@ RSpec.describe 'Copy as GFM', :js, feature_category: :team_planning do sub
-
dt
-
dt
-
dd
-
dd
- -
dt
-
dt
-
dd
-
dd
+
dt
+
dt
+
dd
+
dd
+
dt
+
dt
+
dd
+
dd
kbd @@ -518,9 +517,8 @@ RSpec.describe 'Copy as GFM', :js, feature_category: :team_planning do HTML
- summary> - - details + summary + details
GFM ) @@ -542,17 +540,20 @@ RSpec.describe 'Copy as GFM', :js, feature_category: :team_planning do <<~GFM Foo - ```js - Code goes here - ``` + ```` + ```js + Code goes here + ``` + ```` GFM ) verify( 'MarkdownFilter', - "Line with two spaces at the end \nto insert a linebreak", + "Line with backslash at the end\\\nto insert a linebreak", '`code`', - '`` code with ` ticks ``', + '``code with ` ticks``', + '`` `nested` code ``', '> Quote', # multiline quote <<~GFM, @@ -589,7 +590,7 @@ RSpec.describe 'Copy as GFM', :js, feature_category: :team_planning do GFM <<~GFM, 1. Ordered list item - 1. Ordered list item 2 + 2. Ordered list item 2 GFM # multiline ordered list item @@ -618,7 +619,7 @@ RSpec.describe 'Copy as GFM', :js, feature_category: :team_planning do '##### Heading', '###### Heading', '**Bold**', - '*Italics*', + '_Italics_', '~~Strikethrough (del)~~', 'Strikethrough', '---', diff --git a/spec/frontend/behaviors/copy_as_gfm_spec.js b/spec/frontend/behaviors/copy_as_gfm_spec.js index 2032faa1c33..a35cf2f0b13 100644 --- a/spec/frontend/behaviors/copy_as_gfm_spec.js +++ b/spec/frontend/behaviors/copy_as_gfm_spec.js @@ -1,6 +1,8 @@ import waitForPromises from 'helpers/wait_for_promises'; import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; +jest.mock('~/emoji'); + describe('CopyAsGFM', () => { describe('CopyAsGFM.pasteGFM', () => { let target; @@ -116,7 +118,7 @@ describe('CopyAsGFM', () => { window.getSelection = jest.fn(() => selection); await simulateCopy(); - const expectedGFM = '1. List Item1\n1. List Item2'; + const expectedGFM = '1. List Item1\n2. List Item2'; expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); }); diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js index 6db99e796d6..3951714c64e 100644 --- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -11,6 +11,8 @@ jest.mock('~/lib/utils/common_utils', () => ({ getSelectedFragment: jest.fn().mockName('getSelectedFragment'), })); +jest.mock('~/emoji'); + describe('ShortcutsIssuable', () => { beforeAll(() => { initCopyAsGFM(); @@ -215,7 +217,7 @@ describe('ShortcutsIssuable', () => { ShortcutsIssuable.replyWithSelectedText(true); await waitForPromises(); - expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n'); + expect($(FORM_SELECTOR).val()).toBe('> _Selected text._\n\n'); }); it('triggers `focus`', async () => { diff --git a/spec/frontend/content_editor/extensions/code_suggestion_spec.js b/spec/frontend/content_editor/extensions/code_suggestion_spec.js index 86656fb96c3..b513735af22 100644 --- a/spec/frontend/content_editor/extensions/code_suggestion_spec.js +++ b/spec/frontend/content_editor/extensions/code_suggestion_spec.js @@ -28,7 +28,7 @@ describe('content_editor/extensions/code_suggestion', () => { let doc; let codeSuggestion; - const codeSuggestionConfig = { + const codeSuggestionsConfig = { canSuggest: true, line: { new_line: 5 }, lines: [{ new_line: 5 }], @@ -43,7 +43,9 @@ describe('content_editor/extensions/code_suggestion', () => { tiptapEditor = createTestEditor({ extensions: [ CodeBlockHighlight, - CodeSuggestion.configure({ config: { ...codeSuggestionConfig, ...config } }), + CodeSuggestion.configure({ + codeSuggestionsConfig: { ...codeSuggestionsConfig, ...config }, + }), ], }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 4ae39f7a5a7..aeaa63a332f 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -138,10 +138,10 @@ const { }, }); -const serialize = (...content) => - new MarkdownSerializer().serialize({ - doc: doc(...content), - }); +const serializeWithOptions = (options, ...content) => + new MarkdownSerializer().serialize({ doc: doc(...content) }, options); + +const serialize = (...content) => new MarkdownSerializer().serialize({ doc: doc(...content) }); describe('markdownSerializer', () => { it('correctly serializes bold', () => { @@ -389,6 +389,16 @@ describe('markdownSerializer', () => { expect(serialize(paragraph(strike('deleted content')))).toBe('~~deleted content~~'); }); + it.each` + strikeTag + ${'s'} + ${'strike'} + `('correctly serializes strikethrough with "$strikeTag" tag', ({ strikeTag }) => { + expect(serialize(paragraph(strike({ htmlTag: strikeTag }, 'deleted content')))).toBe( + `<${strikeTag}>deleted content`, + ); + }); + it('correctly serializes blockquotes with hard breaks', () => { expect(serialize(blockquote('some text', hardBreak(), hardBreak(), 'new line'))).toBe( ` @@ -411,6 +421,32 @@ describe('markdownSerializer', () => { ); }); + it('correctly serializes a blockquote with a nested blockquote', () => { + expect( + serialize( + blockquote( + paragraph('some paragraph'), + blockquote(paragraph('nested paragraph'), codeBlock('var x = 10;')), + ), + ), + ).toBe( + ` +> some paragraph +> +> > nested paragraph +> > +> > \`\`\` +> > var x = 10; +> > \`\`\` + `.trim(), + ); + }); + + it('skips serializing an empty blockquote if skipEmptyNodes=true', () => { + expect(serializeWithOptions({ skipEmptyNodes: true }, blockquote())).toBe(''); + expect(serializeWithOptions({ skipEmptyNodes: true }, blockquote(paragraph()))).toBe(''); + }); + it('correctly serializes a multiline blockquote', () => { expect( serialize( @@ -451,6 +487,23 @@ this is not really json but just trying out whether this case works or not ); }); + it('renders a plaintext code block without a prefix', () => { + expect( + serialize( + codeBlock( + { language: 'plaintext', langParams: '' }, + 'this is not really json but just trying out whether this case works or not', + ), + ), + ).toBe( + ` +\`\`\` +this is not really json but just trying out whether this case works or not +\`\`\` + `.trim(), + ); + }); + it('correctly serializes a code block with language parameters', () => { expect( serialize( @@ -514,6 +567,10 @@ var a = 0; ); }); + it('skips serializing an empty code block if skipEmptyNodes=true', () => { + expect(serializeWithOptions({ skipEmptyNodes: true }, codeBlock())).toBe(''); + }); + it('correctly serializes emoji', () => { expect(serialize(paragraph(emoji({ name: 'dog' })))).toBe(':dog:'); }); @@ -545,6 +602,20 @@ var a = 0; ); }); + it('skips serializing an empty heading if skipEmptyNodes=true', () => { + expect( + serializeWithOptions( + { skipEmptyNodes: true }, + heading({ level: 1 }), + heading({ level: 2 }), + heading({ level: 3 }), + heading({ level: 4 }), + heading({ level: 5 }), + heading({ level: 6 }), + ), + ).toBe(''); + }); + it('correctly serializes horizontal rule', () => { expect(serialize(horizontalRule(), horizontalRule(), horizontalRule())).toBe( ` @@ -614,6 +685,22 @@ var a = 0; ).toBe('![this is an image](file.png "foo bar baz")'); }); + it('does not use the canonicalSrc if options.useCanonicalSrc=false', () => { + expect( + serializeWithOptions( + { useCanonicalSrc: false }, + paragraph( + image({ + src: '/uploads/abcde/file.png', + alt: 'this is an image', + canonicalSrc: 'file.png', + title: 'foo bar baz', + }), + ), + ), + ).toBe('![this is an image](/uploads/abcde/file.png "foo bar baz")'); + }); + it('correctly serializes bullet list', () => { expect( serialize( diff --git a/spec/lib/gitlab/current_settings_spec.rb b/spec/lib/gitlab/current_settings_spec.rb index 7fc50438c95..17b2218e05c 100644 --- a/spec/lib/gitlab/current_settings_spec.rb +++ b/spec/lib/gitlab/current_settings_spec.rb @@ -88,6 +88,8 @@ RSpec.describe Gitlab::CurrentSettings, feature_category: :shared do end describe '#current_application_settings', :use_clean_rails_memory_store_caching do + let_it_be(:organization_settings) { create(:organization_setting, restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL]) } + it 'allows keys to be called directly' do described_class.update!(home_page_url: 'http://mydomain.com', signup_enabled: false) @@ -97,10 +99,58 @@ RSpec.describe Gitlab::CurrentSettings, feature_category: :shared do expect(described_class.metrics_sample_interval).to be(15) end - it 'retrieves settings using ApplicationSettingFetcher' do - expect(Gitlab::ApplicationSettingFetcher).to receive(:current_application_settings).and_call_original + context 'when key is in ApplicationSettingFetcher' do + it 'retrieves settings using ApplicationSettingFetcher' do + expect(Gitlab::ApplicationSettingFetcher).to receive(:current_application_settings).and_call_original + + described_class.home_page_url + end + end + + context 'when key is in OrganizationSetting' do + before do + allow(Gitlab::ApplicationSettingFetcher).to receive(:current_application_settings).and_return(nil) + end + + context 'and the current organization is known' do + before do + allow(Organizations::OrganizationSetting).to receive(:for_current_organization).and_return(organization_settings) + end + + it 'retrieves settings using OrganizationSetting' do + expect(described_class.restricted_visibility_levels).to eq(organization_settings.restricted_visibility_levels) + end + end + + context 'and the current organization is unknown' do + before do + allow(Organizations::OrganizationSetting).to receive(:for_current_organization).and_return(nil) + end + + it 'raises NoMethodError' do + expect { described_class.foo }.to raise_error(NoMethodError) + end + end + end + + context 'when key is in both sources' do + it 'for test purposes, ensure the values are different' do + expect( + Gitlab::ApplicationSettingFetcher.current_application_settings.restricted_visibility_levels + ).not_to eq(organization_settings.restricted_visibility_levels) + end - described_class.home_page_url + it 'prefers ApplicationSettingFetcher' do + expect(described_class.restricted_visibility_levels).to eq( + Gitlab::ApplicationSettingFetcher.current_application_settings.restricted_visibility_levels + ) + end + end + + context 'when key is in neither' do + it 'raises NoMethodError' do + expect { described_class.foo }.to raise_error(NoMethodError) + end end end diff --git a/spec/models/organizations/organization_setting_spec.rb b/spec/models/organizations/organization_setting_spec.rb index 376d0b7fe77..dd90a4d0f58 100644 --- a/spec/models/organizations/organization_setting_spec.rb +++ b/spec/models/organizations/organization_setting_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe Organizations::OrganizationSetting, type: :model, feature_category: :cell do let_it_be(:organization) { create(:organization) } + let_it_be(:organization_setting) { create(:organization_setting, organization: organization) } describe 'associations' do it { is_expected.to belong_to :organization } @@ -54,4 +55,41 @@ RSpec.describe Organizations::OrganizationSetting, type: :model, feature_categor end end end + + describe '.for_current_organization' do + let(:dummy_class) { Class.new { extend Organization::CurrentOrganization } } + + subject(:settings) { described_class.for_current_organization } + + context 'when there is no current organization' do + it { is_expected.to be_nil } + end + + context 'when there is a current organization' do + before do + dummy_class.current_organization = organization + end + + after do + dummy_class.current_organization = nil + end + + it 'returns current organization' do + expect(settings).to eq(organization_setting) + end + + context 'when current organization does not have settings' do + before do + allow(organization).to receive(:settings).and_return(nil) + end + + it 'returns new settings record' do + new_settings = settings + + expect(new_settings.organization).to eq(organization) + expect(new_settings.new_record?).to eq(true) + end + end + end + end end diff --git a/spec/scripts/lib/glfm/update_example_snapshots_spec.rb b/spec/scripts/lib/glfm/update_example_snapshots_spec.rb index 4897dbea09c..78cdb81f80c 100644 --- a/spec/scripts/lib/glfm/update_example_snapshots_spec.rb +++ b/spec/scripts/lib/glfm/update_example_snapshots_spec.rb @@ -746,7 +746,10 @@ RSpec.describe Glfm::UpdateExampleSnapshots, '#process', feature_category: :team "type": "text", "marks": [ { - "type": "strike" + "type": "strike", + "attrs": { + "htmlTag": null + } } ], "text": "Hi" -- cgit v1.2.3