summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/content_editor
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue24
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue9
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue11
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue55
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue25
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/diagram.js7
-rw-r--r--app/assets/javascripts/content_editor/extensions/frontmatter.js7
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference_definition.js29
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_of_contents.js15
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js6
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js9
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js82
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js36
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js67
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js56
-rw-r--r--app/assets/javascripts/content_editor/services/table_of_contents_utils.js67
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);
+}