Files

345 lines
18 KiB
JavaScript

// Row 154 (#1648, Q168): the Markdown model of the Console's visual editor.
//
// A document is split into its top-level blocks (markdown-it token maps). Each block that has a
// structure the editor knows becomes a ProseMirror node; anything else (a table, an HTML block, front
// matter, indented code, a paragraph with ~~strikethrough~~, link reference definitions) becomes a
// `raw_block`, a node that holds its source lines and writes them back as they are.
//
// Every top-level node remembers `raw` (its source lines), `lead` (the blank lines before it) and `sig`
// (what the serialiser wrote for it when it was read). A save writes `lead + raw` for a node whose
// serialisation still equals `sig`, and the serialiser's output for one that changed. So a file opened
// and saved with no edit comes back byte for byte, and an edit rewrites only the blocks it touched.
//
// Rendering rules are row 148's: nothing here makes markup from text. A link is a mark only when
// `window.MosaicMarkdown.linkTarget` (markdown-preview.js, the one gate) accepts its target; an image is
// a node drawn as text and never loaded; raw HTML in a file is text.
import { Schema } from 'prosemirror-model';
import { MarkdownParser, MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown';
import MarkdownIt from 'markdown-it';
// The one gate for a link target. Without markdown-preview.js nothing passes.
export const linkTarget = raw => {
const gate = globalThis.MosaicMarkdown?.linkTarget;
return typeof gate === 'function' ? gate(raw) : null;
};
// C2-11a: an inert single-line atom keeps its exact source, including labels
// and heading fragments. HTML/entity-like targets stay ordinary text so the
// existing escaped-text serialiser still protects rewritten blocks.
const wikilinkSource = /^!?\[\[[^\[\]\r\n<>&`]+\]\]$/;
const wikilinkAt = /!?\[\[[^\[\]\r\n<>&`]+\]\]/y;
const isWikilink = value => typeof value === 'string' && wikilinkSource.test(value);
const kept = { raw: { default: null }, sig: { default: null }, lead: { default: '' } };
const block = attrs => ({ ...kept, ...attrs });
export const schema = new Schema({
nodes: {
doc: { content: 'block+', attrs: { tail: { default: '' } } },
paragraph: { content: 'inline*', group: 'block', attrs: block({}), parseDOM: [{ tag: 'p' }], toDOM() { return ['p', 0]; } },
blockquote: { content: 'block+', group: 'block', attrs: block({}), parseDOM: [{ tag: 'blockquote' }], toDOM() { return ['blockquote', 0]; } },
horizontal_rule: { group: 'block', attrs: block({}), parseDOM: [{ tag: 'hr' }], toDOM() { return ['div', ['hr']]; } },
heading: {
attrs: block({ level: { default: 1 } }),
content: '(text | image | wikilink)*', group: 'block', defining: true,
parseDOM: [1, 2, 3, 4, 5, 6].map(level => ({ tag: 'h' + level, attrs: { level } })),
toDOM(node) { return ['h' + +node.attrs.level, 0]; },
},
code_block: {
content: 'text*', group: 'block', code: true, defining: true, marks: '',
attrs: block({ params: { default: '' } }),
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full', getAttrs: dom => ({ params: dom.getAttribute('data-params') || '' }) }],
toDOM(node) { return ['pre', node.attrs.params ? { 'data-params': node.attrs.params } : {}, ['code', 0]]; },
},
// Source the editor does not model. Edited as plain text, written back as typed.
raw_block: {
content: 'text*', group: 'block', code: true, defining: true, marks: '', attrs: block({}),
parseDOM: [{ tag: 'pre.md-raw', preserveWhitespace: 'full' }],
toDOM() { return ['pre', { class: 'md-raw', title: 'Markdown the visual editor keeps as written' }, ['code', 0]]; },
},
ordered_list: {
content: 'list_item+', group: 'block', attrs: block({ order: { default: 1 }, tight: { default: false } }),
parseDOM: [{ tag: 'ol', getAttrs: dom => ({ order: dom.hasAttribute('start') ? +dom.getAttribute('start') : 1, tight: dom.hasAttribute('data-tight') }) }],
toDOM(node) { return ['ol', { start: node.attrs.order === 1 ? null : node.attrs.order, 'data-tight': node.attrs.tight ? 'true' : null }, 0]; },
},
bullet_list: {
content: 'list_item+', group: 'block', attrs: block({ tight: { default: false } }),
parseDOM: [{ tag: 'ul', getAttrs: dom => ({ tight: dom.hasAttribute('data-tight') }) }],
toDOM(node) { return ['ul', { 'data-tight': node.attrs.tight ? 'true' : null }, 0]; },
},
list_item: { content: 'block+', defining: true, parseDOM: [{ tag: 'li' }], toDOM() { return ['li', 0]; } },
text: { group: 'inline' },
// An image is never loaded: it is an atom drawn as the text `[image: alt] address`, as row 148's
// preview draws it. There is no <img> in the schema and no rule that makes one from pasted HTML.
image: {
inline: true, group: 'inline', atom: true, draggable: true,
attrs: { src: {}, alt: { default: null }, title: { default: null } },
parseDOM: [{
tag: 'span.md-image[data-src]',
// Pasted HTML gets an image atom only for a target the link gate accepts; a file's own image, whatever
// its target, stays on the load path, where it is drawn as text and never fetched.
getAttrs: dom => (linkTarget(dom.getAttribute('data-src')) === null ? false
: { src: dom.getAttribute('data-src'), alt: dom.getAttribute('data-alt'), title: dom.getAttribute('data-title') }),
}],
toDOM(node) {
const a = node.attrs, shown = `[image: ${a.alt ?? ''}] ${a.src}`;
return ['span', { class: 'md-image', 'data-src': a.src, 'data-alt': a.alt, 'data-title': a.title }, shown];
},
},
// No href, image, target resolution or click handler. C2-11b owns opening.
wikilink: {
inline: true, group: 'inline', atom: true,
attrs: { source: {} },
parseDOM: [{ tag: 'span.md-wikilink[data-source]', getAttrs: dom => {
const source = dom.getAttribute('data-source');
return isWikilink(source) ? { source } : false;
} }],
toDOM(node) { return ['span', { class: 'md-wikilink', 'data-source': node.attrs.source }, node.attrs.source]; },
},
hard_break: { inline: true, group: 'inline', selectable: false, parseDOM: [{ tag: 'br' }], toDOM() { return ['br']; } },
},
marks: {
em: {
parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }, { style: 'font-style=normal', clearMark: m => m.type.name === 'em' }],
toDOM() { return ['em']; },
},
strong: {
parseDOM: [
{ tag: 'strong' },
{ tag: 'b', getAttrs: dom => dom.style.fontWeight !== 'normal' && null },
{ style: 'font-weight=400', clearMark: m => m.type.name === 'strong' },
{ style: 'font-weight', getAttrs: v => /^(bold(er)?|[5-9]\d{2,})$/.test(v) && null },
],
toDOM() { return ['strong']; },
},
// A link mark exists only for a target the gate accepts, on load, on paste and in the link bar.
link: {
attrs: { href: {}, title: { default: null } },
inclusive: false,
parseDOM: [{
tag: 'a[href]',
getAttrs: dom => { const href = linkTarget(dom.getAttribute('href')); return href ? { href, title: dom.getAttribute('title') } : false; },
}],
toDOM(mark) {
const href = linkTarget(mark.attrs.href);
if (!href) return ['span', { class: 'md-bad-link' }, 0];
const attrs = { href, rel: 'noopener noreferrer', target: '_blank' };
if (mark.attrs.title) attrs.title = mark.attrs.title;
return ['a', attrs, 0];
},
},
code: { code: true, parseDOM: [{ tag: 'code' }], toDOM() { return ['code']; } },
},
});
// ------------------------------------------------------------------ Markdown to nodes
const splitter = MarkdownIt('commonmark', { html: true }).enable('table').enable('strikethrough');
const tokenizer = MarkdownIt('commonmark', { html: false }).enable('table').enable('strikethrough');
// The escape and backtick rules run first. A sticky match avoids copying the
// paragraph suffix at every bracket, and never crosses a link-label boundary.
tokenizer.inline.ruler.before('link', 'wikilink', (state, silent) => {
wikilinkAt.lastIndex = state.pos;
const found = wikilinkAt.exec(state.src);
if (!found || state.pos + found[0].length > state.posMax) return false;
if (!silent) { const token = state.push('wikilink', '', 0); token.content = found[0]; }
state.pos += found[0].length;
return true;
});
// Soft breaks stay line breaks in the text, so a wrapped paragraph keeps its wrapping. A link whose
// target the gate refuses makes the whole block source text (`refused`), so nothing rewrites it.
let refused = false;
const prepared = {
parse(text, env) {
const tokens = tokenizer.parse(text, env);
for (const t of tokens) {
for (const c of t.children ?? []) {
if (c.type === 'softbreak') { c.type = 'text'; c.content = '\n'; }
else if (c.type === 'link_open' && !linkTarget(c.attrGet('href'))) refused = true;
}
}
return tokens;
},
};
const tight = (tokens, i) => {
while (++i < tokens.length) if (tokens[i].type !== 'list_item_open') return tokens[i].hidden;
return false;
};
const parser = new MarkdownParser(schema, prepared, {
blockquote: { block: 'blockquote' },
paragraph: { block: 'paragraph' },
list_item: { block: 'list_item' },
bullet_list: { block: 'bullet_list', getAttrs: (_, tokens, i) => ({ tight: tight(tokens, i) }) },
ordered_list: { block: 'ordered_list', getAttrs: (tok, tokens, i) => ({ order: +tok.attrGet('start') || 1, tight: tight(tokens, i) }) },
heading: { block: 'heading', getAttrs: tok => ({ level: +tok.tag.slice(1) }) },
code_block: { block: 'code_block', noCloseToken: true },
// A tilde fence may carry a backtick in its info string; the serialiser would drop that info, so the block stays source.
fence: { block: 'code_block', getAttrs: tok => { if (tok.info.includes('`')) refused = true; return { params: tok.info || '' }; }, noCloseToken: true },
hr: { node: 'horizontal_rule' },
image: { node: 'image', getAttrs: tok => ({ src: tok.attrGet('src'), title: tok.attrGet('title') || null, alt: tok.content || null }) },
hardbreak: { node: 'hard_break' },
wikilink: { node: 'wikilink', getAttrs: tok => ({ source: tok.content }) },
em: { mark: 'em' },
strong: { mark: 'strong' },
link: { mark: 'link', getAttrs: tok => ({ href: tok.attrGet('href'), title: tok.attrGet('title') || null }) },
code_inline: { mark: 'code', noCloseToken: true },
});
// ------------------------------------------------------------------ nodes to Markdown
// `<` and `&` in text are escaped so a rewritten block never turns typed text into markup or an entity
// in some other Markdown renderer. (The serialiser's own `esc` leaves both alone.)
// A link title, an image alt or title, and a code block's info string are written inside one line of Markdown, but a
// paste can set any of them. A line break in one would end the line early and let the rest start a block of its own
// (a blank line, then `<img onerror=...>`, is an HTML block in another renderer), so a run of line breaks is one
// space, and an info string stops at its first line break and is dropped if it holds a backtick (that ends a fence).
// Row 148's Console renders with html:false, so this is for the files other renderers show.
const oneLine = value => (typeof value === 'string' ? value.replace(/[\r\n]+/g, ' ') : value);
// A title is written between double quotes, so a backslash in it is doubled as well as the quote: left alone, a title
// ending in `\` swallows its own closing quote, and `x\") <img ...>` ends the title early. The vendored link writer
// escapes only `"`, so the backslashes are doubled before it sees the title.
const titleBackslash = value => (typeof value === 'string' ? oneLine(value).replace(/\\/g, '\\\\') : value);
const fenceInfo = value => {
const info = typeof value === 'string' ? value.split(/[\r\n]/, 1)[0] : '';
return info.includes('`') ? '' : info;
};
// Share the complete text policy with invalid wikilink attributes. The
// vendored state.text(..., true) alone does not escape HTML or entities.
function escapedText(state, text, escape = true) {
const esc = state.esc;
state.esc = (str, start) => esc.call(state, str, start).replace(/[<&]/g, '\\$&');
try { state.text(text, escape); } finally { delete state.esc; }
}
const link = defaultMarkdownSerializer.marks.link;
const serializer = new MarkdownSerializer({
...defaultMarkdownSerializer.nodes,
code_block(state, node) {
const params = fenceInfo(node.attrs.params);
defaultMarkdownSerializer.nodes.code_block(state, params === node.attrs.params ? node : node.type.create({ ...node.attrs, params }, node.content));
},
text(state, node) { escapedText(state, node.text, !state.inAutolink); },
bullet_list(state, node) { state.renderList(node, ' ', () => '- '); },
image(state, node) {
const a = node.attrs;
const title = oneLine(a.title);
state.write('![' + state.esc(oneLine(a.alt) || '') + '](' + a.src.replace(/[()]/g, '\\$&') + (title ? ' "' + title.replace(/[\\"]/g, '\\$&') + '"' : '') + ')');
},
wikilink(state, node) {
const source = node.attrs.source, valid = isWikilink(source);
// A programmatic invalid atom must not bypass the same text escaping that
// protects pasted HTML and one-line attributes elsewhere in this model.
if (valid) state.text(source, false);
else escapedText(state, oneLine(String(source ?? '')));
},
raw_block(state, node) { state.text(node.textContent, false); state.closeBlock(node); },
}, {
...defaultMarkdownSerializer.marks,
// Only `close` writes the title; `open` reads it just to tell an autolink, which a title rules out either way.
link: { ...link, close: (state, mark, parent, index) => {
const title = titleBackslash(mark.attrs.title);
return link.close(state, title === mark.attrs.title ? mark : mark.type.create({ ...mark.attrs, title }), parent, index);
} },
});
const written = new WeakMap(); // nodes are immutable, so what the serialiser wrote for one never changes
function canonical(node) {
let text = written.get(node);
if (text === undefined) {
text = node.type.name === 'raw_block' ? node.textContent : serializer.serialize(schema.nodes.doc.create(null, [node]));
written.set(node, text);
}
return text;
}
const STRUCTURED = new Set(['paragraph', 'heading', 'blockquote', 'bullet_list', 'ordered_list', 'fence', 'hr']);
// The span of lines `a` up to `b` with their line ends; joined over a partition of the lines it is the source.
const span = (lines, a, b) => (a >= b ? '' : lines.slice(a, b).join('\n') + (b < lines.length ? '\n' : ''));
// Front matter: a first line of `---` and a later line of `---` or `...`. Returns the end line, or 0.
function frontMatter(lines) {
if (lines[0] !== '---') return 0;
for (let i = 1; i < lines.length; i++) if (lines[i] === '---' || lines[i] === '...') return i + 1;
return 0;
}
// The blocks of a source as {from, to, kind} line spans in order, covering every non-blank line.
function blocks(lines) {
const found = [];
const fm = frontMatter(lines);
if (fm) found.push({ from: 0, to: fm, kind: 'raw' });
const rest = lines.slice(fm).join('\n'), env = {};
const tokens = splitter.parse(rest, env);
for (const t of tokens) {
if (t.level !== 0 || !t.map || t.nesting === -1) continue;
const kind = t.type.replace(/_open$/, '');
const from = t.map[0] + fm, to = t.map[1] + fm;
const plain = STRUCTURED.has(kind) && (kind !== 'heading' || to - from <= 1); // setext headings stay source
found.push({ from, to, kind: plain ? kind : 'raw' });
}
// Non-blank lines no token covers (link reference definitions) are raw blocks of their own.
const all = [];
let cursor = 0;
const gap = to => {
for (let i = cursor; i < to; i++) {
if (!lines[i].trim()) continue;
let j = i;
while (j < to && lines[j].trim()) j++;
all.push({ from: i, to: j, kind: 'raw' });
i = j;
}
};
for (const b of found) {
if (b.from < cursor) return null; // overlapping spans: the caller keeps the whole file as one raw block
gap(b.from);
all.push(b);
cursor = b.to;
}
gap(lines.length);
return { all, env };
}
// A block the parser has no node for (a table, ~~strikethrough~~, anything inside a list or quote that the
// schema lacks) makes it throw; that block stays source.
function nodeOf(kind, text, env) {
if (kind !== 'raw') {
refused = false;
try {
const doc = parser.parse(text, env);
if (doc.childCount === 1 && !refused) return doc.firstChild;
} catch { /* the block stays source */ }
}
return schema.nodes.raw_block.create(null, text ? schema.text(text) : null);
}
/// The ProseMirror document of a Markdown source (LF line ends).
export function parse(src) {
const lines = String(src).split('\n');
const found = blocks(lines) ?? { all: [{ from: 0, to: lines.length, kind: 'raw' }], env: {} };
const children = [];
let cursor = 0;
for (const b of found.all) {
const text = lines.slice(b.from, b.to).join('\n');
const node = nodeOf(b.kind, text, found.env);
const attrs = { ...node.attrs, raw: span(lines, b.from, b.to), lead: span(lines, cursor, b.from) };
let made = node.type.create(attrs, node.content, node.marks);
made = node.type.create({ ...attrs, sig: canonical(made) }, node.content, node.marks);
children.push(made);
cursor = b.to;
}
return schema.nodes.doc.create({ tail: span(lines, cursor, lines.length) }, children.length ? children : [schema.nodes.paragraph.create()]);
}
/// The Markdown source of a document: unchanged blocks as they were read, changed blocks as written now.
export function serialise(doc) {
let out = '';
doc.forEach(node => {
const text = canonical(node), lead = node.attrs.lead;
if (out && !out.endsWith('\n')) out += '\n';
if (node.attrs.raw !== null && node.attrs.sig === text) { out += lead + node.attrs.raw; return; }
if (!text.trim()) return; // an emptied block writes nothing
// A changed block keeps the blank lines it had, but always at least one so it cannot merge into its neighbour.
out += (out && !lead.includes('\n') ? '\n' : lead) + text + '\n';
});
return out + doc.attrs.tail;
}