// 🤖 The rich composer's document core (#531): schema, markdown round-trip, // and doc⇄plaintext offset mapping. Pure — no EditorView, no DOM at module // scope — so every rule here is Node-testable (test/composer.test.mjs). The // view glue lives in composer-pm.js. // // Three load-bearing rules: // // 1. THE KEYBOARD NEVER SPLITS PARAGRAPHS. Enter-that-means-newline inserts a // hard_break; paragraph nodes only arise from parsing "\n\n" on edit-load. // The serializer maps hard_break → "\n" and block joins → "\n\n", so a // plain multi-line message serializes byte-identical to what the textarea // sent yesterday — the wire format must not drift under old clients. // // 2. ZERO-ESCAPE POLICY. Unmarked plain text serializes verbatim — never a // backslash. Old fleet clients (TestFlight/Sparkle lag) render escapes // literally, and "what you type is what sends" is today's behaviour. // Escapes appear only where the serializer's own generated syntax would be // corrupted: `*` inside em/strong runs, `~` inside strike, brackets in // link labels. Consequence, accepted + tested: serialize∘parse is NOT the // identity on markdown-shaped plain text (parse("- a") is a real list) — // but serialize∘parse∘serialize = serialize always holds. // // 3. ONE PARSER ON WEB. The markdown-side parse uses markdown.js's // houseParser() (the feed's exact configuration), so what the composer // understands and what the feed renders can never disagree. The one // deliberate delta: `image` is disabled here, so an edit-loaded // ![alt](url) becomes "!" + a link — which serializes back to the same // bytes — instead of an image node the schema doesn't have. import { Schema, MarkdownParser, MarkdownSerializer } from "./vendor/prosemirror.js"; import { houseParser } from "./markdown.js"; // --- schema ------------------------------------------------------------------- // The feed's allowlist, minus what only exists at render time (mentions, // autolinked bare URLs). No headings, no images, no tables, no hr. The link // mark exists for edit-load round-trips and pasted rich text — typing a URL // stays plain text (the feed autolinks at render, same as today). export const composerSchema = new Schema({ nodes: { doc: { content: "block+" }, paragraph: { content: "inline*", group: "block", parseDOM: [{ tag: "p" }], toDOM: () => ["p", 0], }, blockquote: { content: "block+", group: "block", defining: true, parseDOM: [{ tag: "blockquote" }], toDOM: () => ["blockquote", 0], }, code_block: { content: "text*", marks: "", group: "block", code: true, defining: true, attrs: { params: { default: "" } }, parseDOM: [{ tag: "pre", preserveWhitespace: "full" }], toDOM: () => ["pre", ["code", 0]], }, bullet_list: { content: "list_item+", group: "block", parseDOM: [{ tag: "ul" }], toDOM: () => ["ul", 0], }, ordered_list: { content: "list_item+", group: "block", attrs: { order: { default: 1 } }, parseDOM: [ { tag: "ol", getAttrs: (n) => ({ order: n.hasAttribute("start") ? +n.getAttribute("start") || 1 : 1 }) }, ], toDOM: (n) => (n.attrs.order === 1 ? ["ol", 0] : ["ol", { start: n.attrs.order }, 0]), }, list_item: { content: "paragraph block*", defining: true, parseDOM: [{ tag: "li" }], toDOM: () => ["li", 0], }, text: { group: "inline" }, hard_break: { inline: true, group: "inline", selectable: false, parseDOM: [{ tag: "br" }], toDOM: () => ["br"], }, }, marks: { em: { parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }], toDOM: () => ["em", 0], }, strong: { parseDOM: [ { tag: "strong" }, // Pasted Google-Docs-style bold comes as inline styles / . { tag: "b", getAttrs: (n) => n.style.fontWeight !== "normal" && null }, { style: "font-weight", getAttrs: (v) => /^(bold(er)?|[5-9]\d{2,})$/.test(v) && null }, ], toDOM: () => ["strong", 0], }, strike: { parseDOM: [{ tag: "s" }, { tag: "del" }, { tag: "strike" }, { style: "text-decoration=line-through" }], toDOM: () => ["s", 0], }, code: { excludes: "_", // code never combines with other marks, like the feed parseDOM: [{ tag: "code" }], toDOM: () => ["code", 0], }, link: { attrs: { href: {} }, inclusive: false, parseDOM: [ { tag: "a[href]", // Same gate as the feed's validateLink: http(s) or it isn't a link. getAttrs: (n) => (/^https?:\/\//i.test(n.getAttribute("href") || "") ? { href: n.getAttribute("href") } : false), }, ], toDOM: (n) => ["a", { href: n.attrs.href, rel: "noopener noreferrer" }, 0], }, }, }); // --- markdown → doc ------------------------------------------------------------- const parserMd = houseParser(); parserMd.disable("image"); // see the header note — "!"+link round-trips; an image node wouldn't const mdParser = new MarkdownParser(composerSchema, parserMd, { paragraph: { block: "paragraph" }, blockquote: { block: "blockquote" }, bullet_list: { block: "bullet_list" }, ordered_list: { block: "ordered_list", getAttrs: (tok) => ({ order: +tok.attrGet("start") || 1 }), }, list_item: { block: "list_item" }, fence: { block: "code_block", getAttrs: (tok) => ({ params: (tok.info || "").trim() }), noCloseToken: true }, // Every newline is a break in chat (feed parses breaks:true) — soft and // hard breaks are the same node, and rule 1 keeps paragraphs out of typing. softbreak: { node: "hard_break" }, hardbreak: { node: "hard_break" }, code_inline: { mark: "code", noCloseToken: true }, em: { mark: "em" }, strong: { mark: "strong" }, s: { mark: "strike" }, link: { mark: "link", getAttrs: (tok) => ({ href: tok.attrGet("href") || "" }) }, }); export function parseToDoc(text) { return mdParser.parse(String(text ?? "")); } // --- doc → markdown ------------------------------------------------------------- // Rule 2: only escape where our own generated syntax would break. function escapeInMarks(text, marks) { const names = new Set(marks.map((m) => m.type.name)); if (names.has("code")) return text; // delimiters are computed to clear the content let out = text; if (names.has("em") || names.has("strong")) out = out.replace(/\*/g, "\\*"); if (names.has("strike")) out = out.replace(/~/g, "\\~"); if (names.has("link")) out = out.replace(/[\[\]]/g, (c) => "\\" + c); return out; } // Inline-code delimiters sized past the content's longest backtick run, with // CommonMark's space padding when the content itself starts/ends in a tick // (the defaultMarkdownSerializer rule, replicated so `escape:false` is safe). function backticksFor(node, side) { const ticks = /`+/g; let len = 0; let m; if (node.isText) while ((m = ticks.exec(node.text))) len = Math.max(len, m[0].length); let result = len > 0 && side > 0 ? " `" : "`"; for (let i = 0; i < len; i++) result += "`"; if (len > 0 && side < 0) result += " "; return result; } // Literal-mode escaping for a plain run: marker characters everywhere, and // block-forming syntax at line starts (so a typed "- " or "> " reads back as // exactly those characters in every feed). `out` is the serializer's // accumulated output — empty or newline-terminated means this run opens a line. function escapeLiteralRun(text, out) { let escaped = text.replace(/[\\`*_~]/g, (c) => "\\" + c); const atLineStart = out === "" || out.endsWith("\n"); const guardLine = (line) => line .replace(/^(\s*)([->+])(\s)/, "$1\\$2$3") .replace(/^(\s*\d+)\.(\s)/, "$1\\.$2"); if (atLineStart) escaped = guardLine(escaped); return escaped; } function safeHref(href) { return /[\s()]/.test(href) ? "<" + href + ">" : href; } const mdSerializer = new MarkdownSerializer( { paragraph(state, node) { state.renderInline(node); state.closeBlock(node); }, hard_break(state) { state.write("\n"); // NOT markdown's "\\\n" — the wire format is plain newlines }, code_block(state, node) { const runs = node.textContent.match(/`{3,}/g); const fence = runs ? "`".repeat(Math.max(...runs.map((s) => s.length)) + 1) : "```"; state.write(fence + (node.attrs.params || "") + "\n"); state.text(node.textContent, false); state.ensureNewLine(); state.write(fence); state.closeBlock(node); }, blockquote(state, node) { state.wrapBlock("> ", null, node, () => state.renderContent(node)); }, bullet_list(state, node) { state.renderList(node, " ", () => "- "); }, ordered_list(state, node) { const start = node.attrs.order || 1; const maxW = String(start + node.childCount - 1).length; const space = " ".repeat(maxW + 2); state.renderList(node, space, (i) => { const nStr = String(start + i); return " ".repeat(maxW - nStr.length) + nStr + ". "; }); }, list_item(state, node) { state.renderContent(node); }, text(state, node) { // escapeLiterals (format-as-you-type OFF): plain-run marker characters // are the member's literal text and must RENDER literally — escape // them. Marked runs keep their generated markers (the WYSIWYG's work) // and the normal in-mark escaping. if (state.options.escapeLiterals && node.marks.length === 0) { state.text(escapeLiteralRun(node.text, state.out), false); } else { state.text(escapeInMarks(node.text, node.marks), false); } }, }, { em: { open: "*", close: "*", mixable: true, expelEnclosingWhitespace: true }, strong: { open: "**", close: "**", mixable: true, expelEnclosingWhitespace: true }, // Single tilde on purpose: the old grammar (still live across the fleet) // renders ~s~ perfectly; ~~s~~ would show stray tildes there. strike: { open: "~", close: "~", mixable: true, expelEnclosingWhitespace: true }, link: { open: "[", close: (state, mark) => "](" + safeHref(mark.attrs.href) + ")", }, code: { open: (state, mark, parent, index) => backticksFor(parent.child(index), -1), close: (state, mark, parent, index) => backticksFor(parent.child(index - 1), 1), escape: false, }, }, ); export function serializeDoc(doc, options = {}) { // tightLists: chat lists are always tight (the feed renders them tight, and // loose serialization would inject \n\n old clients show as blank lines). // The one escape prosemirror-markdown emits outside our handlers is the // protective "\!" before a link (renderInline guards against accidental // image syntax). Our zero-escape policy never writes "\!", so stripping it // is unambiguous — and required: it's how an edit-loaded ![alt](url) // round-trips byte-identical (the feed renders images literally anyway). return mdSerializer .serialize(doc, { tightLists: true, escapeLiterals: !!options.escapeLiterals }) .replace(/\\(!\[)/g, "$1"); } // --- doc ⇄ plaintext offsets ---------------------------------------------------- // // The typeahead contract (ui.js composerToken + its Swift twin) is plain // string + caret offset. docText joins textblocks with "\n" and reads // hard_break as "\n"; the two mappers translate offsets in that string to // ProseMirror positions and back, walking in exactly docText's order. export function docText(doc) { let out = ""; let first = true; doc.descendants((node) => { if (!node.isTextblock) return true; if (!first) out += "\n"; first = false; node.forEach((child) => { out += child.isText ? child.text : "\n"; }); return false; }); return out; } export function textOffsetFromPos(doc, pos) { let off = 0; let first = true; let found = null; doc.descendants((node, nodePos) => { if (found !== null) return false; if (!node.isTextblock) return true; if (!first) off += 1; // the "\n" join before this block first = false; if (pos <= nodePos) { found = off; return false; } const contentEnd = nodePos + node.nodeSize - 1; if (pos > contentEnd) { node.forEach((child) => { off += child.isText ? child.text.length : 1; }); return false; } let inner = nodePos + 1; node.forEach((child) => { if (found !== null) return; if (pos <= inner) { found = off; return; } if (child.isText) { if (pos <= inner + child.nodeSize) { found = off + (pos - inner); return; } off += child.text.length; } else { off += 1; // hard_break } inner += child.nodeSize; }); if (found === null) found = off; return false; }); return found === null ? off : found; } export function posFromTextOffset(doc, offset) { let off = 0; let first = true; let result = null; let lastEnd = 0; doc.descendants((node, nodePos) => { if (result !== null) return false; if (!node.isTextblock) return true; if (!first) { // The join "\n" itself: an offset landing on it belongs to the START of // this block (a caret after Enter sits before the next block's text). off += 1; } first = false; let inner = nodePos + 1; node.forEach((child) => { if (result !== null) return; if (child.isText) { if (offset <= off + child.text.length) { result = inner + (offset - off); return; } off += child.text.length; } else { if (offset <= off) { result = inner; return; } off += 1; // hard_break reads as "\n" } inner += child.nodeSize; }); if (result === null && offset <= off) result = inner; lastEnd = inner; return false; }); return result === null ? lastEnd : result; }