gitlab-ce/app/assets/javascripts/content_editor/services/serialization_helpers.js

632 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { uniq, isString, omit, isFunction } from 'lodash';
import { removeLastSlashInUrlPath, removeUrlProtocol } from '../../lib/utils/url_utility';
const defaultAttrs = {
td: { colspan: 1, rowspan: 1, colwidth: null },
th: { colspan: 1, rowspan: 1, colwidth: null },
};
const defaultIgnoreAttrs = ['sourceMarkdown', 'sourceMapKey'];
const ignoreAttrs = {
dd: ['isTerm'],
dt: ['isTerm'],
};
const tableMap = new WeakMap();
function containsOnlyText(node) {
if (node.childCount === 1) {
const child = node.child(0);
return child.isText && child.marks.length === 0;
}
return false;
}
function containsParagraphWithOnlyText(cell) {
if (cell.childCount === 1) {
const child = cell.child(0);
if (child.type.name === 'paragraph') {
return containsOnlyText(child);
}
}
return false;
}
function getRowsAndCells(table) {
const cells = [];
const rows = [];
table.descendants((n) => {
if (n.type.name === 'tableCell' || n.type.name === 'tableHeader') {
cells.push(n);
return false;
}
if (n.type.name === 'tableRow') {
rows.push(n);
}
return true;
});
return { rows, cells };
}
function getChildren(node) {
const children = [];
for (let i = 0; i < node.childCount; i += 1) {
children.push(node.child(i));
}
return children;
}
export function shouldRenderHTMLTable(table) {
const { rows, cells } = getRowsAndCells(table);
const cellChildCount = Math.max(...cells.map((cell) => cell.childCount));
const maxColspan = Math.max(...cells.map((cell) => cell.attrs.colspan));
const maxRowspan = Math.max(...cells.map((cell) => cell.attrs.rowspan));
const rowChildren = rows.map((row) => uniq(getChildren(row).map((cell) => cell.type.name)));
const cellTypeInFirstRow = rowChildren[0];
const cellTypesInOtherRows = uniq(rowChildren.slice(1).map(([type]) => type));
// if the first row has headers, and there are no headers anywhere else, render markdown table
if (
!(
cellTypeInFirstRow.length === 1 &&
cellTypeInFirstRow[0] === 'tableHeader' &&
cellTypesInOtherRows.length === 1 &&
cellTypesInOtherRows[0] === 'tableCell'
)
) {
return true;
}
if (cellChildCount === 1 && maxColspan === 1 && maxRowspan === 1) {
// if all rows contain only one paragraph each and no rowspan/colspan, render markdown table
const children = uniq(cells.map((cell) => cell.child(0).type.name));
if (children.length === 1 && children[0] === 'paragraph') {
return false;
}
}
return true;
}
function htmlEncode(str = '') {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&#39;')
.replace(/"/g, '&#34;');
}
const shouldIgnoreAttr = (tagName, attrKey, attrValue) =>
ignoreAttrs[tagName]?.includes(attrKey) ||
defaultIgnoreAttrs.includes(attrKey) ||
defaultAttrs[tagName]?.[attrKey] === attrValue;
export function openTag(tagName, attrs) {
let str = `<${tagName}`;
str += Object.entries(attrs || {})
.map(([key, value]) => {
if (shouldIgnoreAttr(tagName, key, value)) return '';
return ` ${key}="${htmlEncode(value?.toString())}"`;
})
.join('');
return `${str}>`;
}
export function closeTag(tagName) {
return `</${tagName}>`;
}
function isInBlockTable(node) {
return tableMap.get(node);
}
function isInTable(node) {
return tableMap.has(node);
}
function setIsInBlockTable(table, value) {
tableMap.set(table, value);
const { rows, cells } = getRowsAndCells(table);
rows.forEach((row) => tableMap.set(row, value));
cells.forEach((cell) => {
tableMap.set(cell, value);
if (cell.childCount && cell.child(0).type.name === 'paragraph')
tableMap.set(cell.child(0), value);
});
}
function unsetIsInBlockTable(table) {
tableMap.delete(table);
const { rows, cells } = getRowsAndCells(table);
rows.forEach((row) => tableMap.delete(row));
cells.forEach((cell) => {
tableMap.delete(cell);
if (cell.childCount) tableMap.delete(cell.child(0));
});
}
function renderTagOpen(state, tagName, attrs) {
state.ensureNewLine();
state.write(openTag(tagName, attrs));
}
function renderTagClose(state, tagName, insertNewline = true) {
state.write(closeTag(tagName));
if (insertNewline) state.ensureNewLine();
}
function renderTableHeaderRowAsMarkdown(state, node, cellWidths) {
state.flushClose(1);
state.write('|');
node.forEach((cell, _, i) => {
if (i) state.write('|');
state.write(cell.attrs.align === 'center' ? ':' : '-');
state.write(state.repeat('-', cellWidths[i]));
state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
});
state.write('|');
state.closeBlock(node);
}
function renderTableRowAsMarkdown(state, node, isHeaderRow = false) {
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);
if (isHeaderRow) renderTableHeaderRowAsMarkdown(state, node, cellWidths);
}
function renderTableRowAsHTML(state, node) {
renderTagOpen(state, 'tr');
node.forEach((cell, _, i) => {
const tag = cell.type.name === 'tableHeader' ? 'th' : 'td';
renderTagOpen(state, tag, omit(cell.attrs, 'sourceMapKey', 'sourceMarkdown'));
if (!containsParagraphWithOnlyText(cell)) {
state.closeBlock(node);
state.flushClose();
}
state.render(cell, node, i);
state.flushClose(1);
renderTagClose(state, tag);
});
renderTagClose(state, 'tr');
}
export function renderContent(state, node, forceRenderInline) {
if (node.type.inlineContent) {
if (containsOnlyText(node)) {
state.renderInline(node);
} else {
state.closeBlock(node);
state.flushClose();
state.renderInline(node);
state.closeBlock(node);
state.flushClose();
}
} else {
const renderInline = forceRenderInline || containsParagraphWithOnlyText(node);
if (!renderInline) {
state.closeBlock(node);
state.flushClose();
state.renderContent(node);
state.ensureNewLine();
} else {
state.renderInline(forceRenderInline ? node : node.child(0));
}
}
}
export function renderHTMLNode(tagName, forceRenderContentInline = false) {
return (state, node) => {
renderTagOpen(state, tagName, node.attrs);
renderContent(state, node, forceRenderContentInline);
renderTagClose(state, tagName, false);
if (forceRenderContentInline) {
state.closeBlock(node);
state.flushClose();
}
};
}
export function renderTableCell(state, node) {
if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) {
state.renderInline(node.child(0));
} else {
state.renderContent(node);
}
}
export function renderTableRow(state, node) {
if (isInBlockTable(node)) {
renderTableRowAsHTML(state, node);
} else {
renderTableRowAsMarkdown(state, node, node.child(0).type.name === 'tableHeader');
}
}
export function renderTable(state, node) {
state.flushClose();
setIsInBlockTable(node, shouldRenderHTMLTable(node));
if (isInBlockTable(node)) renderTagOpen(state, 'table');
state.renderContent(node);
if (isInBlockTable(node)) renderTagClose(state, 'table');
// ensure at least one blank line after any table
state.closeBlock(node);
state.flushClose();
unsetIsInBlockTable(node);
}
export function renderHardBreak(state, node, parent, index) {
const br = isInTable(parent) ? '<br>' : '\\\n';
for (let i = index + 1; i < parent.childCount; i += 1) {
if (parent.child(i).type !== node.type) {
state.write(br);
return;
}
}
}
export function renderImage(state, node) {
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 || '')}]${sourceExpression}`);
}
}
export function renderPlayable(state, node) {
renderImage(state, node);
}
export function renderCodeBlock(state, node) {
state.write(`\`\`\`${node.attrs.language || ''}\n`);
state.text(node.textContent, false);
state.ensureNewLine();
state.write('```');
state.closeBlock(node);
}
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 && !overwriteSourcePreservationStrategy) {
state.write(sourceMarkdown);
if (!inline) {
state.closeBlock(node);
}
} else {
render(state, node, parent, index, same, sourceMarkdown);
}
};
}
/**
* 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 dont want to wrap the list item nodes
* with the bullet delimiter when the list item node hasnt 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 hasnt 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];
switch (type) {
case '**':
case '__':
return type;
// eslint-disable-next-line @gitlab/require-i18n-strings
case '<strong':
case '<b':
return wrapTagName(type.substring(1));
default:
return '**';
}
};
};
export const bold = {
open: generateBoldTags(),
close: generateBoldTags(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
const generateItalicTag = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(\*|_|<em|<i).*/.exec(mark.attrs.sourceMarkdown)?.[1];
switch (type) {
case '*':
case '_':
return type;
// eslint-disable-next-line @gitlab/require-i18n-strings
case '<em':
case '<i':
return wrapTagName(type.substring(1));
default:
return '_';
}
};
};
export const italic = {
open: generateItalicTag(),
close: generateItalicTag(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
const generateCodeTag = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(`|<code).*/.exec(mark.attrs.sourceMarkdown)?.[1];
if (type === '<code') {
return wrapTagName(type.substring(1));
}
return '`';
};
};
export const code = {
open: generateCodeTag(),
close: generateCodeTag(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
const LINK_HTML = 'linkHtml';
const LINK_MARKDOWN = 'linkMarkdown';
const linkType = (sourceMarkdown) => {
const expression = /^(\[|<a).*/.exec(sourceMarkdown)?.[1];
if (!expression || expression === '[') {
return LINK_MARKDOWN;
}
return LINK_HTML;
};
const normalizeUrl = (url) => decodeURIComponent(removeLastSlashInUrlPath(removeUrlProtocol(url)));
/**
* Validates that the provided URL is a valid GFM autolink
*
* @param {String} url
* @returns Returns true when the URL is a valid GFM autolink
*/
const isValidAutolinkURL = (url) =>
/(https?:\/\/)?([\w-])+\.{1}([a-zA-Z]{2,63})([/\w-]*)*\/?\??([^#\n\r]*)?#?([^\n\r]*)/.test(url);
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 ||
!isValidAutolinkURL(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) {
if (isAutoLink(mark, parent)) {
return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '<' : '';
}
const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs;
if (linkType(sourceMarkdown) === LINK_MARKDOWN) {
return '[';
}
const attrs = { href: state.esc(href || canonicalSrc) };
if (title) {
attrs.title = title;
}
return openTag('a', attrs);
},
close(state, mark, parent) {
if (isAutoLink(mark, parent)) {
return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : '';
}
const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs;
if (isReference) {
return `][${state.esc(canonicalSrc || href)}]`;
}
if (linkType(sourceMarkdown) === LINK_HTML) {
return closeTag('a');
}
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,
};