Skip to content
299 linesCodeBlameRaw

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 21/**
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 */
12import { type Block, type Span, blocks } from "@g1t/contracts/chat-markdown";
13
14/** The editor's JSON, as much of it as is read here. */
15export 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
23type Mark = { type: string; attrs?: Record<string, unknown> };
24
25// ---------------------------------------------------------------------------
26// Markdown to the document.
27
28function 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
33function 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
70function paragraph(spans: Span[]): DocNode {
71 const content = spanNodes(spans);
72 return content.length ? { type: "paragraph", content } : { type: "paragraph" };
73}
74
75function 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 297 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. */
123export 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. */
132const ORDER = ["link", "bold", "italic", "strike", "code"];
133const OPEN: Record<string, string> = { bold: "**", italic: "_", strike: "~~" };
134
135function 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. */
140export 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. */
153function 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
163function codeSpan(text: string): string {
164 if (!text.includes("`")) return `\`${text}\``;
165 return `\`\` ${text} \`\``;
166}
167
168function 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. */
173function 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
231function 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
238function 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
278function 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. */
295export 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.