summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/content_editor/services/serialization_helpers.js
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/content_editor/services/serialization_helpers.js')
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js222
1 files changed, 176 insertions, 46 deletions
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 089d30edec7..88f5192af77 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -1,4 +1,4 @@
-import { uniq, isString } from 'lodash';
+import { uniq, isString, omit } from 'lodash';
const defaultAttrs = {
td: { colspan: 1, rowspan: 1, colwidth: null },
@@ -12,22 +12,6 @@ const ignoreAttrs = {
const tableMap = new WeakMap();
-// Source taken from
-// prosemirror-markdown/src/to_markdown.js
-export function isPlainURL(link, parent, index, side) {
- if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false;
- const content = parent.child(index + (side < 0 ? -1 : 0));
- if (
- !content.isText ||
- content.text !== link.attrs.href ||
- content.marks[content.marks.length - 1] !== link
- )
- return false;
- if (index === (side < 0 ? 1 : parent.childCount - 1)) return true;
- const next = parent.child(index + (side < 0 ? -2 : 1));
- return !link.isInSet(next.marks);
-}
-
function containsOnlyText(node) {
if (node.childCount === 1) {
const child = node.child(0);
@@ -219,7 +203,7 @@ function renderTableRowAsHTML(state, node) {
node.forEach((cell, _, i) => {
const tag = cell.type.name === 'tableHeader' ? 'th' : 'td';
- renderTagOpen(state, tag, cell.attrs);
+ renderTagOpen(state, tag, omit(cell.attrs, 'sourceMapKey', 'sourceMarkdown'));
if (!containsParagraphWithOnlyText(cell)) {
state.closeBlock(node);
@@ -272,19 +256,6 @@ export function renderHTMLNode(tagName, forceRenderContentInline = false) {
};
}
-export function renderOrderedList(state, node) {
- const { parens } = node.attrs;
- const start = node.attrs.start || 1;
- const maxW = String(start + node.childCount - 1).length;
- const space = state.repeat(' ', maxW + 2);
- const delimiter = parens ? ')' : '.';
-
- state.renderList(node, space, (i) => {
- const nStr = String(start + i);
- return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `;
- });
-}
-
export function renderTableCell(state, node) {
if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) {
state.renderInline(node.child(0));
@@ -364,7 +335,72 @@ export function preserveUnchanged(render) {
};
}
-const generateBoldTags = (open = true) => {
+/**
+ * We extracted this function from
+ * https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.ts#L350.
+ *
+ * We need to overwrite this function because we don’t want to wrap the list item nodes
+ * with the bullet delimiter when the list item node hasn’t changed
+ */
+const renderList = (state, node, delim, firstDelim) => {
+ if (state.closed && state.closed.type === node.type) state.flushClose(3);
+ else if (state.inTightList) state.flushClose(1);
+
+ const isTight =
+ typeof node.attrs.tight !== 'undefined' ? node.attrs.tight : state.options.tightLists;
+ const prevTight = state.inTightList;
+
+ state.inTightList = isTight;
+
+ node.forEach((child, _, i) => {
+ const same = state.options.changeTracker.get(child);
+
+ if (i && isTight) {
+ state.flushClose(1);
+ }
+
+ if (same) {
+ // Avoid wrapping list item when node hasn’t changed
+ state.render(child, node, i);
+ } else {
+ state.wrapBlock(delim, firstDelim(i), node, () => state.render(child, node, i));
+ }
+ });
+
+ state.inTightList = prevTight;
+};
+
+export const renderBulletList = (state, node) => {
+ const { sourceMarkdown, bullet: bulletAttr } = node.attrs;
+ const bullet = /^(\*|\+|-)\s/.exec(sourceMarkdown)?.[1] || bulletAttr || '*';
+
+ renderList(state, node, ' ', () => `${bullet} `);
+};
+
+export function renderOrderedList(state, node) {
+ const { sourceMarkdown } = node.attrs;
+ let start;
+ let delimiter;
+
+ if (sourceMarkdown) {
+ const match = /^(\d+)(\)|\.)/.exec(sourceMarkdown);
+ start = parseInt(match[1], 10) || 1;
+ [, , delimiter] = match;
+ } else {
+ start = node.attrs.start || 1;
+ delimiter = node.attrs.parens ? ')' : '.';
+ }
+
+ const maxW = String(start + node.childCount - 1).length;
+ const space = state.repeat(' ', maxW + 2);
+
+ renderList(state, node, space, (i) => {
+ const nStr = String(start + i);
+ return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `;
+ });
+}
+
+const generateBoldTags = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1];
@@ -375,7 +411,7 @@ const generateBoldTags = (open = true) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
case '<strong':
case '<b':
- return (open ? openTag : closeTag)(type.substring(1));
+ return wrapTagName(type.substring(1));
default:
return '**';
}
@@ -384,12 +420,12 @@ const generateBoldTags = (open = true) => {
export const bold = {
open: generateBoldTags(),
- close: generateBoldTags(false),
+ close: generateBoldTags(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
-const generateItalicTag = (open = true) => {
+const generateItalicTag = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(\*|_|<em|<i).*/.exec(mark.attrs.sourceMarkdown)?.[1];
@@ -400,7 +436,7 @@ const generateItalicTag = (open = true) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
case '<em':
case '<i':
- return (open ? openTag : closeTag)(type.substring(1));
+ return wrapTagName(type.substring(1));
default:
return '_';
}
@@ -409,17 +445,17 @@ const generateItalicTag = (open = true) => {
export const italic = {
open: generateItalicTag(),
- close: generateItalicTag(false),
+ close: generateItalicTag(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
-const generateCodeTag = (open = true) => {
+const generateCodeTag = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(`|<code).*/.exec(mark.attrs.sourceMarkdown)?.[1];
if (type === '<code') {
- return (open ? openTag : closeTag)(type.substring(1));
+ return wrapTagName(type.substring(1));
}
return '`';
@@ -428,7 +464,7 @@ const generateCodeTag = (open = true) => {
export const code = {
open: generateCodeTag(),
- close: generateCodeTag(false),
+ close: generateCodeTag(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
@@ -446,10 +482,79 @@ const linkType = (sourceMarkdown) => {
return LINK_HTML;
};
+const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, '');
+
+const normalizeUrl = (url) => decodeURIComponent(removeUrlProtocol(url));
+
+/**
+ * Validates that the provided URL is well-formed
+ *
+ * @param {String} url
+ * @returns Returns true when the browser’s URL constructor
+ * can successfully parse the URL string
+ */
+const isValidUrl = (url) => {
+ try {
+ return new URL(url) && true;
+ } catch {
+ return false;
+ }
+};
+
+const findChildWithMark = (mark, parent) => {
+ let child;
+ let offset;
+ let index;
+
+ parent.forEach((_child, _offset, _index) => {
+ if (mark.isInSet(_child.marks)) {
+ child = _child;
+ offset = _offset;
+ index = _index;
+ }
+ });
+
+ return child ? { child, offset, index } : null;
+};
+
+/**
+ * This function detects whether a link should be serialized
+ * as an autolink.
+ *
+ * See https://github.github.com/gfm/#autolinks-extension-
+ * to understand the parsing rules of autolinks.
+ * */
+const isAutoLink = (linkMark, parent) => {
+ const { title, href } = linkMark.attrs;
+
+ if (title || !/^\w+:/.test(href)) {
+ return false;
+ }
+
+ const { child } = findChildWithMark(linkMark, parent);
+
+ if (
+ !child ||
+ !child.isText ||
+ !isValidUrl(href) ||
+ normalizeUrl(child.text) !== normalizeUrl(href)
+ ) {
+ return false;
+ }
+
+ return true;
+};
+
+/**
+ * Returns true if the user used brackets to the define
+ * the autolink in the original markdown source
+ */
+const isBracketAutoLink = (sourceMarkdown) => /^<.+?>$/.test(sourceMarkdown);
+
export const link = {
- open(state, mark, parent, index) {
- if (isPlainURL(mark, parent, index, 1)) {
- return '<';
+ open(state, mark, parent) {
+ if (isAutoLink(mark, parent)) {
+ return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '<' : '';
}
const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs;
@@ -466,9 +571,9 @@ export const link = {
return openTag('a', attrs);
},
- close(state, mark, parent, index) {
- if (isPlainURL(mark, parent, index, -1)) {
- return '>';
+ close(state, mark, parent) {
+ if (isAutoLink(mark, parent)) {
+ return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : '';
}
const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs;
@@ -480,3 +585,28 @@ export const link = {
return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`;
},
};
+
+const generateStrikeTag = (wrapTagName = openTag) => {
+ return (_, mark) => {
+ const type = /^(~~|<del|<strike|<s).*/.exec(mark.attrs.sourceMarkdown)?.[1];
+
+ switch (type) {
+ case '~~':
+ return type;
+ /* eslint-disable @gitlab/require-i18n-strings */
+ case '<del':
+ case '<strike':
+ case '<s':
+ return wrapTagName(type.substring(1));
+ default:
+ return '~~';
+ }
+ };
+};
+
+export const strike = {
+ open: generateStrikeTag(),
+ close: generateStrikeTag(closeTag),
+ mixable: true,
+ expelEnclosingWhitespace: true,
+};