Commit a7e77e10 authored by Douwe Maan's avatar Douwe Maan

Add tiptap/prosemirror nodes and marks for all Markdown and GFM features

The schema is built on top of the default schema and Node and Mark
classes provided by tiptap-extensions. prosemirror-model is used to
parse HTML/DOM into a prosemirror document, and prosemirror-markdown is
used to serialize this document to (GitLab Flavored) Markdown.
parent da251c64
/* eslint-disable class-methods-use-this */
import { Bold as BaseBold } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Bold extends BaseBold {
get toMarkdown() {
return defaultMarkdownSerializer.marks.strong;
}
}
/* eslint-disable class-methods-use-this */
import { Code as BaseCode } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Code extends BaseCode {
get toMarkdown() {
return defaultMarkdownSerializer.marks.code;
}
}
/* eslint-disable class-methods-use-this */
import { Mark } from 'tiptap';
// Transforms generated HTML back to GFM for Banzai::Filter::InlineDiffFilter
export default class InlineDiff extends Mark {
get name() {
return 'inline_diff';
}
get schema() {
return {
attrs: {
addition: {
default: true,
},
},
parseDOM: [
{ tag: 'span.idiff.addition', attrs: { addition: true } },
{ tag: 'span.idiff.deletion', attrs: { addition: false } },
],
toDOM: node => [
'span',
{ class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` },
0,
],
};
}
get toMarkdown() {
return {
mixable: true,
open(state, mark) {
return mark.attrs.addition ? '{+' : '{-';
},
close(state, mark) {
return mark.attrs.addition ? '+}' : '-}';
},
};
}
}
/* eslint-disable class-methods-use-this */
import { Mark } from 'tiptap';
import _ from 'underscore';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class InlineHTML extends Mark {
get name() {
return 'inline_html';
}
get schema() {
return {
excludes: '',
attrs: {
tag: {},
title: { default: null },
},
parseDOM: [
{
tag: 'sup, sub, kbd, q, samp, var',
getAttrs: el => ({ tag: el.nodeName.toLowerCase() }),
},
{
tag: 'abbr',
getAttrs: el => ({ tag: 'abbr', title: el.getAttribute('title') }),
},
],
toDOM: node => [node.attrs.tag, { title: node.attrs.title }, 0],
};
}
get toMarkdown() {
return {
mixable: true,
open(state, mark) {
return `<${mark.attrs.tag}${
mark.attrs.title ? ` title="${state.esc(_.escape(mark.attrs.title))}"` : ''
}>`;
},
close(state, mark) {
return `</${mark.attrs.tag}>`;
},
};
}
}
/* eslint-disable class-methods-use-this */
import { Italic as BaseItalic } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Italic extends BaseItalic {
get toMarkdown() {
return defaultMarkdownSerializer.marks.em;
}
}
/* eslint-disable class-methods-use-this */
import { Link as BaseLink } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Link extends BaseLink {
get toMarkdown() {
return {
mixable: true,
open(state, mark, parent, index) {
const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index);
return open === '<' ? '' : open;
},
close(state, mark, parent, index) {
const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index);
return close === '>' ? '' : close;
},
};
}
}
/* eslint-disable class-methods-use-this */
import { Mark } from 'tiptap';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter
export default class MathMark extends Mark {
get name() {
return 'math';
}
get schema() {
return {
parseDOM: [
// Matches HTML generated by Banzai::Filter::MathFilter
{
tag: 'code.code.math[data-math-style=inline]',
priority: 51,
},
// Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
{
tag: 'span.katex',
contentElement: 'annotation[encoding="application/x-tex"]',
},
],
toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0],
};
}
get toMarkdown() {
return {
escape: false,
open(state, mark, parent, index) {
return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`;
},
close(state, mark, parent, index) {
return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`;
},
};
}
}
/* eslint-disable class-methods-use-this */
import { Strike as BaseStrike } from 'tiptap-extensions';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Strike extends BaseStrike {
get toMarkdown() {
return {
open: '~~',
close: '~~',
mixable: true,
expelEnclosingWhitespace: true,
};
}
}
/* eslint-disable class-methods-use-this */
import { Blockquote as BaseBlockquote } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Blockquote extends BaseBlockquote {
toMarkdown(state, node) {
if (!node.childCount) return;
defaultMarkdownSerializer.nodes.blockquote(state, node);
}
}
/* eslint-disable class-methods-use-this */
import { BulletList as BaseBulletList } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class BulletList extends BaseBulletList {
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.bullet_list(state, node);
}
}
/* eslint-disable class-methods-use-this */
import { CodeBlock as BaseCodeBlock } from 'tiptap-extensions';
const PLAINTEXT_LANG = 'plaintext';
// Transforms generated HTML back to GFM for:
// - Banzai::Filter::SyntaxHighlightFilter
// - Banzai::Filter::MathFilter
// - Banzai::Filter::MermaidFilter
export default class CodeBlock extends BaseCodeBlock {
get schema() {
return {
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
attrs: {
lang: { default: PLAINTEXT_LANG },
},
parseDOM: [
// Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter or Banzai::Filter::MermaidFilter
{
tag: 'pre.code.highlight',
preserveWhitespace: 'full',
getAttrs: el => {
const lang = el.getAttribute('lang');
if (!lang || lang === '') return {};
return { lang };
},
},
// Matches HTML generated by Banzai::Filter::MathFilter,
// after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
{
tag: 'span.katex-display',
preserveWhitespace: 'full',
contentElement: 'annotation[encoding="application/x-tex"]',
attrs: { lang: 'math' },
},
// Matches HTML generated by Banzai::Filter::MathFilter,
// after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js
{
tag: 'svg.mermaid',
preserveWhitespace: 'full',
contentElement: 'text.source',
attrs: { lang: 'mermaid' },
},
],
toDOM: node => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]],
};
}
toMarkdown(state, node) {
if (!node.childCount) return;
const {
textContent: text,
attrs: { lang },
} = node;
// Prefixes lines with 4 spaces if the code contains a line that starts with triple backticks
if (lang === PLAINTEXT_LANG && text.match(/^```/gm)) {
state.wrapBlock(' ', null, node, () => state.text(text, false));
return;
}
state.write('```');
if (lang !== PLAINTEXT_LANG) state.write(lang);
state.ensureNewLine();
state.text(text, false);
state.ensureNewLine();
state.write('```');
state.closeBlock(node);
}
}
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class DescriptionDetails extends Node {
get name() {
return 'description_details';
}
get schema() {
return {
content: 'text*',
marks: '',
defining: true,
parseDOM: [{ tag: 'dd' }],
toDOM: () => ['dd', 0],
};
}
toMarkdown(state, node) {
state.flushClose(1);
state.write('<dd>');
state.text(node.textContent, false);
state.write('</dd>');
state.closeBlock(node);
}
}
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class DescriptionList extends Node {
get name() {
return 'description_list';
}
get schema() {
return {
content: '(description_term+ description_details+)+',
group: 'block',
parseDOM: [{ tag: 'dl' }],
toDOM: () => ['dl', 0],
};
}
toMarkdown(state, node) {
state.write('<dl>\n');
state.wrapBlock(' ', null, node, () => state.renderContent(node));
state.flushClose(1);
state.ensureNewLine();
state.write('</dl>');
state.closeBlock(node);
}
}
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class DescriptionTerm extends Node {
get name() {
return 'description_term';
}
get schema() {
return {
content: 'text*',
marks: '',
defining: true,
parseDOM: [{ tag: 'dt' }],
toDOM: () => ['dt', 0],
};
}
toMarkdown(state, node) {
state.flushClose(state.closed && state.closed.type === node.type ? 1 : 2);
state.write('<dt>');
state.text(node.textContent, false);
state.write('</dt>');
state.closeBlock(node);
}
}
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Details extends Node {
get name() {
return 'details';
}
get schema() {
return {
content: 'summary block*',
group: 'block',
parseDOM: [{ tag: 'details' }],
toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0],
};
}
toMarkdown(state, node) {
state.write('<details>\n');
state.renderContent(node);
state.flushClose(1);
state.ensureNewLine();
state.write('</details>');
state.closeBlock(node);
}
}
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
export default class Doc extends Node {
get name() {
return 'doc';
}
get schema() {
return {
content: 'block+',
};
}
}
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
// Transforms generated HTML back to GFM for Banzai::Filter::EmojiFilter
export default class Emoji extends Node {
get name() {
return 'emoji';
}
get schema() {
return {
inline: true,
group: 'inline',
attrs: {
name: {},
title: {},
moji: {},
},
parseDOM: [
{
tag: 'gl-emoji',
getAttrs: el => ({
name: el.dataset.name,
title: el.getAttribute('title'),
moji: el.textContent,
}),
},
],
toDOM: node => [
'gl-emoji',
{ 'data-name': node.attrs.name, title: node.attrs.title },
node.attrs.moji,
],
};
}
toMarkdown(state, node) {
state.write(`:${node.attrs.name}:`);
}
}
/* eslint-disable class-methods-use-this */
import { HardBreak as BaseHardBreak } from 'tiptap-extensions';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class HardBreak extends BaseHardBreak {
toMarkdown(state) {
if (!state.atBlank()) state.write(' \n');
}
}
/* eslint-disable class-methods-use-this */
import { Heading as BaseHeading } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Heading extends BaseHeading {
toMarkdown(state, node) {
if (!node.childCount) return;
defaultMarkdownSerializer.nodes.heading(state, node);
}
}
/* eslint-disable class-methods-use-this */
import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class HorizontalRule extends BaseHorizontalRule {
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.horizontal_rule(state, node);
}
}
/* eslint-disable class-methods-use-this */
import { Image as BaseImage } from 'tiptap-extensions';
import { placeholderImage } from '~/lazy_loader';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
export default class Image extends BaseImage {
get schema() {
return {
attrs: {
src: {},
alt: {
default: null,
},
title: {
default: null,
},
},
group: 'inline',
inline: true,
draggable: true,
parseDOM: [
// Matches HTML generated by Banzai::Filter::ImageLinkFilter
{
tag: 'a.no-attachment-icon',
priority: 51,
skip: true,
},
// Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter
{
tag: 'img[src]',
getAttrs: el => {
const imageSrc = el.src;
const imageUrl =
imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || '';
return {
src: imageUrl,
title: el.getAttribute('title'),
alt: el.getAttribute('alt'),
};
},
},
],
toDOM: node => ['img', node.attrs],
};
}
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.image(state, node);
}
}
/* eslint-disable class-methods-use-this */
import { ListItem as BaseListItem } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class ListItem extends BaseListItem {
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.list_item(state, node);
}
}
/* eslint-disable class-methods-use-this */
import { OrderedList as BaseOrderedList } from 'tiptap-extensions';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class OrderedList extends BaseOrderedList {
toMarkdown(state, node) {
state.renderList(node, ' ', () => '1. ');
}
}
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
export default class OrderedTaskList extends Node {
get name() {
return 'ordered_task_list';
}
get schema() {
return {
group: 'block',
content: '(task_list_item|list_item)+',
parseDOM: [
{
priority: 51,
tag: 'ol.task-list',
},
],
toDOM: () => ['ol', { class: 'task-list' }, 0],
};
}
toMarkdown(state, node) {
state.renderList(node, ' ', () => '1. ');
}
}
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Paragraph extends Node {
get name() {
return 'paragraph';
}
get schema() {
return {
content: 'inline*',
group: 'block',
parseDOM: [{ tag: 'p' }],
toDOM: () => ['p', 0],
};
}
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.paragraph(state, node);
}
}
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
// Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses