web: render ``` fenced code blocks and > blockquotes in messages (#48)
Messages are now parsed for block structure before inline formatting: a pure blockTokens() in format.js splits text into plain / fenced-code / blockquote blocks, and renderText builds <pre><code> and <blockquote> nodes (no innerHTML, contents via textContent, so still XSS-safe). - Fences: optional language tag, verbatim contents (no linkify/marks inside), unterminated fences run to end of message. - Quotes: consecutive > lines merge into one blockquote; inline formatting and links still apply inside. - Link previews now use firstLink(), which skips code fences so a URL pasted as code doesn't sprout an unfurl card. - Styled for both bubble colours; blank lines butting against a block are dropped so pre-wrap doesn't double-space.
Co-authored-by: Claude <noreply@anthropic.com>
4 files changed,
+184
−4
test/format.test.mjs+64 −1web/static/chat.js+23 −3web/static/format.js+70 −0web/static/style.css+27 −0
diff --git a/test/format.test.mjs b/test/format.test.mjs| index 6954f5c..f639bfa 100644 |
| --- a/test/format.test.mjs |
| +++ b/test/format.test.mjs |
| @@ -1,6 +1,6 @@ |
| // Tests for text formatting (link detection, file sizes). node test/format.test.mjs |
| import assert from "node:assert/strict"; |
| -import { tokenizeText, richTokens, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle } from "../web/static/format.js"; |
| +import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle } from "../web/static/format.js"; |
| import { emojify, emojiMatches } from "../web/static/emoji.js"; |
| let failures = 0; |
| @@ -201,6 +201,69 @@ await test("richTokens: plain text passes through unmarked", () => { |
| assert.equal(richText("a *b* c `d` e"), "a b c d e"); |
| }); |
| +await test("blockTokens: plain text is one untouched p block", () => { |
| + assert.deepEqual(blockTokens("hello\n\nworld"), [{ type: "p", text: "hello\n\nworld" }]); |
| + assert.deepEqual(blockTokens(""), [{ type: "p", text: "" }]); |
| + assert.deepEqual(blockTokens(null), [{ type: "p", text: "" }]); |
| +}); |
| + |
| +await test("blockTokens: fenced code block, markers verbatim inside", () => { |
| + const blocks = blockTokens("look:\n```\nif (a > b) { *notBold* }\n```\nnice"); |
| + assert.deepEqual(blocks, [ |
| + { type: "p", text: "look:" }, |
| + { type: "code", text: "if (a > b) { *notBold* }", lang: "" }, |
| + { type: "p", text: "nice" }, |
| + ]); |
| +}); |
| + |
| +await test("blockTokens: fence captures a language tag", () => { |
| + const blocks = blockTokens("```js\nconst x = 1;\n```"); |
| + assert.deepEqual(blocks, [{ type: "code", text: "const x = 1;", lang: "js" }]); |
| +}); |
| + |
| +await test("blockTokens: unterminated fence runs to the end", () => { |
| + const blocks = blockTokens("```\nline one\nline two"); |
| + assert.deepEqual(blocks, [{ type: "code", text: "line one\nline two", lang: "" }]); |
| +}); |
| + |
| +await test("blockTokens: inline backticks on one line are not a fence", () => { |
| + const blocks = blockTokens("```js``` opens a fence"); |
| + assert.deepEqual(blocks, [{ type: "p", text: "```js``` opens a fence" }]); |
| +}); |
| + |
| +await test("blockTokens: consecutive > lines merge into one quote", () => { |
| + const blocks = blockTokens("> two roads diverged\n> in a yellow wood\nso true"); |
| + assert.deepEqual(blocks, [ |
| + { type: "quote", text: "two roads diverged\nin a yellow wood" }, |
| + { type: "p", text: "so true" }, |
| + ]); |
| +}); |
| + |
| +await test("blockTokens: bare > continues a quote as a blank line", () => { |
| + const blocks = blockTokens("> first\n>\n> second"); |
| + assert.deepEqual(blocks, [{ type: "quote", text: "first\n\nsecond" }]); |
| +}); |
| + |
| +await test("blockTokens: mid-line > is not a quote", () => { |
| + assert.deepEqual(blockTokens("5 > 3 and 2 > 1"), [{ type: "p", text: "5 > 3 and 2 > 1" }]); |
| +}); |
| + |
| +await test("blockTokens: blank lines around blocks are dropped", () => { |
| + const blocks = blockTokens("intro\n\n> quoted\n\noutro"); |
| + assert.deepEqual(blocks, [ |
| + { type: "p", text: "intro" }, |
| + { type: "quote", text: "quoted" }, |
| + { type: "p", text: "outro" }, |
| + ]); |
| +}); |
| + |
| +await test("firstLink finds a URL but ignores those inside code fences", () => { |
| + assert.equal(firstLink("```\nhttps://example.com/in-code\n```\nsee https://example.com/real"), |
| + "https://example.com/real"); |
| + assert.equal(firstLink("> see https://example.com/quoted"), "https://example.com/quoted"); |
| + assert.equal(firstLink("```\nhttps://example.com/only-code\n```"), null); |
| +}); |
| + |
| await test("emojify: known shortcodes → emoji, incl. +1 / -1", () => { |
| assert.equal(emojify("nice :tada:"), "nice 🎉"); |
| assert.equal(emojify(":+1: / :-1:"), "👍 / 👎"); |
diff --git a/web/static/chat.js b/web/static/chat.js| index 4f66b81..b54ed23 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -29,7 +29,7 @@ import { |
| colorScheme, |
| composerToken, |
| } from "/static/ui.js"; |
| -import { richTokens, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount } from "/static/format.js"; |
| +import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| import { MESSAGE_TYPE_TYPING } from "/static/message.js"; |
| import { qrCanvas } from "/static/qrcode.js"; |
| @@ -1401,7 +1401,7 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache |
| bubble.className = jumbo ? "bubble jumbo-emoji" : "bubble"; |
| renderText(bubble, data.text); |
| content.appendChild(bubble); |
| - const link = (richTokens(data.text).find((t) => t.href) || {}).href; |
| + const link = firstLink(data.text); |
| if (link) addLinkPreview(content, link); |
| } |
| // Reply/edit affordances: desktop hover icons (reply on any message; edit on |
| @@ -4274,7 +4274,7 @@ function openMentionMenu(entry, anchor) { |
| setTimeout(() => document.addEventListener("pointerdown", close, true), 0); |
| } |
| -function renderText(el, text) { |
| +function renderInline(el, text) { |
| for (const t of richTokens(text)) { |
| if (t.href) { |
| const a = document.createElement("a"); |
| @@ -4293,6 +4293,26 @@ function renderText(el, text) { |
| } |
| } |
| +// Block structure first (``` fences, > quotes), then inline formatting inside |
| +// everything except code, which renders verbatim. Still no innerHTML anywhere. |
| +function renderText(el, text) { |
| + for (const b of blockTokens(text)) { |
| + if (b.type === "code") { |
| + const pre = document.createElement("pre"); |
| + const code = document.createElement("code"); |
| + code.textContent = b.text; |
| + pre.appendChild(code); |
| + el.appendChild(pre); |
| + } else if (b.type === "quote") { |
| + const q = document.createElement("blockquote"); |
| + renderInline(q, b.text); |
| + el.appendChild(q); |
| + } else { |
| + renderInline(el, b.text); |
| + } |
| + } |
| +} |
| + |
| // A centered modal sheet. Click the backdrop or Esc to dismiss. |
| function modal() { |
| const prevFocus = document.activeElement; // restored when the sheet closes |
diff --git a/web/static/format.js b/web/static/format.js| index 31e3488..06867ae 100644 |
| --- a/web/static/format.js |
| +++ b/web/static/format.js |
| @@ -107,6 +107,76 @@ export function richTokens(text) { |
| return tokens.filter((t) => t.text !== "" || t.href); |
| } |
| +// Block-level structure, parsed BEFORE inline formatting so markers inside a |
| +// fence stay verbatim. Splits a message into ordered blocks: |
| +// { type: "p", text } → normal text (linkify + inline-format) |
| +// { type: "code", text, lang } → ``` fenced block — render verbatim |
| +// { type: "quote", text } → run of "> " lines, markers stripped |
| +// (still linkified + inline-formatted) |
| +// Blank lines butting against a code/quote block are dropped — the blocks |
| +// carry their own spacing, so the delimiter newlines shouldn't render too. |
| +// Pure + flat so chat.js builds <pre>/<blockquote> wrappers with no innerHTML. |
| +const FENCE_RE = /^\s*```(.*)$/; |
| +const QUOTE_RE = /^\s*> ?(.*)$/; |
| + |
| +export function blockTokens(text) { |
| + text = text == null ? "" : String(text); |
| + const lines = text.split("\n"); |
| + // Fast path: no block markers anywhere — one plain block, text untouched. |
| + if (!lines.some((l) => FENCE_RE.test(l) || QUOTE_RE.test(l))) { |
| + return [{ type: "p", text }]; |
| + } |
| + const blocks = []; |
| + let plain = []; |
| + const flush = (beforeBlock) => { |
| + // Trim blank lines that only exist to separate text from a block. |
| + if (blocks.length) while (plain.length && plain[0].trim() === "") plain.shift(); |
| + if (beforeBlock) while (plain.length && plain[plain.length - 1].trim() === "") plain.pop(); |
| + if (plain.length) blocks.push({ type: "p", text: plain.join("\n") }); |
| + plain = []; |
| + }; |
| + let i = 0; |
| + while (i < lines.length) { |
| + const fence = lines[i].match(FENCE_RE); |
| + // An opening fence's info string never contains a backtick — that's an |
| + // inline-code line (e.g. "```js``` opens a fence"), not a block. |
| + if (fence && !fence[1].includes("`")) { |
| + flush(true); |
| + const body = []; |
| + i++; |
| + while (i < lines.length && lines[i].trim() !== "```") body.push(lines[i++]); |
| + i++; // skip the closing fence; an unterminated fence runs to the end |
| + blocks.push({ type: "code", text: body.join("\n"), lang: fence[1].trim() }); |
| + continue; |
| + } |
| + const quote = lines[i].match(QUOTE_RE); |
| + if (quote) { |
| + flush(true); |
| + const body = [quote[1]]; |
| + i++; |
| + for (let q; i < lines.length && (q = lines[i].match(QUOTE_RE)); i++) body.push(q[1]); |
| + blocks.push({ type: "quote", text: body.join("\n") }); |
| + continue; |
| + } |
| + plain.push(lines[i++]); |
| + } |
| + flush(false); |
| + if (blocks.length === 0) blocks.push({ type: "p", text: "" }); |
| + return blocks; |
| +} |
| + |
| +// The first http(s) link in a message, skipping fenced code blocks (a URL |
| +// pasted as code shouldn't sprout a preview card). Used by chat.js to decide |
| +// which URL, if any, gets an OpenGraph unfurl. |
| +export function firstLink(text) { |
| + for (const b of blockTokens(text)) { |
| + if (b.type === "code") continue; |
| + const hit = tokenizeText(b.text).find((t) => t.href); |
| + if (hit) return hit.href; |
| + } |
| + return null; |
| +} |
| + |
| // Build a short, plain-text preview of a message for a reply quote. Collapses |
| // whitespace and truncates long text; when a message has no text (e.g. a photo |
| // only), falls back to a label for its first attachment so the quote still says |
diff --git a/web/static/style.css b/web/static/style.css| index 27e0abd..7ee0426 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -335,6 +335,33 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| border-radius: 5px; |
| } |
| .msg.me .bubble code { background: color-mix(in srgb, var(--bubble-me-ink) 22%, transparent); } |
| +/* ``` fenced code block — the <code> inside inherits the font rules above but |
| + drops the inline chip background; long lines scroll rather than wrap */ |
| +.msg .bubble pre { |
| + margin: 6px 0; |
| + padding: 8px 10px; |
| + border-radius: 8px; |
| + background: color-mix(in srgb, var(--ink) 8%, transparent); |
| + overflow-x: auto; |
| + white-space: pre; |
| + line-height: 1.4; |
| +} |
| +.msg .bubble pre code { background: none; padding: 0; } |
| +.msg.me .bubble pre { background: color-mix(in srgb, var(--bubble-me-ink) 22%, transparent); } |
| +/* "> " blockquote — quiet left rule, slightly dimmed text */ |
| +.msg .bubble blockquote { |
| + margin: 6px 0; |
| + padding: 1px 0 1px 10px; |
| + border-left: 3px solid color-mix(in srgb, var(--ink) 30%, transparent); |
| + color: color-mix(in srgb, var(--ink) 72%, transparent); |
| +} |
| +.msg.me .bubble blockquote { |
| + border-left-color: color-mix(in srgb, var(--bubble-me-ink) 55%, transparent); |
| + color: color-mix(in srgb, var(--bubble-me-ink) 85%, transparent); |
| +} |
| +/* blocks flush with the bubble's own padding at the edges */ |
| +.msg .bubble pre:first-child, .msg .bubble blockquote:first-child { margin-top: 0; } |
| +.msg .bubble pre:last-child, .msg .bubble blockquote:last-child { margin-bottom: 0; } |
| .msg.me { align-self: flex-end; align-items: flex-end; } |
| .msg.me .bubble { background: var(--bubble-me); color: var(--bubble-me-ink); border-bottom-right-radius: 6px; } |
| .msg.them .bubble { border-bottom-left-radius: 6px; } |