Skip to content

Commit

Chat messages render the Markdown people and agents write: headings as bold lines, rules, nested and numbered lists, quotes that hold lists, single-tilde strikethrough, backslash escapes, and fenced code coloured by the site's highlighter with a copy button, while links still go only to the web, mail or this site and HTML stays text. The parser now lives in @g1t/contracts/chat-markdown so the chat service reads messages by the same rule, and notifications show a message's plain words with the marks gone, keeping snake_case, #channels and references as typed.

syntaqxcommitted Parent6c97c24Browse files
7 files+648−1570/7 viewed
+105−9
1−import { Fragment, type ReactNode, useMemo } from "react";
1+import { Check, Copy } from "lucide-react";
2+import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
23 import { Link } from "react-router";
34
45 import { MemberCard } from "./profile-card";
6+import { Hint } from "../ui/hint";
57 import { type Block, type Span, blocks, onlyEmoji } from "../../lib/chat";
68 // The workspace's own emoji, drawn where `:name:` is written (components/emoji).
79 import { useEmojiContext } from "../emoji/context";
114116 ));
115117 }
116118
119+/** A run of blocks, a little apart. */
120+function BlocksView({ list, context, className = "space-y-1.5" }: { list: Block[]; context: TextContext; className?: string }) {
121+ return (
122+ <div className={className}>
123+ {list.map((block, index) => (
124+ <BlockView key={index} block={block} context={context} />
125+ ))}
126+ </div>
127+ );
128+}
129+
130+function ListItem({ item, context }: { item: Block[]; context: TextContext }) {
131+ // An item of one paragraph is its lines; one with more holds its blocks.
132+ if (item.length === 1 && item[0]!.t === "p") return <li className="pl-0.5">{lines(item[0]!.lines, context)}</li>;
133+ return (
134+ <li className="pl-0.5">
135+ <BlocksView list={item} context={context} className="space-y-1" />
136+ </li>
137+ );
138+}
139+
117140 function BlockView({ block, context }: { block: Block; context: TextContext }) {
118141 switch (block.t) {
119142 case "p":
120143 return <p>{lines(block.lines, context)}</p>;
144+ case "heading":
145+ // Chat, not a document: a heading is a bold line, the top two a touch larger.
146+ return <p className={`font-semibold text-fg ${block.level <= 2 ? "text-base" : ""}`}>{spans(block.c, context)}</p>;
147+ case "hr":
148+ return <hr className="my-2 border-line" />;
121149 case "code":
122− return (
123− <pre className="my-1 overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 font-mono text-[0.8125rem] leading-relaxed text-fg-soft [scrollbar-width:thin]">
124− <code>{block.v}</code>
125− </pre>
126− );
150+ return <CodeBlock language={block.lang} code={block.v} />;
127151 case "list": {
128152 const Tag = block.ordered ? "ol" : "ul";
129153 return (
130− <Tag className={`my-0.5 space-y-0.5 pl-5 ${block.ordered ? "list-decimal" : "list-disc"} marker:text-faint`}>
154+ <Tag start={block.ordered && block.start !== 1 ? block.start : undefined} className={`space-y-0.5 pl-5 ${block.ordered ? "list-decimal" : "list-disc"} marker:text-faint`}>
131155 {block.items.map((item, index) => (
132− <li key={index}>{spans(item, context)}</li>
156+ <ListItem key={index} item={item} context={context} />
133157 ))}
134158 </Tag>
135159 );
136160 }
137161 case "quote":
138− return <blockquote className="border-l-2 border-line-strong pl-3 text-muted">{lines(block.lines, context)}</blockquote>;
162+ return (
163+ <blockquote className="border-l-2 border-line-strong pl-3 text-muted">
164+ <BlocksView list={block.c} context={context} className="space-y-1" />
165+ </blockquote>
166+ );
139167 }
140168 }
141169
170+type Token = { content: string; color?: string };
171+
172+/**
173+ * Fenced code: plain at once, coloured once the highlighter has loaded
174+ * when its language is one g1t knows (lib/shiki.ts), with a copy button.
175+ * The colours are tokens drawn as text, never HTML.
176+ */
177+function CodeBlock({ language, code }: { language: string | null; code: string }) {
178+ const [rows, setRows] = useState<Token[][] | null>(null);
179+ const [copied, setCopied] = useState(false);
180+ useEffect(() => {
181+ setRows(null);
182+ if (!language || code.length > 20_000) return;
183+ let cancelled = false;
184+ void import("../../lib/shiki")
185+ .then(async ({ THEME, getHighlighter, languageNamed }) => {
186+ const lang = languageNamed(language);
187+ if (!lang) return null;
188+ const core = await getHighlighter();
189+ return core.codeToTokens(code, { lang, theme: THEME }).tokens.map((row) => row.map((token) => ({ content: token.content, color: token.color })));
190+ })
191+ .then((tokens) => {
192+ if (!cancelled && tokens) setRows(tokens);
193+ })
194+ .catch(() => {});
195+ return () => {
196+ cancelled = true;
197+ };
198+ }, [language, code]);
199+ return (
200+ <div className="group/code relative my-1">
201+ <pre className="overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 font-mono text-[0.8125rem] leading-relaxed text-fg-soft [scrollbar-width:thin]">
202+ <code>
203+ {rows
204+ ? rows.map((row, index) => (
205+ <span key={index} className="block min-h-lh">
206+ {row.map((token, at) => (
207+ <span key={at} style={token.color ? { color: token.color } : undefined}>
208+ {token.content}
209+ </span>
210+ ))}
211+ </span>
212+ ))
213+ : code}
214+ </code>
215+ </pre>
216+ <div className="absolute top-1.5 right-1.5 flex items-center gap-2 opacity-0 transition-opacity group-hover/code:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100">
217+ {language && <span className="font-mono text-[0.6875rem] text-faint">{language}</span>}
218+ <Hint label={copied ? "Copied" : "Copy code"}>
219+ <button
220+ type="button"
221+ aria-label="Copy code"
222+ onClick={() => {
223+ void navigator.clipboard?.writeText(code).then(() => {
224+ setCopied(true);
225+ setTimeout(() => setCopied(false), 1500);
226+ });
227+ }}
228+ className="flex size-6 items-center justify-center rounded-md border border-line bg-raised text-muted hover:text-fg"
229+ >
230+ {copied ? <Check size={12} className="text-success" /> : <Copy size={12} />}
231+ </button>
232+ </Hint>
233+ </div>
234+ </div>
235+ );
236+}
237+
142238 /** A message's text, rendered from parsed blocks: never as HTML. */
143239 export function MessageText({ body, context }: { body: string; context: TextContext }) {
144240 const parsed = useMemo(() => blocks(body), [body]);
+2−136
134134 }
135135
136136 // ---------------------------------------------------------------------------
137−// A message's text: a little Markdown, parsed into blocks and spans.
138−
139−export type Span =
140− | { t: "text"; v: string }
141− | { t: "code"; v: string }
142− | { t: "strong"; c: Span[] }
143− | { t: "em"; c: Span[] }
144− | { t: "del"; c: Span[] }
145− | { t: "link"; href: string; c: Span[] }
146− | { t: "mention"; name: string }
147− | { t: "channel"; name: string }
148− | { t: "ref"; repo: string | null; number: number };
149−
150−export type Block =
151− | { t: "p"; lines: Span[][] }
152− | { t: "code"; lang: string | null; v: string }
153− | { t: "list"; ordered: boolean; items: Span[][] }
154− | { t: "quote"; lines: Span[][] };
155−
156−/** Where a link may go: the web, mail, or a page on this site. */
157−export function safeHref(href: string): string | null {
158− const trimmed = href.trim();
159− if (/^https?:\/\/[^\s]+$/i.test(trimmed)) return trimmed;
160− if (/^mailto:[^\s]+$/i.test(trimmed)) return trimmed;
161− if (/^\/(?!\/)[^\s]*$/.test(trimmed)) return trimmed;
162− return null;
163−}
164−
165−const INLINE = new RegExp(
166− [
167− "`([^`\\n]+)`", // 1 code
168− "\\*\\*([^*\\n](?:[^\\n]*?[^*\\n])?)\\*\\*", // 2 strong
169− "~~([^~\\n]+)~~", // 3 del
170− "(?<![\\w*])\\*([^*\\s](?:[^*\\n]*[^*\\s])?)\\*(?![\\w*])", // 4 em with *
171− "(?<![\\w])_([^_\\s](?:[^_\\n]*[^_\\s])?)_(?![\\w])", // 5 em with _
172− "\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)", // 6 text, 7 href
173− "(https?:\\/\\/[^\\s<>()]+[^\\s<>().,;:!?'\"])", // 8 bare link
174− "(?<![\\w/@])@([A-Za-z0-9][\\w.-]*[A-Za-z0-9_]|[A-Za-z0-9])(?:\\/([A-Za-z0-9][\\w.-]*))?", // 9 mention, 10 team
175− "(?<![\\w/])([A-Za-z0-9][\\w.-]*(?:\\/[A-Za-z0-9][\\w.-]*)?)#(\\d+)\\b", // 11 repo, 12 number
176− "(?<![\\w&#/])#(\\d+)\\b", // 13 number alone
177− "(?<![\\w&#/])#([a-z0-9][a-z0-9_-]*)", // 14 channel
178− ].join("|"),
179− "g",
180−);
137+// A message's text: its Markdown, parsed by the rule every part of g1t reads it with.
181138
182−/** One line of text as spans. */
183−export function inline(text: string): Span[] {
184− const spans: Span[] = [];
185− let at = 0;
186− const push = (span: Span) => {
187− const last = spans[spans.length - 1];
188− if (span.t === "text" && last?.t === "text") last.v += span.v;
189− else spans.push(span);
190− };
191− // Its own copy: the spans inside bold and the like are parsed on the way.
192− const pattern = new RegExp(INLINE.source, "g");
193− for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
194− if (m.index > at) push({ t: "text", v: text.slice(at, m.index) });
195− at = m.index + m[0].length;
196− if (m[1] != null) push({ t: "code", v: m[1] });
197− else if (m[2] != null) push({ t: "strong", c: inline(m[2]) });
198− else if (m[3] != null) push({ t: "del", c: inline(m[3]) });
199− else if (m[4] != null) push({ t: "em", c: inline(m[4]) });
200− else if (m[5] != null) push({ t: "em", c: inline(m[5]) });
201− else if (m[6] != null) {
202− const href = safeHref(m[7]!);
203− if (href) push({ t: "link", href, c: inline(m[6]) });
204− else push({ t: "text", v: m[0] });
205− } else if (m[8] != null) push({ t: "link", href: m[8], c: [{ t: "text", v: m[8] }] });
206− else if (m[9] != null) push({ t: "mention", name: m[10] ? `${m[9]}/${m[10]}` : m[9] });
207− else if (m[11] != null) push({ t: "ref", repo: m[11], number: Number(m[12]) });
208− else if (m[13] != null) push({ t: "ref", repo: null, number: Number(m[13]) });
209− else if (m[14] != null) push({ t: "channel", name: m[14] });
210− }
211− if (at < text.length) push({ t: "text", v: text.slice(at) });
212− return spans;
213−}
214−
215−/** A message's whole text as blocks: paragraphs, fenced code, lists and quotes. */
216−export function blocks(text: string): Block[] {
217− const out: Block[] = [];
218− const lines = text.replace(/\r\n?/g, "\n").split("\n");
219− let i = 0;
220− let paragraph: Span[][] = [];
221− const flush = () => {
222− // Blank lines at either end of a paragraph say nothing.
223− while (paragraph.length && paragraph[0]!.length === 0) paragraph.shift();
224− while (paragraph.length && paragraph[paragraph.length - 1]!.length === 0) paragraph.pop();
225− if (paragraph.length) out.push({ t: "p", lines: paragraph });
226− paragraph = [];
227− };
228− while (i < lines.length) {
229− const line = lines[i]!;
230− const fence = /^\s*```\s*([\w+#.-]*)\s*$/.exec(line);
231− if (fence) {
232− flush();
233− const body: string[] = [];
234− i++;
235− while (i < lines.length && !/^\s*```\s*$/.test(lines[i]!)) body.push(lines[i++]!);
236− i++; // the closing fence, or the end
237− out.push({ t: "code", lang: fence[1] || null, v: body.join("\n") });
238− continue;
239− }
240− const bullet = /^\s*[-*•]\s+(.*)$/.exec(line);
241− const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
242− if (bullet || numbered) {
243− flush();
244− const ordered = !bullet;
245− const items: Span[][] = [];
246− while (i < lines.length) {
247− const item = ordered ? /^\s*\d+[.)]\s+(.*)$/.exec(lines[i]!) : /^\s*[-*•]\s+(.*)$/.exec(lines[i]!);
248− if (!item) break;
249− items.push(inline(item[1]!));
250− i++;
251− }
252− out.push({ t: "list", ordered, items });
253− continue;
254− }
255− if (/^\s*>\s?/.test(line)) {
256− flush();
257− const quoted: Span[][] = [];
258− while (i < lines.length && /^\s*>\s?/.test(lines[i]!)) quoted.push(inline(lines[i++]!.replace(/^\s*>\s?/, "")));
259− out.push({ t: "quote", lines: quoted });
260− continue;
261− }
262− if (line.trim() === "") {
263− // A blank line ends a paragraph.
264− flush();
265− i++;
266− continue;
267− }
268− paragraph.push(inline(line));
269− i++;
270− }
271− flush();
272− return out;
273−}
139+export { type Block, type Span, blocks, inline, plainText, safeHref, spansText } from "@g1t/contracts/chat-markdown";
274140
275141 /** Whether a message is only emoji, a few of them: shown larger, as people expect. */
276142 export function onlyEmoji(text: string): boolean {
+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./chat-markdown": "./src/chat-markdown.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+175−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { type Block, type Span, blocks, inline, plainText, safeHref } from "./chat-markdown.ts";
5+
6+/** Every link anywhere in parsed blocks. */
7+function links(list: Block[]): string[] {
8+ const out: string[] = [];
9+ const walk = (spans: Span[]) => {
10+ for (const span of spans) {
11+ if (span.t === "link") out.push(span.href);
12+ if ("c" in span) walk(span.c);
13+ }
14+ };
15+ const each = (block: Block) => {
16+ if (block.t === "p") block.lines.forEach(walk);
17+ else if (block.t === "heading") walk(block.c);
18+ else if (block.t === "quote") block.c.forEach(each);
19+ else if (block.t === "list") block.items.forEach((item) => item.forEach(each));
20+ };
21+ list.forEach(each);
22+ return out;
23+}
24+
25+test("formatting: bold, italic, strikethrough and code, as people and agents write them", () => {
26+ assert.deepEqual(inline("**bold** __also__ *it* _it_ ~~gone~~ ~gone~ `x`"), [
27+ { t: "strong", c: [{ t: "text", v: "bold" }] },
28+ { t: "text", v: " " },
29+ { t: "strong", c: [{ t: "text", v: "also" }] },
30+ { t: "text", v: " " },
31+ { t: "em", c: [{ t: "text", v: "it" }] },
32+ { t: "text", v: " " },
33+ { t: "em", c: [{ t: "text", v: "it" }] },
34+ { t: "text", v: " " },
35+ { t: "del", c: [{ t: "text", v: "gone" }] },
36+ { t: "text", v: " " },
37+ { t: "del", c: [{ t: "text", v: "gone" }] },
38+ { t: "text", v: " " },
39+ { t: "code", v: "x" },
40+ ]);
41+ assert.deepEqual(inline("**_both_**"), [{ t: "strong", c: [{ t: "em", c: [{ t: "text", v: "both" }] }] }]);
42+ assert.deepEqual(inline("``a ` b``"), [{ t: "code", v: "a ` b" }]);
43+});
44+
45+test("words that only look like formatting stay words", () => {
46+ assert.deepEqual(inline("snake_case_name and 2*3*4"), [{ t: "text", v: "snake_case_name and 2*3*4" }]);
47+ assert.deepEqual(inline("about ~5 min, ~/src"), [{ t: "text", v: "about ~5 min, ~/src" }]);
48+ assert.deepEqual(inline("\\*not\\* \\_em\\_ \\`code\\` \\@nobody"), [{ t: "text", v: "*not* _em_ `code` @nobody" }]);
49+ assert.deepEqual(inline("C:\\Users\\me"), [{ t: "text", v: "C:\\Users\\me" }]);
50+});
51+
52+test("mentions, channels and references; never inside code or a link's text", () => {
53+ assert.deepEqual(inline("ask @reviewer about #12, g1t#3 in #web"), [
54+ { t: "text", v: "ask " },
55+ { t: "mention", name: "reviewer" },
56+ { t: "text", v: " about " },
57+ { t: "ref", repo: null, number: 12 },
58+ { t: "text", v: ", " },
59+ { t: "ref", repo: "g1t", number: 3 },
60+ { t: "text", v: " in " },
61+ { t: "channel", name: "web" },
62+ ]);
63+ assert.deepEqual(inline("`@reviewer #12`"), [{ t: "code", v: "@reviewer #12" }]);
64+ assert.deepEqual(inline("[ping @ana](https://x.example)"), [{ t: "link", href: "https://x.example", c: [{ t: "text", v: "ping @ana" }] }]);
65+ assert.deepEqual(inline("me@example.com"), [{ t: "text", v: "me@example.com" }]);
66+});
67+
68+test("links go only to the web, mail or this site", () => {
69+ assert.deepEqual(inline("[docs](https://g1t.sh/docs) and https://g1t.sh/a."), [
70+ { t: "link", href: "https://g1t.sh/docs", c: [{ t: "text", v: "docs" }] },
71+ { t: "text", v: " and " },
72+ { t: "link", href: "https://g1t.sh/a", c: [{ t: "text", v: "https://g1t.sh/a" }] },
73+ { t: "text", v: "." },
74+ ]);
75+ assert.deepEqual(inline("<https://g1t.sh>"), [{ t: "link", href: "https://g1t.sh", c: [{ t: "text", v: "https://g1t.sh" }] }]);
76+ assert.deepEqual(inline("https://en.wikipedia.org/wiki/Foo_(bar)"), [
77+ { t: "link", href: "https://en.wikipedia.org/wiki/Foo_(bar)", c: [{ t: "text", v: "https://en.wikipedia.org/wiki/Foo_(bar)" }] },
78+ ]);
79+ assert.equal(safeHref("/acme/web/pull/3"), "/acme/web/pull/3");
80+ assert.equal(safeHref("mailto:me@example.com"), "mailto:me@example.com");
81+});
82+
83+test("XSS: script links, raw HTML and nested tricks are text", () => {
84+ const evil = [
85+ "[x](javascript:alert(1))",
86+ "[x](JaVaScRiPt:alert(1))",
87+ "[x]( javascript:alert(1))",
88+ "[x](java\tscript:alert(1))",
89+ "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)",
90+ "[x](vbscript:msgbox(1))",
91+ "[x](//evil.example)",
92+ "[x](/\\evil.example)",
93+ "[x](<javascript:alert(1)>)",
94+ "![x](javascript:alert(1))",
95+ "<javascript:alert(1)>",
96+ "[**[x](javascript:alert(1))**](javascript:alert(2))",
97+ "[a](https://ok.example)[b](javascript:alert(1))",
98+ "javascript:alert(1)",
99+ ];
100+ for (const text of evil) {
101+ for (const href of links(blocks(text))) assert.ok(/^(https?:\/\/|mailto:|\/(?![/\\]))/i.test(href), `${text} linked to ${href}`);
102+ }
103+ assert.equal(safeHref("javascript:alert(1)"), null);
104+ assert.equal(safeHref("//evil.example"), null);
105+ assert.equal(safeHref("/\\evil.example"), null);
106+ assert.equal(safeHref("https://ok.example/\u0000x"), null);
107+ assert.equal(safeHref("https://ok.example\\@evil.example"), null);
108+
109+ // HTML is never markup: it comes through as the text it is.
110+ const html = blocks('<img src=x onerror="alert(1)"><script>alert(1)</script>\n<a href="javascript:alert(1)">x</a>');
111+ assert.deepEqual(html, [
112+ {
113+ t: "p",
114+ lines: [[{ t: "text", v: '<img src=x onerror="alert(1)"><script>alert(1)</script>' }], [{ t: "text", v: '<a href="javascript:alert(1)">x</a>' }]],
115+ },
116+ ]);
117+ // An image is a link to it, never an image.
118+ assert.deepEqual(inline("![logo](https://x.example/a.png)"), [{ t: "link", href: "https://x.example/a.png", c: [{ t: "text", v: "logo" }] }]);
119+});
120+
121+test("blocks: paragraphs keep their line breaks; code, lists, quotes, headings and rules", () => {
122+ const parsed = blocks("Plan:\n- one\n- two\n\n```ts\nconst a = 1;\n```\n> quoted\nlast");
123+ assert.deepEqual(
124+ parsed.map((block) => block.t),
125+ ["p", "list", "code", "quote", "p"],
126+ );
127+ assert.deepEqual(parsed[2], { t: "code", lang: "ts", v: "const a = 1;" });
128+ assert.deepEqual(blocks("a\nb"), [{ t: "p", lines: [[{ t: "text", v: "a" }], [{ t: "text", v: "b" }]] }]);
129+ assert.deepEqual(blocks("## Summary\n---\n#general"), [
130+ { t: "heading", level: 2, c: [{ t: "text", v: "Summary" }] },
131+ { t: "hr" },
132+ { t: "p", lines: [[{ t: "channel", name: "general" }]] },
133+ ]);
134+ // A fence inside a fence, with a longer fence around it.
135+ assert.deepEqual(blocks("````md\n```\ninner\n```\n````"), [{ t: "code", lang: "md", v: "```\ninner\n```" }]);
136+ // An unclosed fence runs to the end.
137+ assert.deepEqual(blocks("```\nopen"), [{ t: "code", lang: null, v: "open" }]);
138+});
139+
140+test("lists: numbered from where they start, nested by indenting, items with more than a line", () => {
141+ const [list] = blocks("3. three\n4. four\n - deeper\n more\n5. five");
142+ assert.equal(list!.t, "list");
143+ if (list!.t !== "list") return;
144+ assert.equal(list.ordered, true);
145+ assert.equal(list.start, 3);
146+ assert.equal(list.items.length, 3);
147+ const nested = list.items[1]![1]!;
148+ assert.equal(nested.t, "list");
149+ if (nested.t === "list") assert.deepEqual(nested.items[0], [{ t: "p", lines: [[{ t: "text", v: "deeper" }], [{ t: "text", v: "more" }]] }]);
150+ // A year at the start of a line is not a list.
151+ assert.deepEqual(blocks("We grew in\n2019. Then again.").map((b) => b.t), ["p"]);
152+ // Blank lines between items keep one list.
153+ assert.equal(blocks("- a\n\n- b").length, 1);
154+ // A quote holds blocks of its own.
155+ const [quote] = blocks("> - a\n> - b");
156+ assert.ok(quote!.t === "quote" && quote.c[0]!.t === "list");
157+});
158+
159+test("deep nesting is bounded", () => {
160+ const deep = blocks(">".repeat(200) + " x");
161+ assert.equal(deep.length, 1);
162+ const list = blocks(Array.from({ length: 50 }, (_, i) => `${" ".repeat(i)}- ${i}`).join("\n"));
163+ assert.equal(list.length, 1);
164+});
165+
166+test("plain text: the words without the marks, for previews", () => {
167+ assert.equal(plainText("**bold** and `code`\n\nnext [link](https://x.example)"), "bold and code next link");
168+ assert.equal(plainText("## Release\n- one\n- **two**\n\n> said\n---\n1. first"), "Release one two said 1. first");
169+ assert.equal(plainText("ping @ana in #web about g1t#3, snake_case_name"), "ping @ana in #web about g1t#3, snake_case_name");
170+ assert.equal(plainText("\\*literal\\* ~~gone~~ ![logo](https://x.example/a.png)"), "*literal* gone logo");
171+ assert.equal(plainText("```ts\nconst a = 1;\n```"), "const a = 1;");
172+ assert.equal(plainText("<b>hi</b>"), "<b>hi</b>");
173+ assert.equal(plainText("x".repeat(300), 140).length, 140);
174+ assert.ok(plainText("x".repeat(300), 140).endsWith("…"));
175+});
+358−0
1+/**
2+ * A chat message's Markdown, parsed into a small tree that React renders
3+ * as text (components/chat/text.tsx). Nothing in a message is ever HTML:
4+ * `<b>` is shown as typed, a link goes only to the web, mail or a page on
5+ * this site, and an image is shown as a link to it. Pure, so it is tested
6+ * on its own (chat-markdown.test.ts).
7+ *
8+ * The dialect is what people and agents write in chat: bold, italic,
9+ * strikethrough (`~~x~~` or `~x~`), inline and fenced code, links and bare
10+ * URLs, lists (nested by indenting), quotes, headings and rules, plus what
11+ * g1t adds: `@mentions`, `#channels` and `#123` references. A line break
12+ * is kept where it was typed.
13+ */
14+
15+export type Span =
16+ | { t: "text"; v: string }
17+ | { t: "code"; v: string }
18+ | { t: "strong"; c: Span[] }
19+ | { t: "em"; c: Span[] }
20+ | { t: "del"; c: Span[] }
21+ | { t: "link"; href: string; c: Span[] }
22+ | { t: "mention"; name: string }
23+ | { t: "channel"; name: string }
24+ | { t: "ref"; repo: string | null; number: number };
25+
26+export type Block =
27+ | { t: "p"; lines: Span[][] }
28+ | { t: "heading"; level: number; c: Span[] }
29+ | { t: "code"; lang: string | null; v: string }
30+ | { t: "list"; ordered: boolean; start: number; items: Block[][] }
31+ | { t: "quote"; c: Block[] }
32+ | { t: "hr" };
33+
34+/** Where a link may go: the web, mail, or a page on this site. */
35+export function safeHref(href: string): string | null {
36+ const trimmed = href.trim();
37+ // Control characters and spaces never belong in a link someone can follow.
38+ // eslint-disable-next-line no-control-regex
39+ if (/[\u0000-\u0020\u007f]/.test(trimmed)) return null;
40+ if (/^https?:\/\/[^\s\\]+$/i.test(trimmed)) return trimmed;
41+ if (/^mailto:[^\s/\\]+$/i.test(trimmed)) return trimmed;
42+ // A path on this site; `//host` and `/\host` are other sites to a browser.
43+ if (/^\/(?![/\\])[^\s\\]*$/.test(trimmed)) return trimmed;
44+ return null;
45+}
46+
47+/** The ASCII punctuation a backslash can escape. */
48+const ESCAPABLE = "\\\\`*_{}\\[\\]()#+\\-.!~>|<=:@";
49+
50+const INLINE = new RegExp(
51+ [
52+ `\\\\([${ESCAPABLE}])`, // 1 an escaped character
53+ "``\\s?([^\\n]+?)\\s?``(?!`)", // 2 code with two backticks
54+ "`([^`\\n]+)`", // 3 code
55+ "\\*\\*([^*\\s](?:[^\\n]*?[^*\\s])?)\\*\\*", // 4 strong
56+ "(?<![\\w_])__([^_\\s](?:[^\\n]*?[^_\\s])?)__(?![\\w_])", // 5 strong with _
57+ "~~([^~\\n]+)~~", // 6 del
58+ "(?<![\\w~])~([^~\\s](?:[^~\\n]*[^~\\s])?)~(?![\\w~])", // 7 del with one ~
59+ "(?<![\\w*])\\*([^*\\s](?:[^*\\n]*[^*\\s])?)\\*(?![\\w*])", // 8 em with *
60+ "(?<![\\w_])_([^_\\s](?:[^_\\n]*[^_\\s])?)_(?![\\w_])", // 9 em with _
61+ "!\\[([^\\]\\n]{0,1000})\\]\\(<?([^)\\s>]{1,2048})>?(?:\\s+\"[^\"\\n]{0,1000}\")?\\)", // 10 alt, 11 src: an image, shown as a link
62+ "\\[([^\\]\\n]{1,1000})\\]\\(<?([^)\\s>]{1,2048})>?(?:\\s+\"[^\"\\n]{0,1000}\")?\\)", // 12 text, 13 href
63+ "<(https?:\\/\\/[^\\s<>]+|mailto:[^\\s<>]+)>", // 14 <autolink>
64+ "(https?:\\/\\/(?:[^\\s<>()]|\\([^\\s<>()]*\\))*(?:[^\\s<>().,;:!?'\"*_~]|\\([^\\s<>()]*\\)))", // 15 bare link
65+ "(?<![\\w/@])@([A-Za-z0-9][\\w.-]*[A-Za-z0-9_]|[A-Za-z0-9])(?:\\/([A-Za-z0-9][\\w.-]*))?", // 16 mention, 17 team
66+ "(?<![\\w/])([A-Za-z0-9][\\w.-]*(?:\\/[A-Za-z0-9][\\w.-]*)?)#(\\d+)\\b", // 18 repo, 19 number
67+ "(?<![\\w&#/])#(\\d+)\\b", // 20 number alone
68+ "(?<![\\w&#/])#([a-z0-9][a-z0-9_-]*)", // 21 channel
69+ ].join("|"),
70+ "g",
71+);
72+
73+/** One line of text as spans. */
74+export function inline(text: string): Span[] {
75+ const spans: Span[] = [];
76+ let at = 0;
77+ const push = (span: Span) => {
78+ const last = spans[spans.length - 1];
79+ if (span.t === "text" && last?.t === "text") last.v += span.v;
80+ else spans.push(span);
81+ };
82+ const link = (href: string, inner: Span[], raw: string) => {
83+ const safe = safeHref(href);
84+ if (safe) push({ t: "link", href: safe, c: inner });
85+ else push({ t: "text", v: raw });
86+ };
87+ // Its own copy: the spans inside bold and the like are parsed on the way.
88+ const pattern = new RegExp(INLINE.source, "g");
89+ for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
90+ if (m.index > at) push({ t: "text", v: text.slice(at, m.index) });
91+ at = m.index + m[0].length;
92+ if (m[1] != null) push({ t: "text", v: m[1] });
93+ else if (m[2] != null) push({ t: "code", v: m[2] });
94+ else if (m[3] != null) push({ t: "code", v: m[3] });
95+ else if (m[4] != null) push({ t: "strong", c: inline(m[4]) });
96+ else if (m[5] != null) push({ t: "strong", c: inline(m[5]) });
97+ else if (m[6] != null) push({ t: "del", c: inline(m[6]) });
98+ else if (m[7] != null) push({ t: "del", c: inline(m[7]) });
99+ else if (m[8] != null) push({ t: "em", c: inline(m[8]) });
100+ else if (m[9] != null) push({ t: "em", c: inline(m[9]) });
101+ else if (m[11] != null) link(m[11], [{ t: "text", v: m[10] || m[11] }], m[0]);
102+ else if (m[13] != null) link(m[13], plainSpans(inline(m[12]!)), m[0]);
103+ else if (m[14] != null) link(m[14], [{ t: "text", v: m[14] }], m[0]);
104+ else if (m[15] != null) push({ t: "link", href: m[15], c: [{ t: "text", v: m[15] }] });
105+ else if (m[16] != null) push({ t: "mention", name: m[17] ? `${m[16]}/${m[17]}` : m[16] });
106+ else if (m[18] != null) push({ t: "ref", repo: m[18], number: Number(m[19]) });
107+ else if (m[20] != null) push({ t: "ref", repo: null, number: Number(m[20]) });
108+ else if (m[21] != null) push({ t: "channel", name: m[21] });
109+ }
110+ if (at < text.length) push({ t: "text", v: text.slice(at) });
111+ return spans;
112+}
113+
114+/** A link's text keeps its formatting, but never a link, mention or reference inside a link. */
115+function plainSpans(list: Span[]): Span[] {
116+ const out: Span[] = [];
117+ for (const span of list.map(plainSpan)) {
118+ const last = out[out.length - 1];
119+ if (span.t === "text" && last?.t === "text") last.v += span.v;
120+ else out.push(span);
121+ }
122+ return out;
123+}
124+
125+function plainSpan(span: Span): Span {
126+ switch (span.t) {
127+ case "link":
128+ return { t: "text", v: spansText(span.c) };
129+ case "mention":
130+ return { t: "text", v: `@${span.name}` };
131+ case "channel":
132+ return { t: "text", v: `#${span.name}` };
133+ case "ref":
134+ return { t: "text", v: `${span.repo ?? ""}#${span.number}` };
135+ case "strong":
136+ case "em":
137+ case "del":
138+ return { ...span, c: plainSpans(span.c) };
139+ default:
140+ return { ...span };
141+ }
142+}
143+
144+// ---------------------------------------------------------------------------
145+// Blocks.
146+
147+const FENCE = /^( {0,3})(`{3,}|~{3,})\s*([\w+#.-]*)[^`]*$/;
148+const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
149+const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
150+const QUOTE = /^ {0,3}>[ \t]?/;
151+/** A heading has words after its `#`s: `#general` is a channel. */
152+const HEADED = /^ {0,3}#{1,6}[ \t]+\S/;
153+const ITEM = /^([ \t]*)([-*+•]|\d{1,9}[.)])(?:([ \t]+)(.*)|[ \t]*$)/;
154+
155+function indentOf(line: string): number {
156+ let width = 0;
157+ for (const char of line) {
158+ if (char === " ") width++;
159+ else if (char === "\t") width += 4 - (width % 4);
160+ else break;
161+ }
162+ return width;
163+}
164+
165+/** A line with up to `width` columns of its indent taken off. */
166+function dedent(line: string, width: number): string {
167+ let taken = 0;
168+ let i = 0;
169+ while (i < line.length && taken < width) {
170+ if (line[i] === " ") taken++;
171+ else if (line[i] === "\t") taken += 4 - (taken % 4);
172+ else break;
173+ i++;
174+ }
175+ return line.slice(i);
176+}
177+
178+/** Whether a line starts a block of its own, ending a paragraph. */
179+function startsBlock(line: string): boolean {
180+ if (FENCE.test(line) || HEADED.test(line) || RULE.test(line) || QUOTE.test(line)) return true;
181+ // A numbered list breaks into a paragraph only when it counts from 1.
182+ const item = ITEM.exec(line);
183+ return item != null && (!/\d/.test(item[2]!) || Number.parseInt(item[2]!, 10) === 1);
184+}
185+
186+/** A paragraph's line without the marks of a hard break at its end. */
187+function lineText(line: string): string {
188+ return line.replace(/(?: {2,}|\\)$/, "").trim();
189+}
190+
191+function parse(lines: string[], depth: number): Block[] {
192+ const out: Block[] = [];
193+ let paragraph: Span[][] = [];
194+ const flush = () => {
195+ if (paragraph.length) out.push({ t: "p", lines: paragraph });
196+ paragraph = [];
197+ };
198+ let i = 0;
199+ while (i < lines.length) {
200+ const line = lines[i]!;
201+ if (line.trim() === "") {
202+ // A blank line ends a paragraph.
203+ flush();
204+ i++;
205+ continue;
206+ }
207+ const fence = FENCE.exec(line);
208+ if (fence) {
209+ flush();
210+ const [, indent, marks] = fence;
211+ const close = new RegExp(`^ {0,3}${marks![0] === "`" ? "`" : "~"}{${marks!.length},}\\s*$`);
212+ const body: string[] = [];
213+ i++;
214+ while (i < lines.length && !close.test(lines[i]!)) body.push(dedent(lines[i++]!, indent!.length));
215+ i++; // the closing fence, or the end
216+ out.push({ t: "code", lang: fence[3] || null, v: body.join("\n") });
217+ continue;
218+ }
219+ const heading = HEADED.test(line) ? HEADING.exec(line) : null;
220+ if (heading) {
221+ flush();
222+ out.push({ t: "heading", level: heading[1]!.length, c: inline((heading[2] ?? "").trim()) });
223+ i++;
224+ continue;
225+ }
226+ if (RULE.test(line)) {
227+ flush();
228+ out.push({ t: "hr" });
229+ i++;
230+ continue;
231+ }
232+ if (QUOTE.test(line)) {
233+ flush();
234+ const quoted: string[] = [];
235+ while (i < lines.length && QUOTE.test(lines[i]!)) quoted.push(lines[i++]!.replace(QUOTE, ""));
236+ out.push({ t: "quote", c: depth < 8 ? parse(quoted, depth + 1) : [{ t: "p", lines: quoted.map((q) => inline(q)) }] });
237+ continue;
238+ }
239+ const item = ITEM.exec(line);
240+ if (item && depth < 8 && (paragraph.length === 0 || startsBlock(line))) {
241+ flush();
242+ const ordered = /\d/.test(item[2]!);
243+ const base = indentOf(item[1]!);
244+ const items: Block[][] = [];
245+ const start = ordered ? Number.parseInt(item[2]!, 10) : 1;
246+ while (i < lines.length) {
247+ const head = ITEM.exec(lines[i]!);
248+ if (!head || /\d/.test(head[2]!) !== ordered || indentOf(head[1]!) > base + 1) break;
249+ // Where the item's text starts: what is indented that far belongs to it.
250+ const gap = head[3] ? Math.min(indentOf(head[3]), 4) : 1;
251+ const content = base + head[2]!.length + gap;
252+ const body = [head[4] ?? ""];
253+ i++;
254+ while (i < lines.length) {
255+ const next = lines[i]!;
256+ if (next.trim() === "") {
257+ // A blank line inside an item, if what follows is still indented under it.
258+ const after = lines.slice(i + 1).find((l) => l.trim() !== "");
259+ if (after != null && indentOf(after) > base && !(ITEM.test(after) && indentOf(after) <= base + 1)) {
260+ body.push("");
261+ i++;
262+ continue;
263+ }
264+ break;
265+ }
266+ if (indentOf(next) <= base) break;
267+ if (ITEM.test(next) && indentOf(next) <= base + 1) break;
268+ body.push(dedent(next, Math.min(indentOf(next), content)));
269+ i++;
270+ }
271+ items.push(parse(body, depth + 1));
272+ // Blank lines between items keep one list.
273+ let j = i;
274+ while (j < lines.length && lines[j]!.trim() === "") j++;
275+ const following = j < lines.length ? ITEM.exec(lines[j]!) : null;
276+ if (j > i && following && /\d/.test(following[2]!) === ordered && indentOf(following[1]!) <= base + 1) i = j;
277+ }
278+ out.push({ t: "list", ordered, start, items });
279+ continue;
280+ }
281+ if (paragraph.length && startsBlock(line)) flush();
282+ paragraph.push(inline(lineText(line)));
283+ i++;
284+ }
285+ flush();
286+ return out;
287+}
288+
289+/** A message's whole text as blocks: paragraphs, headings, code, lists, quotes and rules. */
290+export function blocks(text: string): Block[] {
291+ return parse(text.replace(/\r\n?/g, "\n").split("\n"), 0);
292+}
293+
294+// ---------------------------------------------------------------------------
295+// Plain text.
296+
297+/** Spans as the words they show. */
298+export function spansText(list: Span[]): string {
299+ return list
300+ .map((span) => {
301+ switch (span.t) {
302+ case "text":
303+ case "code":
304+ return span.v;
305+ case "strong":
306+ case "em":
307+ case "del":
308+ case "link":
309+ return spansText(span.c);
310+ case "mention":
311+ return `@${span.name}`;
312+ case "channel":
313+ return `#${span.name}`;
314+ case "ref":
315+ return `${span.repo ?? ""}#${span.number}`;
316+ }
317+ })
318+ .join("");
319+}
320+
321+function blocksText(list: Block[]): string[] {
322+ const out: string[] = [];
323+ for (const block of list) {
324+ switch (block.t) {
325+ case "p":
326+ out.push(block.lines.map(spansText).join(" "));
327+ break;
328+ case "heading":
329+ out.push(spansText(block.c));
330+ break;
331+ case "code":
332+ out.push(block.v);
333+ break;
334+ case "list":
335+ block.items.forEach((item, index) => {
336+ const text = blocksText(item).join(" ");
337+ out.push(block.ordered ? `${block.start + index}. ${text}` : text);
338+ });
339+ break;
340+ case "quote":
341+ out.push(...blocksText(block.c));
342+ break;
343+ case "hr":
344+ break;
345+ }
346+ }
347+ return out.filter((line) => line.trim() !== "");
348+}
349+
350+/**
351+ * A message as plain words on one line, for a preview: the Markdown's
352+ * marks gone, a link as its text, a mention as `@name`. Cut to `max`
353+ * characters with an ellipsis.
354+ */
355+export function plainText(markdown: string, max = Infinity): string {
356+ const text = blocksText(blocks(markdown)).join(" ").replace(/\s+/g, " ").trim();
357+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
358+}
+3−1
8181
8282 test("previews are one short line without markup", () => {
8383 assert.equal(preview("**bold** and `code`\n\nnext [link](https://x)"), "bold and code next link");
84− assert.equal(preview("```\nlong code\n```"), "[code]");
84+ assert.equal(preview("```\nlong code\n```"), "long code");
85+ assert.equal(preview("## Done\n- shipped **#412** in #web\n- see \\*notes\\*"), "Done shipped #412 in #web see *notes*");
86+ assert.equal(preview("snake_case and ~/src stay, <b>as typed</b>"), "snake_case and ~/src stay, <b>as typed</b>");
8587 assert.equal(preview("x".repeat(300)).length, 140);
8688 });
8789
+4−10
2121 */
2222 import type { CardAction, FeedDelivery, FeedNotification, MemberProfile, NotificationCard, NotificationKind } from "@g1t/contracts";
2323
24+import { plainText } from "@g1t/contracts/chat-markdown";
25+
2426 import { tally, type UnreadRow } from "./unread.ts";
2527
2628 export type Person = { key: string; user_id: string; username: string; muted: boolean };
5557 return out;
5658 }
5759
58−/** A message's text as a notification shows it: one line, no markup, short. */
60+/** A message's text as a notification shows it: one line, the Markdown's marks gone, short. */
5961 export function preview(body: string, max = 140): string {
60− const text = String(body ?? "")
61− .replace(/```[\s\S]*?```/g, " [code] ")
62− .replace(/`([^`]*)`/g, "$1")
63− .replace(/!\[[^\]]*\]\([^)]*\)/g, "[image]")
64− .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
65− .replace(/[*_~>#]+/g, "")
66− .replace(/\s+/g, " ")
67− .trim();
68− return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
62+ return plainText(String(body ?? ""), max);
6963 }
7064
7165 /** At most this many of a card's actions ride on a notification. */