Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge main into Artifacts Phase 2 | 1 | /** |
| 2 | * The chat composer's document and the Markdown a message is stored as, | |
| 3 | * each turned into the other. The composer (components/chat/editor.tsx) | |
| 4 | * edits a small rich-text document; what it sends, keeps as a draft and | |
| 5 | * edits again is Markdown, the same text the API, agents and notifications | |
| 6 | * read. Pure, on the editor's JSON, so it is tested without a browser | |
| 7 | * (chat-compose.test.ts). | |
| 8 | * | |
| 9 | * In the document each paragraph is one line of the message: Shift+Enter | |
| 10 | * starts a new one, an empty one is a blank line. | |
| 11 | */ | |
| 12 | import { type Block, type Span, blocks } from "@g1t/contracts/chat-markdown"; | |
| 13 | ||
| 14 | /** The editor's JSON, as much of it as is read here. */ | |
| 15 | export type DocNode = { | |
| 16 | type: string; | |
| 17 | attrs?: Record<string, unknown>; | |
| 18 | content?: DocNode[]; | |
| 19 | text?: string; | |
| 20 | marks?: { type: string; attrs?: Record<string, unknown> }[]; | |
| 21 | }; | |
| 22 | ||
| 23 | type Mark = { type: string; attrs?: Record<string, unknown> }; | |
| 24 | ||
| 25 | // --------------------------------------------------------------------------- | |
| 26 | // Markdown to the document. | |
| 27 | ||
| 28 | function textNode(text: string, marks: Mark[]): DocNode | null { | |
| 29 | if (!text) return null; | |
| 30 | return marks.length ? { type: "text", text, marks } : { type: "text", text }; | |
| 31 | } | |
| 32 | ||
| 33 | function spanNodes(list: Span[], marks: Mark[] = []): DocNode[] { | |
| 34 | const out: DocNode[] = []; | |
| 35 | const add = (node: DocNode | null) => node && out.push(node); | |
| 36 | for (const span of list) { | |
| 37 | switch (span.t) { | |
| 38 | case "text": | |
| 39 | add(textNode(span.v, marks)); | |
| 40 | break; | |
| 41 | case "code": | |
| 42 | add(textNode(span.v, [...marks, { type: "code" }])); | |
| 43 | break; | |
| 44 | case "strong": | |
| 45 | out.push(...spanNodes(span.c, [...marks, { type: "bold" }])); | |
| 46 | break; | |
| 47 | case "em": | |
| 48 | out.push(...spanNodes(span.c, [...marks, { type: "italic" }])); | |
| 49 | break; | |
| 50 | case "del": | |
| 51 | out.push(...spanNodes(span.c, [...marks, { type: "strike" }])); | |
| 52 | break; | |
| 53 | case "link": | |
| 54 | out.push(...spanNodes(span.c, [{ type: "link", attrs: { href: span.href } }, ...marks])); | |
| 55 | break; | |
| 56 | case "mention": | |
| 57 | add(textNode(`@${span.name}`, marks)); | |
| 58 | break; | |
| 59 | case "channel": | |
| 60 | add(textNode(`#${span.name}`, marks)); | |
| 61 | break; | |
| 62 | case "ref": | |
| 63 | add(textNode(`${span.repo ?? ""}#${span.number}`, marks)); | |
| 64 | break; | |
| 65 | } | |
| 66 | } | |
| 67 | return out; | |
| 68 | } | |
| 69 | ||
| 70 | function paragraph(spans: Span[]): DocNode { | |
| 71 | const content = spanNodes(spans); | |
| 72 | return content.length ? { type: "paragraph", content } : { type: "paragraph" }; | |
| 73 | } | |
| 74 | ||
| 75 | function blockNodes(list: Block[]): DocNode[] { | |
| 76 | const out: DocNode[] = []; | |
| 77 | list.forEach((block, index) => { | |
| 78 | // Paragraphs one after another were apart by a blank line: an empty line between them. | |
| 79 | if (block.t === "p" && index > 0 && list[index - 1]!.t === "p") out.push({ type: "paragraph" }); | |
| 80 | switch (block.t) { | |
| 81 | case "p": | |
| 82 | for (const line of block.lines) out.push(paragraph(line)); | |
| 83 | break; | |
| 84 | case "heading": | |
| 85 | // Chat has no headings to write; one comes back as a bold line. | |
| 86 | out.push(paragraph([{ t: "strong", c: block.c }])); | |
| 87 | break; | |
| 88 | case "hr": | |
| 89 | break; | |
| The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work. | 90 | case "table": { |
| 91 | // The composer has no tables: each row comes back as the line it was written as. | |
| 92 | const row = (cells: Span[][]): Span[] => [{ t: "text", v: "| " }, ...cells.flatMap((cell, at) => [...(at ? [{ t: "text" as const, v: " | " }] : []), ...cell]), { t: "text", v: " |" }]; | |
| 93 | const rule = block.align.map((align) => (align === "center" ? ":-:" : align === "right" ? "--:" : align === "left" ? ":--" : "---")); | |
| 94 | out.push(paragraph(row(block.head)), paragraph([{ t: "text", v: `| ${rule.join(" | ")} |` }]), ...block.rows.map((cells) => paragraph(row(cells)))); | |
| 95 | break; | |
| 96 | } | |
| Merge main into Artifacts Phase 2 | 97 | case "code": |
| 98 | out.push({ type: "codeBlock", attrs: { language: block.lang }, ...(block.v ? { content: [{ type: "text", text: block.v }] } : {}) }); | |
| 99 | break; | |
| 100 | case "quote": { | |
| 101 | const content = blockNodes(block.c); | |
| 102 | out.push({ type: "blockquote", content: content.length ? content : [{ type: "paragraph" }] }); | |
| 103 | break; | |
| 104 | } | |
| 105 | case "list": | |
| 106 | out.push({ | |
| 107 | type: block.ordered ? "orderedList" : "bulletList", | |
| 108 | ...(block.ordered ? { attrs: { start: block.start } } : {}), | |
| 109 | content: block.items.map((item) => { | |
| 110 | const content = blockNodes(item); | |
| 111 | // An item starts with a line of text. | |
| 112 | if (content[0]?.type !== "paragraph") content.unshift({ type: "paragraph" }); | |
| 113 | return { type: "listItem", content }; | |
| 114 | }), | |
| 115 | }); | |
| 116 | break; | |
| 117 | } | |
| 118 | }); | |
| 119 | return out; | |
| 120 | } | |
| 121 | ||
| 122 | /** A message's Markdown as the composer's document. */ | |
| 123 | export function markdownToDoc(markdown: string): DocNode { | |
| 124 | const content = blockNodes(blocks(markdown)); | |
| 125 | return { type: "doc", content: content.length ? content : [{ type: "paragraph" }] }; | |
| 126 | } | |
| 127 | ||
| 128 | // --------------------------------------------------------------------------- | |
| 129 | // The document to Markdown. | |
| 130 | ||
| 131 | /** Marks from the outside in: a link holds bold, bold holds code. */ | |
| 132 | const ORDER = ["link", "bold", "italic", "strike", "code"]; | |
| 133 | const OPEN: Record<string, string> = { bold: "**", italic: "_", strike: "~~" }; | |
| 134 | ||
| 135 | function sameMark(a: Mark, b: Mark): boolean { | |
| 136 | return a.type === b.type && (a.type !== "link" || a.attrs?.href === b.attrs?.href); | |
| 137 | } | |
| 138 | ||
| 139 | /** Text as Markdown shows it as typed: its marks escaped. */ | |
| 140 | export function escapeText(text: string): string { | |
| 141 | return text | |
| 142 | .replace(/\\(?=[\\`*_{}[\]()#+\-.!~>|<=:@])/g, "\\\\") | |
| 143 | .replace(/[`*~[\]]/g, (char) => `\\${char}`) | |
| 144 | .replace(/_/g, (char, at: number, all: string) => { | |
| 145 | // `snake_case` stays as typed: only an `_` at a word's edge could start or end italics. | |
| 146 | const before = all[at - 1] ?? ""; | |
| 147 | const after = all[at + 1] ?? ""; | |
| 148 | return /\w/.test(before) && /\w/.test(after) ? char : `\\${char}`; | |
| 149 | }); | |
| 150 | } | |
| 151 | ||
| 152 | /** A line that would read as a block's start (`# `, `- `, `1. `, `> `, `---`) when it is only text. */ | |
| 153 | function escapeLineStart(line: string): string { | |
| 154 | return line | |
| 155 | .replace(/^(\s*)(#{1,6}(?:\s|$))/, "$1\\$2") | |
| 156 | .replace(/^(\s*)([-+](?:\s|$))/, "$1\\$2") | |
| 157 | .replace(/^(\s*)(\d{1,9})([.)])(\s|$)/, "$1$2\\$3$4") | |
| 158 | .replace(/^(\s*)(>)/, "$1\\$2") | |
| 159 | .replace(/^(\s*)([-_=]{3,}\s*)$/, "$1\\$2") | |
| 160 | .replace(/^(\s*)•/, "$1\\•"); | |
| 161 | } | |
| 162 | ||
| 163 | function codeSpan(text: string): string { | |
| 164 | if (!text.includes("`")) return `\`${text}\``; | |
| 165 | return `\`\` ${text} \`\``; | |
| 166 | } | |
| 167 | ||
| 168 | function linkTarget(href: string): string { | |
| 169 | return /[\s()<>]/.test(href) ? `<${href.replace(/[<>\s]/g, encodeURIComponent)}>` : href; | |
| 170 | } | |
| 171 | ||
| 172 | /** A line of inline content (text with marks) as Markdown. */ | |
| 173 | function inlineMarkdown(nodes: DocNode[]): string { | |
| 174 | let out = ""; | |
| 175 | const open: { mark: Mark; text: string }[] = []; | |
| 176 | // A link's text is gathered so a bare URL can be written bare. | |
| 177 | const close = (count: number) => { | |
| 178 | for (let n = 0; n < count; n++) { | |
| 179 | const top = open.pop()!; | |
| 180 | // Spaces at a mark's end go after its closing mark: `**a **` would not be bold. | |
| 181 | const body = top.text; | |
| 182 | const trailing = /\s*$/.exec(body)![0]; | |
| 183 | const inner = body.slice(0, body.length - trailing.length); | |
| 184 | let written: string; | |
| 185 | if (top.mark.type === "link") { | |
| 186 | const href = String(top.mark.attrs?.href ?? ""); | |
| 187 | written = inner === escapeText(href) && /^https?:\/\//i.test(href) && !/[\s()<>]/.test(href) ? href : `[${inner}](${linkTarget(href)})`; | |
| 188 | } else if (!inner) { | |
| 189 | written = ""; | |
| 190 | } else { | |
| 191 | const delimiter = OPEN[top.mark.type] ?? ""; | |
| 192 | written = `${delimiter}${inner}${delimiter}`; | |
| 193 | } | |
| 194 | append(written + trailing); | |
| 195 | } | |
| 196 | }; | |
| 197 | const append = (text: string) => { | |
| 198 | if (open.length) open[open.length - 1]!.text += text; | |
| 199 | else out += text; | |
| 200 | }; | |
| 201 | for (const node of nodes) { | |
| 202 | if (node.type === "hardBreak") { | |
| 203 | close(open.length); | |
| 204 | out += "\n"; | |
| 205 | continue; | |
| 206 | } | |
| 207 | if (node.type !== "text" || !node.text) continue; | |
| 208 | const marks = [...(node.marks ?? [])] | |
| 209 | .filter((mark) => ORDER.includes(mark.type)) | |
| 210 | .sort((a, b) => ORDER.indexOf(a.type) - ORDER.indexOf(b.type)); | |
| 211 | // Close what this text no longer has, and everything opened after it. | |
| 212 | let keep = 0; | |
| 213 | while (keep < open.length && keep < marks.length && sameMark(open[keep]!.mark, marks[keep]!)) keep++; | |
| 214 | close(open.length - keep); | |
| 215 | const code = marks.some((mark) => mark.type === "code"); | |
| 216 | let text = node.text; | |
| 217 | // Spaces before a mark's start go before it: `** a**` would not be bold. | |
| 218 | const opening = marks.slice(keep).filter((mark) => mark.type !== "code"); | |
| 219 | if (opening.length) { | |
| 220 | const leading = /^\s*/.exec(text)![0]; | |
| 221 | append(leading); | |
| 222 | text = text.slice(leading.length); | |
| 223 | } | |
| 224 | for (const mark of marks.slice(keep)) if (mark.type !== "code") open.push({ mark, text: "" }); | |
| 225 | append(code ? codeSpan(text) : escapeText(text)); | |
| 226 | } | |
| 227 | close(open.length); | |
| 228 | return out; | |
| 229 | } | |
| 230 | ||
| 231 | function prefixLines(text: string, first: string, rest: string): string { | |
| 232 | return text | |
| 233 | .split("\n") | |
| 234 | .map((line, index) => (index === 0 ? first : line ? rest : rest.trimEnd()) + line) | |
| 235 | .join("\n"); | |
| 236 | } | |
| 237 | ||
| 238 | function blockMarkdown(node: DocNode): string | null { | |
| 239 | switch (node.type) { | |
| 240 | case "paragraph": { | |
| 241 | const line = inlineMarkdown(node.content ?? []); | |
| 242 | return line | |
| 243 | .split("\n") | |
| 244 | .map((part) => escapeLineStart(part)) | |
| 245 | .join("\n"); | |
| 246 | } | |
| 247 | case "codeBlock": { | |
| 248 | const text = (node.content ?? []).map((child) => child.text ?? "").join(""); | |
| 249 | const longest = Math.max(2, ...(text.match(/`{3,}/g) ?? []).map((run) => run.length)); | |
| 250 | const fence = "`".repeat(longest + 1); | |
| 251 | const language = typeof node.attrs?.language === "string" ? node.attrs.language : ""; | |
| 252 | return `${fence}${language}\n${text}\n${fence}`; | |
| 253 | } | |
| 254 | case "blockquote": { | |
| 255 | const inner = blocksMarkdown(node.content ?? []); | |
| 256 | return inner | |
| 257 | .split("\n") | |
| 258 | .map((line) => (line ? `> ${line}` : ">")) | |
| 259 | .join("\n"); | |
| 260 | } | |
| 261 | case "bulletList": | |
| 262 | case "orderedList": { | |
| 263 | const ordered = node.type === "orderedList"; | |
| 264 | const start = ordered ? Number(node.attrs?.start ?? 1) || 1 : 1; | |
| 265 | return (node.content ?? []) | |
| 266 | .map((item, index) => { | |
| 267 | const marker = ordered ? `${start + index}. ` : "- "; | |
| 268 | const inner = blocksMarkdown(item.content ?? [], true); | |
| 269 | return prefixLines(inner, marker, " ".repeat(marker.length)); | |
| 270 | }) | |
| 271 | .join("\n"); | |
| 272 | } | |
| 273 | default: | |
| 274 | return null; | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | function blocksMarkdown(nodes: DocNode[], tight = false): string { | |
| 279 | let out = ""; | |
| 280 | let previous: string | null = null; | |
| 281 | for (const node of nodes) { | |
| 282 | const text = blockMarkdown(node); | |
| 283 | if (text == null) continue; | |
| 284 | if (previous != null) { | |
| 285 | // A line follows a line, and in a list item everything does; anything else stands apart by a blank line. | |
| 286 | out += tight || (previous === "paragraph" && node.type === "paragraph") ? "\n" : "\n\n"; | |
| 287 | } | |
| 288 | out += text; | |
| 289 | previous = node.type; | |
| 290 | } | |
| 291 | return out; | |
| 292 | } | |
| 293 | ||
| 294 | /** The composer's document as the Markdown a message is sent as. */ | |
| 295 | export function docToMarkdown(doc: DocNode): string { | |
| 296 | return blocksMarkdown(doc.content ?? []) | |
| 297 | .replace(/^(?:[ \t]*\n)+/, "") | |
| 298 | .replace(/(?:\n[ \t]*)+$/, ""); | |
| 299 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.