// 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; }; 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)*', 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 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]; }, }, 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'); // 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' }, 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 ``, 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\") ` 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; }; 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) { const esc = state.esc; state.esc = (str, start) => esc.call(state, str, start).replace(/[<&]/g, '\\$&'); try { state.text(node.text, !state.inAutolink); } finally { delete state.esc; } }, 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, '\\$&') + '"' : '') + ')'); }, 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; }