eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// πŸ€– The allowlisted CommonMark subset for message rendering (issue #531).
2//
3// Messages are parsed by a real spec-compliant parser (vendored markdown-it,
4// web/static/vendor/markdown-it.js) instead of the hand-rolled regex passes in
5// format.js β€” a defined *subset* with an allowlist: emphasis, inline code,
6// links (http(s) only), lists, blockquotes, fenced code, strike. Constructs
7// outside the allowlist (headings, tables, HTML, images, …) stay literal text.
8// The native twin is MarkdownTree.swift (apple/swift-markdown walked with the
9// same allowlist); both sides normalize to the SAME semantic tree, pinned by
10// test/event-vectors/markdown.json β€” the vectors are the contract: when the
11// twins disagree, fix the implementations, never the vectors.
12//
13// Layering: parseMessage(text) β†’ tree is pure (Node-testable, no DOM);
14// renderTree(el, tree, hooks) walks the tree into DOM nodes β€” createElement /
15// textContent only, never innerHTML (CLAUDE.md). Bare-URL autolinking is NOT
16// markdown-it's linkify: text runs go through format.js tokenizeText, so the
17// curated BARE_TLDS + trailing-punctuation rules survive verbatim, and a URL
18// inside emphasis links correctly (**github.com/x** β†’ strong > link β€” the bug
19// that motivated #531).
20
21import MarkdownIt from "./vendor/markdown-it.js";
22import { tokenizeText } from "./format.js";
23
24// --- house parser configuration ---------------------------------------------
25
26// Only http(s) may become a real link β€” no relative paths, no mailto:, and
27// javascript:/data: die here. A rejected link never tokenizes, so the source
28// text stays literal automatically.
29function validateLink(url) {
30 return /^https?:\/\//i.test(url);
31}
32
33// Single-OR-double-tilde strike (~s~ and ~~s~~), matching cmark-gfm on native
34// (its strikethrough extension accepts runs of 1 or 2; the DOUBLE_TILDE-only
35// option is never set by swift-markdown). Adapted from markdown-it's own
36// strikethrough rule, with two house differences: runs of 3+ tildes never
37// strike (they stay literal, as in cmark-gfm), and an opener only pairs with
38// a closer of the same length (~a~~ stays literal). History needs this rule:
39// old messages use the original grammar's single ~.
40function strike11Tokenize(state, silent) {
41 const marker = state.src.charCodeAt(state.pos);
42 if (silent || marker !== 0x7e /* ~ */) return false;
43 const scanned = state.scanDelims(state.pos, true);
44 const len = scanned.length;
45 const token = state.push("text", "", 0);
46 token.content = "~".repeat(len);
47 if (len <= 2) {
48 state.delimiters.push({
49 marker,
50 length: len,
51 token: state.tokens.length - 1,
52 end: -1,
53 open: scanned.can_open,
54 close: scanned.can_close,
55 });
56 }
57 state.pos += len;
58 return true;
59}
60
61function strike11Pair(state, delimiters) {
62 for (let i = 0; i < delimiters.length; i++) {
63 const open = delimiters[i];
64 if (open.marker !== 0x7e || open.end === -1) continue;
65 const close = delimiters[open.end];
66 const openTok = state.tokens[open.token];
67 const closeTok = state.tokens[close.token];
68 if (openTok.content !== closeTok.content) continue; // ~ pairs with ~, ~~ with ~~
69 openTok.type = "s_open";
70 openTok.tag = "s";
71 openTok.nesting = 1;
72 openTok.markup = openTok.content;
73 openTok.content = "";
74 closeTok.type = "s_close";
75 closeTok.tag = "s";
76 closeTok.nesting = -1;
77 closeTok.markup = openTok.markup;
78 closeTok.content = "";
79 }
80}
81
82function strike11PostProcess(state) {
83 strike11Pair(state, state.delimiters);
84 for (const meta of state.tokens_meta) {
85 if (meta && meta.delimiters) strike11Pair(state, meta.delimiters);
86 }
87}
88
89// The allowlist is expressed ENABLE-ONLY from the "zero" preset, so a vendored
90// parser version bump can never sneak a new construct in. Never enabled (and
91// therefore literal text): heading, lheading, hr, code (indented β€” it mangles
92// pasted text), table, reference, html_block, html_inline, linkify,
93// smartquotes, replacements, strikethrough (markdown-it's ~~-only rule β€”
94// replaced by strike11 above). `image` IS enabled, deliberately: the tree
95// walk reconstructs images as literal text (below) β€” with the rule disabled,
96// ![a](url) would half-parse as "!" + a link, which native cmark (where
97// images cannot be disabled) could not mirror.
98export function houseParser() {
99 const md = new MarkdownIt("zero", {
100 html: false,
101 breaks: true, // every newline is a break in chat (renderer-level; tokens carry softbreak)
102 linkify: false,
103 typographer: false,
104 });
105 md.enable([
106 "fence", "blockquote", "list", // block
107 "newline", "escape", "backticks", "emphasis", "link", "image", "autolink", "entity", // inline
108 ]);
109 md.validateLink = validateLink;
110 md.inline.ruler.before("emphasis", "strike11", strike11Tokenize);
111 md.inline.ruler2.before("emphasis", "strike11", strike11PostProcess);
112 return md;
113}
114
115const md = houseParser();
116
117// --- token stream β†’ semantic tree --------------------------------------------
118//
119// Tree shape (the vector contract β€” see test/event-vectors/markdown.json):
120// blocks: {t:"p", children} {t:"quote", children:[blocks]}
121// {t:"ul", items:[[blocks]]} {t:"ol", start, items:[[blocks]]}
122// {t:"codeblock", text, lang}
123// inlines: {t:"text", text} {t:"br"} {t:"code", text}
124// {t:"link", href, children} {t:"strong"|"em"|"strike", children}
125
126function pushText(list, text) {
127 if (!text) return;
128 const last = list[list.length - 1];
129 // Adjacent text runs merge so both twins normalize to one canonical tree
130 // (native splits text around inline HTML; web merges in text_join).
131 if (last && last.t === "text") last.text += text;
132 else list.push({ t: "text", text });
133}
134
135// A plain text run outside links: format.js tokenizeText splits out bare URLs
136// under the house rules (curated TLDs, trailing punctuation stays text).
137function pushLinkified(list, text) {
138 for (const tok of tokenizeText(text)) {
139 if (tok.href) list.push({ t: "link", href: tok.href, children: [{ t: "text", text: tok.text }] });
140 else pushText(list, tok.text);
141 }
142}
143
144const INLINE_WRAP = { em_open: "em", strong_open: "strong", s_open: "strike" };
145
146function readInline(children) {
147 const root = { t: "", children: [] };
148 const stack = [root];
149 let linkDepth = 0;
150 const top = () => stack[stack.length - 1].children;
151 for (const c of children || []) {
152 if (c.type === "text") {
153 if (linkDepth > 0) pushText(top(), c.content);
154 else pushLinkified(top(), c.content);
155 } else if (c.type === "softbreak" || c.type === "hardbreak") {
156 top().push({ t: "br" });
157 } else if (c.type === "code_inline") {
158 top().push({ t: "code", text: c.content });
159 } else if (INLINE_WRAP[c.type]) {
160 const node = { t: INLINE_WRAP[c.type], children: [] };
161 top().push(node);
162 stack.push(node);
163 } else if (c.type === "em_close" || c.type === "strong_close" || c.type === "s_close") {
164 if (stack.length > 1) stack.pop();
165 } else if (c.type === "link_open") {
166 const node = { t: "link", href: c.attrGet("href") || "", children: [] };
167 top().push(node);
168 stack.push(node);
169 linkDepth++;
170 } else if (c.type === "link_close") {
171 if (stack.length > 1) stack.pop();
172 linkDepth--;
173 } else if (c.type === "image") {
174 // Outside the allowlist: reconstruct the literal source from the parsed
175 // parts (alt rides in content, the destination in src). The native twin
176 // rebuilds the same string from cmark's Image node.
177 pushText(top(), "![" + (c.content || "") + "](" + (c.attrGet("src") || "") + ")");
178 } else {
179 // Unreachable under the enable-list β€” a safety net, not a contract.
180 pushText(top(), (c.markup || "") + (c.content || ""));
181 }
182 }
183 return root.children;
184}
185
186function readBlocks(tokens, i, closeType, srcLines) {
187 const blocks = [];
188 while (i < tokens.length) {
189 const t = tokens[i];
190 if (t.type === closeType) return [blocks, i + 1];
191 if (t.type === "paragraph_open") {
192 blocks.push({ t: "p", children: readInline(tokens[i + 1].children) });
193 i += 3;
194 } else if (t.type === "fence") {
195 blocks.push({
196 t: "codeblock",
197 text: t.content.replace(/\n$/, ""),
198 lang: (t.info || "").trim(),
199 });
200 i++;
201 } else if (t.type === "blockquote_open") {
202 const [children, next] = readBlocks(tokens, i + 1, "blockquote_close", srcLines);
203 blocks.push({ t: "quote", children });
204 i = next;
205 } else if (t.type === "bullet_list_open" || t.type === "ordered_list_open") {
206 const ordered = t.type === "ordered_list_open";
207 const items = [];
208 let j = i + 1;
209 while (j < tokens.length && tokens[j].type === "list_item_open") {
210 const [itemBlocks, next] = readBlocks(tokens, j + 1, "list_item_close", srcLines);
211 items.push(itemBlocks);
212 j = next;
213 }
214 if (ordered) {
215 blocks.push({ t: "ol", start: parseInt(t.attrGet("start") || "1", 10), items });
216 } else {
217 blocks.push({ t: "ul", items });
218 }
219 i = j + 1; // past the list_close
220 } else {
221 // Unreachable under the enable-list; recover exact source via the block
222 // token's line map as a literal paragraph.
223 if (t.map) {
224 blocks.push({ t: "p", children: [{ t: "text", text: srcLines.slice(t.map[0], t.map[1]).join("\n") }] });
225 i++;
226 while (i < tokens.length && tokens[i].map && tokens[i].map[1] <= t.map[1]) i++;
227 } else {
228 i++;
229 }
230 }
231 }
232 return [blocks, i];
233}
234
235// The pure half: message text β†’ semantic tree (exactly the vector shape).
236export function parseMessage(text) {
237 const src = String(text ?? "");
238 const tokens = md.parse(src, {});
239 const [blocks] = readBlocks(tokens, 0, null, src.split("\n"));
240 return blocks;
241}
242
243// --- tree β†’ DOM ---------------------------------------------------------------
244
245const WRAP_TAG = { strong: "strong", em: "em", strike: "s" };
246
247// hooks.plain(target, text): render a plain text run β€” chat.js passes its
248// Markdown-aware PLAIN projection for one-line surfaces (the sidebar's
249// last-message preview, notification bodies): escapes resolve (\* β†’ *) and
250// the marks themselves drop β€” the same characters the bubbles show, minus
251// the styling. Never raw message text on those surfaces: the composer's
252// opt-out escapes leak as visible backslashes (Paul's report, 2026-07-15).
253// Twin: markdownPlainText (MarkdownTree.swift); the `plain` fields in
254// test/event-vectors/markdown.json pin the pair.
255export function plainText(text) {
256 const inline = (children) =>
257 children
258 .map((n) => {
259 if (n.t === "text" || n.t === "code") return n.text;
260 if (n.t === "br") return " ";
261 if (n.children) return inline(n.children); // link / strong / em / strike
262 return "";
263 })
264 .join("");
265 const blocks = (list) =>
266 list
267 .map((b) => {
268 if (b.t === "p") return inline(b.children);
269 if (b.t === "quote") return blocks(b.children);
270 if (b.t === "ul" || b.t === "ol") return b.items.map(blocks).join(" ");
271 if (b.t === "codeblock") return b.text;
272 return "";
273 })
274 .join(" ");
275 return blocks(parseMessage(text));
276}
277
278// mention scanner; default is a bare text node. Mentions are scanned only on
279// text runs, never inside code or links, matching the original feed.
280export function renderTree(el, blocks, hooks = {}) {
281 const plain = hooks.plain || ((target, t) => target.appendChild(document.createTextNode(t)));
282 const inline = (target, children) => {
283 for (const n of children) {
284 if (n.t === "text") {
285 plain(target, n.text);
286 } else if (n.t === "br") {
287 target.appendChild(document.createElement("br"));
288 } else if (n.t === "code") {
289 const c = document.createElement("code");
290 c.textContent = n.text;
291 target.appendChild(c);
292 } else if (n.t === "link") {
293 const a = document.createElement("a");
294 a.href = n.href;
295 a.target = "_blank";
296 a.rel = "noopener noreferrer";
297 inline(a, n.children);
298 target.appendChild(a);
299 } else if (WRAP_TAG[n.t]) {
300 const w = document.createElement(WRAP_TAG[n.t]);
301 inline(w, n.children);
302 target.appendChild(w);
303 }
304 }
305 };
306 const block = (target, b) => {
307 if (b.t === "p") {
308 const p = document.createElement("p");
309 inline(p, b.children);
310 target.appendChild(p);
311 } else if (b.t === "codeblock") {
312 const pre = document.createElement("pre");
313 const code = document.createElement("code");
314 code.textContent = b.text;
315 pre.appendChild(code);
316 target.appendChild(pre);
317 } else if (b.t === "quote") {
318 const q = document.createElement("blockquote");
319 for (const child of b.children) block(q, child);
320 target.appendChild(q);
321 } else if (b.t === "ul" || b.t === "ol") {
322 const list = document.createElement(b.t);
323 if (b.t === "ol" && b.start !== 1) list.start = b.start;
324 for (const item of b.items) {
325 const li = document.createElement("li");
326 for (const child of item) block(li, child);
327 list.appendChild(li);
328 }
329 target.appendChild(list);
330 }
331 };
332 // The common case β€” one plain paragraph β€” keeps the original feed's DOM
333 // shape (inline nodes appended bare, no <p> wrapper), so existing bubble
334 // styling is untouched for ordinary messages.
335 if (blocks.length === 1 && blocks[0].t === "p") {
336 inline(el, blocks[0].children);
337 return;
338 }
339 for (const b of blocks) block(el, b);
340}
341