diff options
Diffstat (limited to 'app/assets/javascripts/content_editor')
22 files changed, 441 insertions, 86 deletions
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue index f0726ff3e63..05ca7fd75c3 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue @@ -3,13 +3,11 @@ import { GlButtonGroup } from '@gitlab/ui'; import { BubbleMenu } from '@tiptap/vue-2'; import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants'; import trackUIControl from '../../services/track_ui_control'; -import Image from '../../extensions/image'; +import Paragraph from '../../extensions/paragraph'; +import Heading from '../../extensions/heading'; import Audio from '../../extensions/audio'; import Video from '../../extensions/video'; -import Code from '../../extensions/code'; -import CodeBlockHighlight from '../../extensions/code_block_highlight'; -import Diagram from '../../extensions/diagram'; -import Frontmatter from '../../extensions/frontmatter'; +import Image from '../../extensions/image'; import ToolbarButton from '../toolbar_button.vue'; export default { @@ -27,17 +25,13 @@ export default { shouldShow: ({ editor, from, to }) => { if (from === to) return false; - const exclude = [ - Code.name, - CodeBlockHighlight.name, - Diagram.name, - Frontmatter.name, - Image.name, - Audio.name, - Video.name, - ]; + const includes = [Paragraph.name, Heading.name]; + const excludes = [Image.name, Audio.name, Video.name]; - return !exclude.some((type) => editor.isActive(type)); + return ( + includes.some((type) => editor.isActive(type)) && + !excludes.some((type) => editor.isActive(type)) + ); }, }, }; diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 74ae37b6d06..c3c881d9135 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -84,7 +84,14 @@ export default { <template> <content-editor-provider :content-editor="contentEditor"> <div> - <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" /> + <editor-state-observer + @docUpdate="notifyChange" + @focus="focus" + @blur="blur" + @loading="$emit('loading')" + @loadingSuccess="$emit('loadingSuccess')" + @loadingError="$emit('loadingError')" + /> <content-editor-alert /> <div data-testid="content-editor" diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue index 6e4cde5dad6..9ad739e7358 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -33,8 +33,12 @@ export default { this.$emit('execute', { contentType: listType }); }, - execute(command, contentType) { - this.tiptapEditor.chain().focus()[command]().run(); + execute(command, contentType, ...args) { + this.tiptapEditor + .chain() + .focus() + [command](...args) + .run(); this.$emit('execute', { contentType }); }, @@ -67,5 +71,8 @@ export default { <gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })"> {{ __('PlantUML diagram') }} </gl-dropdown-item> + <gl-dropdown-item @click="execute('insertTableOfContents', 'tableOfContents')"> + {{ __('Table of contents') }} + </gl-dropdown-item> </gl-dropdown> </template> diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index 65d71814268..1030ebbf838 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -93,7 +93,7 @@ export default { icon-name="list-task" class="gl-mx-2 gl-display-none gl-sm-display-inline" editor-command="toggleTaskList" - :label="__('Add a task list')" + :label="__('Add a checklist')" @execute="trackToolbarControlExecution" /> <toolbar-image-button diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index c0d6e32a739..6456540a0dd 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; -import { selectedRect as getSelectedRect } from 'prosemirror-tables'; +import { selectedRect as getSelectedRect } from '@_ueberdosis/prosemirror-tables'; import { __ } from '~/locale'; const TABLE_CELL_HEADER = 'th'; diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue new file mode 100644 index 00000000000..a4e1be9d95d --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue @@ -0,0 +1,55 @@ +<script> +import { debounce } from 'lodash'; +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { getHeadings } from '../../services/table_of_contents_utils'; +import TableOfContentsHeading from './table_of_contents_heading.vue'; + +export default { + name: 'TableOfContentsWrapper', + components: { + NodeViewWrapper, + TableOfContentsHeading, + }, + props: { + editor: { + type: Object, + required: true, + }, + node: { + type: Object, + required: true, + }, + }, + data() { + return { + headings: [], + }; + }, + mounted() { + this.handleUpdate = debounce(this.handleUpdate, DEFAULT_DEBOUNCE_AND_THROTTLE_MS * 2); + + this.editor.on('update', this.handleUpdate); + this.$nextTick(this.handleUpdate); + }, + methods: { + handleUpdate() { + this.headings = getHeadings(this.editor); + }, + }, +}; +</script> +<template> + <node-view-wrapper + as="ul" + class="table-of-contents gl-border-1 gl-border-solid gl-border-gray-100 gl-mb-5 gl-p-4!" + data-testid="table-of-contents" + > + {{ __('Table of contents') }} + <table-of-contents-heading + v-for="(heading, index) in headings" + :key="index" + :heading="heading" + /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue new file mode 100644 index 00000000000..edd75d232e8 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue @@ -0,0 +1,25 @@ +<script> +export default { + name: 'TableOfContentsHeading', + props: { + heading: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <li> + <a v-if="heading.text" href="#" @click.prevent> + {{ heading.text }} + </a> + <ul v-if="heading.subHeadings.length"> + <table-of-contents-heading + v-for="(child, index) in heading.subHeadings" + :key="index" + :heading="child" + /> + </ul> + </li> +</template> 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 edf8b3d3a0b..27432b1e18b 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -1,3 +1,4 @@ +import { lowlight } from 'lowlight/lib/core'; import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; import { textblockTypeInputRule } from '@tiptap/core'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; @@ -66,4 +67,4 @@ export default CodeBlockLowlight.extend({ addNodeView() { return new VueNodeViewRenderer(CodeBlockWrapper); }, -}); +}).configure({ lowlight }); diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js index c59ca8a28b8..d9983b8c1c5 100644 --- a/app/assets/javascripts/content_editor/extensions/diagram.js +++ b/app/assets/javascripts/content_editor/extensions/diagram.js @@ -1,3 +1,4 @@ +import { lowlight } from 'lowlight/lib/core'; import { textblockTypeInputRule } from '@tiptap/core'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; import languageLoader from '../services/code_block_language_loader'; @@ -10,6 +11,12 @@ export default CodeBlockHighlight.extend({ isolating: true, + addOptions() { + return { + lowlight, + }; + }, + addAttributes() { return { language: { diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js index 2ec22158106..428171a9389 100644 --- a/app/assets/javascripts/content_editor/extensions/frontmatter.js +++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js @@ -1,9 +1,16 @@ +import { lowlight } from 'lowlight/lib/core'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; import CodeBlockHighlight from './code_block_highlight'; export default CodeBlockHighlight.extend({ name: 'frontmatter', + addOptions() { + return { + lowlight, + }; + }, + addAttributes() { return { ...this.parent?.(), diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 25f976f524f..65849ec4d0d 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -34,6 +34,7 @@ export default Image.extend({ canonicalSrc: { default: null, parseHTML: (element) => element.dataset.canonicalSrc, + renderHTML: () => '', }, alt: { default: null, @@ -51,6 +52,10 @@ export default Image.extend({ return img.getAttribute('title'); }, }, + isReference: { + default: false, + renderHTML: () => '', + }, }; }, parseHTML() { @@ -71,7 +76,6 @@ export default Image.extend({ src: HTMLAttributes.src, alt: HTMLAttributes.alt, title: HTMLAttributes.title, - 'data-canonical-src': HTMLAttributes.canonicalSrc, }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index f9b12f631fe..e985e561fda 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -56,6 +56,11 @@ export default Link.extend({ canonicalSrc: { default: null, parseHTML: (element) => element.dataset.canonicalSrc, + renderHTML: () => '', + }, + isReference: { + default: false, + renderHTML: () => '', }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/reference_definition.js b/app/assets/javascripts/content_editor/extensions/reference_definition.js new file mode 100644 index 00000000000..e2762fe9fd9 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/reference_definition.js @@ -0,0 +1,29 @@ +import { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'referenceDefinition', + + group: 'block', + + content: 'text*', + + marks: '', + + addAttributes() { + return { + identifier: { + default: null, + }, + url: { + default: null, + }, + title: { + default: null, + }, + }; + }, + + renderHTML() { + return ['pre', {}, 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js index 618f17b1c5e..f9de71f601b 100644 --- a/app/assets/javascripts/content_editor/extensions/sourcemap.js +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -6,6 +6,7 @@ import Code from './code'; import CodeBlockHighlight from './code_block_highlight'; import FootnoteReference from './footnote_reference'; import FootnoteDefinition from './footnote_definition'; +import Frontmatter from './frontmatter'; import Heading from './heading'; import HardBreak from './hard_break'; import HorizontalRule from './horizontal_rule'; @@ -16,6 +17,7 @@ import Link from './link'; import ListItem from './list_item'; import OrderedList from './ordered_list'; import Paragraph from './paragraph'; +import ReferenceDefinition from './reference_definition'; import Strike from './strike'; import TaskList from './task_list'; import TaskItem from './task_item'; @@ -36,6 +38,7 @@ export default Extension.create({ CodeBlockHighlight.name, FootnoteReference.name, FootnoteDefinition.name, + Frontmatter.name, HardBreak.name, Heading.name, HorizontalRule.name, @@ -45,6 +48,7 @@ export default Extension.create({ ListItem.name, OrderedList.name, Paragraph.name, + ReferenceDefinition.name, Strike.name, TaskList.name, TaskItem.name, diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js index a8882f9ede4..f64ed67199f 100644 --- a/app/assets/javascripts/content_editor/extensions/table_of_contents.js +++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js @@ -1,6 +1,8 @@ import { Node, InputRule } from '@tiptap/core'; -import { s__ } from '~/locale'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { __ } from '~/locale'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import TableOfContentsWrapper from '../components/wrappers/table_of_contents.vue'; export default Node.create({ name: 'tableOfContents', @@ -25,9 +27,18 @@ export default Node.create({ class: 'table-of-contents gl-border-1 gl-border-solid gl-text-center gl-border-gray-100 gl-mb-5', }, - s__('ContentEditor|Table of Contents'), + __('Table of contents'), ]; }, + addNodeView() { + return VueNodeViewRenderer(TableOfContentsWrapper); + }, + + addCommands() { + return { + insertTableOfContents: () => ({ commands }) => commands.insertContent({ type: this.name }), + }; + }, addInputRules() { const { type } = this; diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 867bf0b4d55..75d8581890f 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -1,4 +1,3 @@ -import { TextSelection } from 'prosemirror-state'; import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; /* eslint-disable no-underscore-dangle */ @@ -59,7 +58,6 @@ export class ContentEditor { async setSerializedContent(serializedContent) { const { _tiptapEditor: editor, _eventHub: eventHub } = this; const { doc, tr } = editor.state; - const selection = TextSelection.create(doc, 0, doc.content.size); try { eventHub.$emit(LOADING_CONTENT_EVENT); @@ -67,9 +65,7 @@ export class ContentEditor { if (document) { this._pristineDoc = document; - tr.setSelection(selection) - .replaceSelectionWith(document, false) - .setMeta('preventUpdate', true); + tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true); editor.view.dispatch(tr); } 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 c5cfa9a4285..7a289df94ea 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -1,6 +1,5 @@ import { Editor } from '@tiptap/vue-2'; import { isFunction } from 'lodash'; -import { lowlight } from 'lowlight/lib/core'; import eventHubFactory from '~/helpers/event_hub_factory'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import Attachment from '../extensions/attachment'; @@ -43,6 +42,7 @@ import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import PasteMarkdown from '../extensions/paste_markdown'; import Reference from '../extensions/reference'; +import ReferenceDefinition from '../extensions/reference_definition'; import Sourcemap from '../extensions/sourcemap'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; @@ -96,7 +96,7 @@ export const createContentEditor = ({ BulletList, Code, ColorChip, - CodeBlockHighlight.configure({ lowlight }), + CodeBlockHighlight, DescriptionItem, DescriptionList, Details, @@ -110,7 +110,7 @@ export const createContentEditor = ({ FootnoteDefinition, FootnoteReference, FootnotesSection, - Frontmatter.configure({ lowlight }), + Frontmatter, Gapcursor, HardBreak, Heading, @@ -127,8 +127,9 @@ export const createContentEditor = ({ MathInline, OrderedList, Paragraph, - PasteMarkdown.configure({ renderMarkdown, eventHub }), + PasteMarkdown, Reference, + ReferenceDefinition, Sourcemap, Strike, Subscript, diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js index 312ab88de4a..28a50adca6b 100644 --- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js +++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js @@ -21,7 +21,7 @@ import { Mark } from 'prosemirror-model'; import { visitParents, SKIP } from 'unist-util-visit-parents'; -import { isFunction, isString, noop } from 'lodash'; +import { isFunction, isString, noop, mapValues } from 'lodash'; const NO_ATTRIBUTES = {}; @@ -73,28 +73,48 @@ function createSourceMapAttributes(hastNode, markdown) { } /** - * Compute ProseMirror node’s attributes from a Hast node. - * By default, this function includes sourcemap position - * information in the object returned. - * - * Other attributes are retrieved by invoking a getAttrs - * function provided by the ProseMirror node factory spec. - * - * @param {*} proseMirrorNodeSpec ProseMirror node spec object - * @param {HastNode} hastNode A hast node - * @param {Array<HastNode>} hastParents All the ancestors of the hastNode - * @param {String} markdown Markdown source file’s content - * - * @returns An object that contains a ProseMirror node’s attributes + * Creates a function that resolves the attributes + * of a ProseMirror node based on a hast node. + * + * @param {Object} params Parameters + * @param {String} params.markdown Markdown source from which the AST was generated + * @param {Object} params.attributeTransformer An object that allows applying a transformation + * function to all the attributes listed in the attributes property. + * @param {Array} params.attributeTransformer.attributes A list of attributes names + * that the getAttrs function should apply the transformation + * @param {Function} params.attributeTransformer.transform A function that applies + * a transform operation on an attribute value. + * @returns A `getAttrs` function */ -function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, markdown) { - const { getAttrs: specGetAttrs } = proseMirrorNodeSpec; +const getAttrsFactory = ({ attributeTransformer, markdown }) => + /** + * Compute ProseMirror node’s attributes from a Hast node. + * By default, this function includes sourcemap position + * information in the object returned. + * + * Other attributes are retrieved by invoking a getAttrs + * function provided by the ProseMirror node factory spec. + * + * @param {Object} proseMirrorNodeSpec ProseMirror node spec object + * @param {Object} hastNode A hast node + * @param {Array} hastParents All the ancestors of the hastNode + * @param {String} markdown Markdown source file’s content + * @returns An object that contains a ProseMirror node’s attributes + */ + function getAttrs(proseMirrorNodeSpec, hastNode, hastParents) { + const { getAttrs: specGetAttrs } = proseMirrorNodeSpec; + const attributes = { + ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}), + }; + const { transform } = attributeTransformer; - return { - ...createSourceMapAttributes(hastNode, markdown), - ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}), + return { + ...createSourceMapAttributes(hastNode, markdown), + ...mapValues(attributes, (attributeValue, attributeName) => + transform(attributeName, attributeValue, hastNode), + ), + }; }; -} /** * Keeps track of the Hast -> ProseMirror conversion process. @@ -322,7 +342,13 @@ class HastToProseMirrorConverterState { * * @returns An object that contains ProseMirror node factories */ -const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdown) => { +const createProseMirrorNodeFactories = ( + schema, + proseMirrorFactorySpecs, + attributeTransformer, + markdown, +) => { + const getAttrs = getAttrsFactory({ attributeTransformer, markdown }); const factories = { root: { selector: 'root', @@ -355,20 +381,20 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdow const nodeType = schema.nodeType(proseMirrorName); state.closeUntil(parent); - state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); + state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent), factory); }; } else if (factory.type === 'inline') { const nodeType = schema.nodeType(proseMirrorName); factory.handle = (state, hastNode, parent) => { state.closeUntil(parent); - state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); + state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent), factory); // Inline nodes do not have children therefore they are immediately closed state.closeNode(); }; } else if (factory.type === 'mark') { const markType = schema.marks[proseMirrorName]; factory.handle = (state, hastNode, parent) => { - state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); + state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent), factory); }; } else if (factory.type === 'ignore') { factory.handle = noop; @@ -581,9 +607,15 @@ export const createProseMirrorDocFromMdastTree = ({ factorySpecs, wrappableTags, tree, + attributeTransformer, markdown, }) => { - const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, markdown); + const proseMirrorNodeFactories = createProseMirrorNodeFactories( + schema, + factorySpecs, + attributeTransformer, + markdown, + ); const state = new HastToProseMirrorConverterState(); visitParents(tree, (hastNode, ancestors) => { diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index c1c7af6b1af..472a0a4815b 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -33,6 +33,7 @@ import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import Reference from '../extensions/reference'; +import ReferenceDefinition from '../extensions/reference_definition'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; import Superscript from '../extensions/superscript'; @@ -148,10 +149,13 @@ const defaultSerializerConfig = { state.renderInline(node); state.ensureNewLine(); }), - [FootnoteReference.name]: preserveUnchanged((state, node) => { - state.write(`[^${node.attrs.identifier}]`); + [FootnoteReference.name]: preserveUnchanged({ + render: (state, node) => { + state.write(`[^${node.attrs.identifier}]`); + }, + inline: true, }), - [Frontmatter.name]: (state, node) => { + [Frontmatter.name]: preserveUnchanged((state, node) => { const { language } = node.attrs; const syntax = { toml: '+++', @@ -164,19 +168,41 @@ const defaultSerializerConfig = { state.ensureNewLine(); 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(renderImage), + [Image.name]: preserveUnchanged({ + render: renderImage, + inline: true, + }), [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), [OrderedList.name]: preserveUnchanged(renderOrderedList), [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), [Reference.name]: (state, node) => { state.write(node.attrs.originalText || node.attrs.text); }, + [ReferenceDefinition.name]: preserveUnchanged({ + render: (state, node, parent, index, same, sourceMarkdown) => { + const nextSibling = parent.maybeChild(index + 1); + + state.text(same ? sourceMarkdown : node.textContent, false); + + /** + * Do not insert a blank line between reference definitions + * because it isn’t necessary and a more compact text format + * is preferred. + */ + if (!nextSibling || nextSibling.type.name !== ReferenceDefinition.name) { + state.closeBlock(node); + } else { + state.ensureNewLine(); + } + }, + overwriteSourcePreservationStrategy: true, + }), [TableOfContents.name]: (state, node) => { state.write('[[_TOC_]]'); state.closeBlock(node); diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index 8e2c066e011..8a15633708f 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -1,4 +1,5 @@ import { render } from '~/lib/gfm'; +import { isValidAttribute } from '~/lib/dompurify'; import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter'; const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del']; @@ -125,6 +126,8 @@ const factorySpecs = { selector: 'img', getAttrs: (hastNode) => ({ src: hastNode.properties.src, + canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src, + isReference: hastNode.properties.isReference === 'true', title: hastNode.properties.title, alt: hastNode.properties.alt, }), @@ -154,7 +157,9 @@ const factorySpecs = { type: 'mark', selector: 'a', getAttrs: (hastNode) => ({ + canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.href, href: hastNode.properties.href, + isReference: hastNode.properties.isReference === 'true', title: hastNode.properties.title, }), }, @@ -170,6 +175,55 @@ const factorySpecs = { type: 'ignore', selector: (hastNode) => hastNode.type === 'comment', }, + + referenceDefinition: { + type: 'block', + selector: 'referencedefinition', + getAttrs: (hastNode) => ({ + title: hastNode.properties.title, + url: hastNode.properties.url, + identifier: hastNode.properties.identifier, + }), + }, + + frontmatter: { + type: 'block', + selector: 'frontmatter', + getAttrs: (hastNode) => ({ + language: hastNode.properties.language, + }), + }, +}; + +const SANITIZE_ALLOWLIST = ['level', 'identifier', 'numeric', 'language', 'url', 'isReference']; + +const sanitizeAttribute = (attributeName, attributeValue, hastNode) => { + if (!attributeValue || SANITIZE_ALLOWLIST.includes(attributeName)) { + return attributeValue; + } + + /** + * This is a workaround to validate the value of the canonicalSrc + * attribute using DOMPurify without passing the attribute name. canonicalSrc + * is not an allowed attribute in DOMPurify therefore the library will remove + * it regardless of its value. + * + * We want to preserve canonicalSrc, and we also want to make sure that its + * value is sanitized. + */ + const validateAttributeAs = attributeName === 'canonicalSrc' ? 'src' : attributeName; + + if (!isValidAttribute(hastNode.tagName, validateAttributeAs, attributeValue)) { + return null; + } + + return attributeValue; +}; + +const attributeTransformer = { + transform: (attributeName, attributeValue, hastNode) => { + return sanitizeAttribute(attributeName, attributeValue, hastNode); + }, }; export default () => { @@ -183,9 +237,20 @@ export default () => { factorySpecs, tree, wrappableTags, + attributeTransformer, markdown, }), - skipRendering: ['footnoteReference', 'footnoteDefinition', 'code'], + skipRendering: [ + 'footnoteReference', + 'footnoteDefinition', + 'code', + 'definition', + 'linkReference', + 'imageReference', + 'yaml', + 'toml', + 'json', + ], }); return { document }; diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 7d5e718b41c..41114571df7 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 } from 'lodash'; +import { uniq, isString, omit, isFunction } from 'lodash'; +import { removeLastSlashInUrlPath, removeUrlProtocol } from '../../lib/utils/url_utility'; const defaultAttrs = { td: { colspan: 1, rowspan: 1, colwidth: null }, @@ -306,12 +307,15 @@ export function renderHardBreak(state, node, parent, index) { } export function renderImage(state, node) { - const { alt, canonicalSrc, src, title } = node.attrs; + const { alt, canonicalSrc, src, title, isReference } = node.attrs; if (isString(src) || isString(canonicalSrc)) { const quotedTitle = title ? ` ${state.quote(title)}` : ''; + const sourceExpression = isReference + ? `[${canonicalSrc}]` + : `(${state.esc(canonicalSrc || src)}${quotedTitle})`; - state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); + state.write(`![${state.esc(alt || '')}]${sourceExpression}`); } } @@ -327,16 +331,28 @@ export function renderCodeBlock(state, node) { state.closeBlock(node); } -export function preserveUnchanged(render) { +const expandPreserveUnchangedConfig = (configOrRender) => + isFunction(configOrRender) + ? { render: configOrRender, overwriteSourcePreservationStrategy: false, inline: false } + : configOrRender; + +export function preserveUnchanged(configOrRender) { return (state, node, parent, index) => { + const { render, overwriteSourcePreservationStrategy, inline } = expandPreserveUnchangedConfig( + configOrRender, + ); + const { sourceMarkdown } = node.attrs; const same = state.options.changeTracker.get(node); - if (same) { + if (same && !overwriteSourcePreservationStrategy) { state.write(sourceMarkdown); - state.closeBlock(node); + + if (!inline) { + state.closeBlock(node); + } } else { - render(state, node, parent, index); + render(state, node, parent, index, same, sourceMarkdown); } }; } @@ -488,24 +504,16 @@ const linkType = (sourceMarkdown) => { return LINK_HTML; }; -const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, ''); - -const normalizeUrl = (url) => decodeURIComponent(removeUrlProtocol(url)); +const normalizeUrl = (url) => decodeURIComponent(removeLastSlashInUrlPath(removeUrlProtocol(url))); /** - * Validates that the provided URL is well-formed + * Validates that the provided URL is a valid GFM autolink * * @param {String} url - * @returns Returns true when the browser’s URL constructor - * can successfully parse the URL string + * @returns Returns true when the URL is a valid GFM autolink */ -const isValidUrl = (url) => { - try { - return new URL(url) && true; - } catch { - return false; - } -}; +const isValidAutolinkURL = (url) => + /(https?:\/\/)?([\w-])+\.{1}([a-zA-Z]{2,63})([/\w-]*)*\/?\??([^#\n\r]*)?#?([^\n\r]*)/.test(url); const findChildWithMark = (mark, parent) => { let child; @@ -542,7 +550,7 @@ const isAutoLink = (linkMark, parent) => { if ( !child || !child.isText || - !isValidUrl(href) || + !isValidAutolinkURL(href) || normalizeUrl(child.text) !== normalizeUrl(href) ) { return false; @@ -582,7 +590,11 @@ export const link = { return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : ''; } - const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs; + + if (isReference) { + return `][${state.esc(canonicalSrc || href)}]`; + } if (linkType(sourceMarkdown) === LINK_HTML) { return closeTag('a'); diff --git a/app/assets/javascripts/content_editor/services/table_of_contents_utils.js b/app/assets/javascripts/content_editor/services/table_of_contents_utils.js new file mode 100644 index 00000000000..dad917b2270 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/table_of_contents_utils.js @@ -0,0 +1,67 @@ +export function fillEmpty(headings) { + for (let i = 0; i < headings.length; i += 1) { + let j = headings[i - 1]?.level || 0; + if (headings[i].level - j > 1) { + while (j < headings[i].level) { + headings.splice(i, 0, { level: j + 1, text: '' }); + j += 1; + } + } + } + + return headings; +} + +const exitHeadingBranch = (heading, targetLevel) => { + let currentHeading = heading; + + while (currentHeading.level > targetLevel) { + currentHeading = currentHeading.parent; + } + + return currentHeading; +}; + +export function toTree(headings) { + fillEmpty(headings); + + const tree = []; + let currentHeading; + for (let i = 0; i < headings.length; i += 1) { + const heading = headings[i]; + if (heading.level === 1) { + const h = { ...heading, subHeadings: [] }; + tree.push(h); + currentHeading = h; + } else if (heading.level > currentHeading.level) { + const h = { ...heading, subHeadings: [], parent: currentHeading }; + currentHeading.subHeadings.push(h); + currentHeading = h; + } else if (heading.level <= currentHeading.level) { + currentHeading = exitHeadingBranch(currentHeading, heading.level - 1); + + const h = { ...heading, subHeadings: [], parent: currentHeading }; + (currentHeading?.subHeadings || headings).push(h); + currentHeading = h; + } + } + + return tree; +} + +export function getHeadings(editor) { + const headings = []; + + editor.state.doc.descendants((node) => { + if (node.type.name !== 'heading') return false; + + headings.push({ + level: node.attrs.level, + text: node.textContent, + }); + + return true; + }); + + return toTree(headings); +} |