debian-mirror-gitlab/app/assets/javascripts/content_editor/services/markdown_serializer.js

291 lines
10 KiB
JavaScript

import {
MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer,
} from 'prosemirror-markdown/src/to_markdown';
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
import Audio from '../extensions/audio';
import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list';
import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import Division from '../extensions/division';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
import FootnotesSection from '../extensions/footnotes_section';
import FootnoteDefinition from '../extensions/footnote_definition';
import FootnoteReference from '../extensions/footnote_reference';
import Frontmatter from '../extensions/frontmatter';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
import HTMLMarks from '../extensions/html_marks';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import Reference from '../extensions/reference';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
import Superscript from '../extensions/superscript';
import Table from '../extensions/table';
import TableCell from '../extensions/table_cell';
import TableHeader from '../extensions/table_header';
import TableOfContents from '../extensions/table_of_contents';
import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
import Video from '../extensions/video';
import WordBreak from '../extensions/word_break';
import {
isPlainURL,
renderHardBreak,
renderTable,
renderTableCell,
renderTableRow,
openTag,
closeTag,
renderOrderedList,
renderImage,
renderPlayable,
renderHTMLNode,
renderContent,
} from './serialization_helpers';
const defaultSerializerConfig = {
marks: {
[Bold.name]: defaultMarkdownSerializer.marks.strong,
[Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
[Code.name]: defaultMarkdownSerializer.marks.code,
[Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true },
[Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true },
[InlineDiff.name]: {
mixable: true,
open(state, mark) {
return mark.attrs.type === 'addition' ? '{+' : '{-';
},
close(state, mark) {
return mark.attrs.type === 'addition' ? '+}' : '-}';
},
},
[Link.name]: {
open(state, mark, parent, index) {
return isPlainURL(mark, parent, index, 1) ? '<' : '[';
},
close(state, mark, parent, index) {
const href = mark.attrs.canonicalSrc || mark.attrs.href;
return isPlainURL(mark, parent, index, -1)
? '>'
: `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`;
},
},
[MathInline.name]: {
open: (...args) => `$${defaultMarkdownSerializer.marks.code.open(...args)}`,
close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`,
escape: false,
},
[Strike.name]: {
open: '~~',
close: '~~',
mixable: true,
expelEnclosingWhitespace: true,
},
...HTMLMarks.reduce(
(acc, { name }) => ({
...acc,
[name]: {
mixable: true,
open(state, node) {
return openTag(name, node.attrs);
},
close: closeTag(name),
},
}),
{},
),
},
nodes: {
[Audio.name]: renderPlayable,
[Blockquote.name]: (state, node) => {
if (node.attrs.multiline) {
state.write('>>>');
state.ensureNewLine();
state.renderContent(node);
state.ensureNewLine();
state.write('>>>');
state.closeBlock(node);
} else {
state.wrapBlock('> ', null, node, () => state.renderContent(node));
}
},
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
[CodeBlockHighlight.name]: (state, node) => {
state.write(`\`\`\`${node.attrs.language || ''}\n`);
state.text(node.textContent, false);
state.ensureNewLine();
state.write('```');
state.closeBlock(node);
},
[Division.name]: (state, node) => {
if (node.attrs.className?.includes('js-markdown-code')) {
state.renderInline(node);
} else {
const newNode = node;
delete newNode.attrs.className;
renderHTMLNode('div')(state, newNode);
}
},
[DescriptionList.name]: renderHTMLNode('dl', true),
[DescriptionItem.name]: (state, node, parent, index) => {
if (index === 1) state.ensureNewLine();
renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node);
if (index === parent.childCount - 1) state.ensureNewLine();
},
[Details.name]: renderHTMLNode('details', true),
[DetailsContent.name]: (state, node, parent, index) => {
if (!index) renderHTMLNode('summary')(state, node);
else {
if (index === 1) state.ensureNewLine();
renderContent(state, node);
if (index === parent.childCount - 1) state.ensureNewLine();
}
},
[Emoji.name]: (state, node) => {
const { name } = node.attrs;
state.write(`:${name}:`);
},
[FootnoteDefinition.name]: (state, node) => {
state.renderInline(node);
},
[FootnoteReference.name]: (state, node) => {
state.write(`[^${node.attrs.footnoteNumber}]`);
},
[FootnotesSection.name]: (state, node) => {
state.renderList(node, '', (index) => `[^${index + 1}]: `);
},
[Frontmatter.name]: (state, node) => {
const { language } = node.attrs;
const syntax = {
toml: '+++',
json: ';;;',
yaml: '---',
}[language];
state.write(`${syntax}\n`);
state.text(node.textContent, false);
state.ensureNewLine();
state.write(syntax);
state.closeBlock(node);
},
[Figure.name]: renderHTMLNode('figure'),
[FigureCaption.name]: renderHTMLNode('figcaption'),
[HardBreak.name]: renderHardBreak,
[Heading.name]: defaultMarkdownSerializer.nodes.heading,
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
[Image.name]: renderImage,
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
[OrderedList.name]: renderOrderedList,
[Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph,
[Reference.name]: (state, node) => {
state.write(node.attrs.originalText || node.attrs.text);
},
[TableOfContents.name]: (state, node) => {
state.write('[[_TOC_]]');
state.closeBlock(node);
},
[Table.name]: renderTable,
[TableCell.name]: renderTableCell,
[TableHeader.name]: renderTableCell,
[TableRow.name]: renderTableRow,
[TaskItem.name]: (state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
state.renderContent(node);
},
[TaskList.name]: (state, node) => {
if (node.attrs.numeric) renderOrderedList(state, node);
else defaultMarkdownSerializer.nodes.bullet_list(state, node);
},
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Video.name]: renderPlayable,
[WordBreak.name]: (state) => state.write('<wbr>'),
},
};
/**
* A markdown serializer converts arbitrary Markdown content
* into a ProseMirror document and viceversa. To convert Markdown
* into a ProseMirror document, the Markdown should be rendered.
*
* The client should provide a render function to allow flexibility
* on the desired rendering approach.
*
* @param {Function} params.render Render function
* that parses the Markdown and converts it into HTML.
* @returns a markdown serializer
*/
export default ({ render = () => null, serializerConfig = {} } = {}) => ({
/**
* Converts a Markdown string into a ProseMirror JSONDocument based
* on a ProseMirror schema.
* @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
* the types of content supported in the document
* @param {String} params.content An arbitrary markdown string
* @returns A ProseMirror JSONDocument
*/
deserialize: async ({ schema, content }) => {
const html = await render(content);
if (!html) return null;
const parser = new DOMParser();
const { body } = parser.parseFromString(html, 'text/html');
// append original source as a comment that nodes can access
body.append(document.createComment(content));
const state = ProseMirrorDOMParser.fromSchema(schema).parse(body);
return state.toJSON();
},
/**
* Converts a ProseMirror JSONDocument based
* on a ProseMirror schema into Markdown
* @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
* the types of content supported in the document
* @param {String} params.content A ProseMirror JSONDocument
* @returns A Markdown string
*/
serialize: ({ schema, content }) => {
const proseMirrorDocument = schema.nodeFromJSON(content);
const serializer = new ProseMirrorMarkdownSerializer(
{
...defaultSerializerConfig.nodes,
...serializerConfig.nodes,
},
{
...defaultSerializerConfig.marks,
...serializerConfig.marks,
},
);
return serializer.serialize(proseMirrorDocument, {
tightLists: true,
});
},
});