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.
7 files+648−1570/7 viewed
| 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"; | |
| 2 | 3 | import { Link } from "react-router"; | |
| 3 | 4 | ||
| 4 | 5 | import { MemberCard } from "./profile-card"; | |
| 6 | + | import { Hint } from "../ui/hint"; | |
| 5 | 7 | import { type Block, type Span, blocks, onlyEmoji } from "../../lib/chat"; | |
| 6 | 8 | // The workspace's own emoji, drawn where `:name:` is written (components/emoji). | |
| 7 | 9 | import { useEmojiContext } from "../emoji/context"; | |
| ⋯ | |||
| 114 | 116 | )); | |
| 115 | 117 | } | |
| 116 | 118 | ||
| 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 | + | ||
| 117 | 140 | function BlockView({ block, context }: { block: Block; context: TextContext }) { | |
| 118 | 141 | switch (block.t) { | |
| 119 | 142 | case "p": | |
| 120 | 143 | 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" />; | |
| 121 | 149 | 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} />; | |
| 127 | 151 | case "list": { | |
| 128 | 152 | const Tag = block.ordered ? "ol" : "ul"; | |
| 129 | 153 | 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`}> | |
| 131 | 155 | {block.items.map((item, index) => ( | |
| 132 | − | <li key={index}>{spans(item, context)}</li> | |
| 156 | + | <ListItem key={index} item={item} context={context} /> | |
| 133 | 157 | ))} | |
| 134 | 158 | </Tag> | |
| 135 | 159 | ); | |
| 136 | 160 | } | |
| 137 | 161 | 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 | + | ); | |
| 139 | 167 | } | |
| 140 | 168 | } | |
| 141 | 169 | ||
| 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 | + | ||
| 142 | 238 | /** A message's text, rendered from parsed blocks: never as HTML. */ | |
| 143 | 239 | export function MessageText({ body, context }: { body: string; context: TextContext }) { | |
| 144 | 240 | const parsed = useMemo(() => blocks(body), [body]); | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | 136 | // --------------------------------------------------------------------------- | |
| 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. | |
| 181 | 138 | ||
| 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"; | |
| 274 | 140 | ||
| 275 | 141 | /** Whether a message is only emoji, a few of them: shown larger, as people expect. */ | |
| 276 | 142 | export function onlyEmoji(text: string): boolean { |
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "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" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 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 | + | ")", | |
| 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(""), [{ 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~~ "), "*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 | + | }); |
| 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 | + | } |
| 81 | 81 | ||
| 82 | 82 | test("previews are one short line without markup", () => { | |
| 83 | 83 | 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>"); | |
| 85 | 87 | assert.equal(preview("x".repeat(300)).length, 140); | |
| 86 | 88 | }); | |
| 87 | 89 |
| 21 | 21 | */ | |
| 22 | 22 | import type { CardAction, FeedDelivery, FeedNotification, MemberProfile, NotificationCard, NotificationKind } from "@g1t/contracts"; | |
| 23 | 23 | ||
| 24 | + | import { plainText } from "@g1t/contracts/chat-markdown"; | |
| 25 | + | ||
| 24 | 26 | import { tally, type UnreadRow } from "./unread.ts"; | |
| 25 | 27 | ||
| 26 | 28 | export type Person = { key: string; user_id: string; username: string; muted: boolean }; | |
| ⋯ | |||
| 55 | 57 | return out; | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 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. */ | |
| 59 | 61 | 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); | |
| 69 | 63 | } | |
| 70 | 64 | ||
| 71 | 65 | /** At most this many of a card's actions ride on a notification. */ | |