eleven / messenger Public

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>
James Adam 91b126241375c8f6670536452895b6f210a81d2f parent 24ec129
4 files changed, +184 −4
  • test/format.test.mjs +64 −1
  • web/static/chat.js +23 −3
  • web/static/format.js +70 −0
  • web/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; }