Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

301 linesCodeBlameRaw
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 */
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;
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[] => [
93 { t: "text", v: "| " },
94 ...cells.flatMap((cell, at) => [...(at ? [{ t: "text" as const, v: " | " }] : []), ...cell]),
95 { t: "text", v: " |" },
96 ];
97 const rule = block.align.map((align) => (align === "center" ? ":-:" : align === "right" ? "--:" : align === "left" ? ":--" : "---"));
98 out.push(paragraph(row(block.head)), paragraph([{ t: "text", v: `| ${rule.join(" | ")} |` }]), ...block.rows.map((cells) => paragraph(row(cells))));
99 break;
100 }
101 case "code":
102 out.push({ type: "codeBlock", attrs: { language: block.lang }, ...(block.v ? { content: [{ type: "text", text: block.v }] } : {}) });
103 break;
104 case "quote": {
105 const content = blockNodes(block.c);
106 out.push({ type: "blockquote", content: content.length ? content : [{ type: "paragraph" }] });
107 break;
108 }
109 case "list":
110 out.push({
111 type: block.ordered ? "orderedList" : "bulletList",
112 ...(block.ordered ? { attrs: { start: block.start } } : {}),
113 content: block.items.map((item) => {
114 const content = blockNodes(item);
115 // An item starts with a line of text.
116 if (content[0]?.type !== "paragraph") content.unshift({ type: "paragraph" });
117 return { type: "listItem", content };
118 }),
119 });
120 break;
121 }
122 });
123 return out;
124}
125
126/** A message's Markdown as the composer's document. */
127export function markdownToDoc(markdown: string): DocNode {
128 const content = blockNodes(blocks(markdown));
129 return { type: "doc", content: content.length ? content : [{ type: "paragraph" }] };
130}
131
132// ---------------------------------------------------------------------------
133// The document to Markdown.
134
135/** Marks from the outside in: a link holds bold, bold holds code. */
136const ORDER = ["link", "bold", "italic", "strike", "code"];
137const OPEN: Record<string, string> = { bold: "**", italic: "_", strike: "~~" };
138
139function sameMark(a: Mark, b: Mark): boolean {
140 return a.type === b.type && (a.type !== "link" || a.attrs?.href === b.attrs?.href);
141}
142
143/** Text as Markdown shows it as typed: its marks escaped. */
144export function escapeText(text: string): string {
145 return text
146 .replace(/\\(?=[\\`*_{}[\]()#+\-.!~>|<=:@])/g, "\\\\")
147 .replace(/[`*~[\]]/g, (char) => `\\${char}`)
148 .replace(/_/g, (char, at: number, all: string) => {
149 // `snake_case` stays as typed: only an `_` at a word's edge could start or end italics.
150 const before = all[at - 1] ?? "";
151 const after = all[at + 1] ?? "";
152 return /\w/.test(before) && /\w/.test(after) ? char : `\\${char}`;
153 });
154}
155
156/** A line that would read as a block's start (`# `, `- `, `1. `, `> `, `---`) when it is only text. */
157function escapeLineStart(line: string): string {
158 return line
159 .replace(/^(\s*)(#{1,6}(?:\s|$))/, "$1\\$2")
160 .replace(/^(\s*)([-+](?:\s|$))/, "$1\\$2")
161 .replace(/^(\s*)(\d{1,9})([.)])(\s|$)/, "$1$2\\$3$4")
162 .replace(/^(\s*)(>)/, "$1\\$2")
163 .replace(/^(\s*)([-_=]{3,}\s*)$/, "$1\\$2")
164 .replace(/^(\s*)•/, "$1\\•");
165}
166
167function codeSpan(text: string): string {
168 if (!text.includes("`")) return `\`${text}\``;
169 return `\`\` ${text} \`\``;
170}
171
172function linkTarget(href: string): string {
173 return /[\s()<>]/.test(href) ? `<${href.replace(/[<>\s]/g, encodeURIComponent)}>` : href;
174}
175
176/** A line of inline content (text with marks) as Markdown. */
177function inlineMarkdown(nodes: DocNode[]): string {
178 let out = "";
179 const open: { mark: Mark; text: string }[] = [];
180 // A link's text is gathered so a bare URL can be written bare.
181 const close = (count: number) => {
182 for (let n = 0; n < count; n++) {
183 const top = open.pop()!;
184 // Spaces at a mark's end go after its closing mark: `**a **` would not be bold.
185 const body = top.text;
186 const trailing = /\s*$/.exec(body)![0];
187 const inner = body.slice(0, body.length - trailing.length);
188 let written: string;
189 if (top.mark.type === "link") {
190 const href = String(top.mark.attrs?.href ?? "");
191 written = inner === escapeText(href) && /^https?:\/\//i.test(href) && !/[\s()<>]/.test(href) ? href : `[${inner}](${linkTarget(href)})`;
192 } else if (!inner) {
193 written = "";
194 } else {
195 const delimiter = OPEN[top.mark.type] ?? "";
196 written = `${delimiter}${inner}${delimiter}`;
197 }
198 append(written + trailing);
199 }
200 };
201 const append = (text: string) => {
202 if (open.length) open[open.length - 1]!.text += text;
203 else out += text;
204 };
205 for (const node of nodes) {
206 if (node.type === "hardBreak") {
207 close(open.length);
208 out += "\n";
209 continue;
210 }
211 if (node.type !== "text" || !node.text) continue;
212 const marks = [...(node.marks ?? [])].filter((mark) => ORDER.includes(mark.type)).sort((a, b) => ORDER.indexOf(a.type) - ORDER.indexOf(b.type));
213 // Close what this text no longer has, and everything opened after it.
214 let keep = 0;
215 while (keep < open.length && keep < marks.length && sameMark(open[keep]!.mark, marks[keep]!)) keep++;
216 close(open.length - keep);
217 const code = marks.some((mark) => mark.type === "code");
218 let text = node.text;
219 // Spaces before a mark's start go before it: `** a**` would not be bold.
220 const opening = marks.slice(keep).filter((mark) => mark.type !== "code");
221 if (opening.length) {
222 const leading = /^\s*/.exec(text)![0];
223 append(leading);
224 text = text.slice(leading.length);
225 }
226 for (const mark of marks.slice(keep)) if (mark.type !== "code") open.push({ mark, text: "" });
227 append(code ? codeSpan(text) : escapeText(text));
228 }
229 close(open.length);
230 return out;
231}
232
233function prefixLines(text: string, first: string, rest: string): string {
234 return text
235 .split("\n")
236 .map((line, index) => (index === 0 ? first : line ? rest : rest.trimEnd()) + line)
237 .join("\n");
238}
239
240function blockMarkdown(node: DocNode): string | null {
241 switch (node.type) {
242 case "paragraph": {
243 const line = inlineMarkdown(node.content ?? []);
244 return line
245 .split("\n")
246 .map((part) => escapeLineStart(part))
247 .join("\n");
248 }
249 case "codeBlock": {
250 const text = (node.content ?? []).map((child) => child.text ?? "").join("");
251 const longest = Math.max(2, ...(text.match(/`{3,}/g) ?? []).map((run) => run.length));
252 const fence = "`".repeat(longest + 1);
253 const language = typeof node.attrs?.language === "string" ? node.attrs.language : "";
254 return `${fence}${language}\n${text}\n${fence}`;
255 }
256 case "blockquote": {
257 const inner = blocksMarkdown(node.content ?? []);
258 return inner
259 .split("\n")
260 .map((line) => (line ? `> ${line}` : ">"))
261 .join("\n");
262 }
263 case "bulletList":
264 case "orderedList": {
265 const ordered = node.type === "orderedList";
266 const start = ordered ? Number(node.attrs?.start ?? 1) || 1 : 1;
267 return (node.content ?? [])
268 .map((item, index) => {
269 const marker = ordered ? `${start + index}. ` : "- ";
270 const inner = blocksMarkdown(item.content ?? [], true);
271 return prefixLines(inner, marker, " ".repeat(marker.length));
272 })
273 .join("\n");
274 }
275 default:
276 return null;
277 }
278}
279
280function blocksMarkdown(nodes: DocNode[], tight = false): string {
281 let out = "";
282 let previous: string | null = null;
283 for (const node of nodes) {
284 const text = blockMarkdown(node);
285 if (text == null) continue;
286 if (previous != null) {
287 // A line follows a line, and in a list item everything does; anything else stands apart by a blank line.
288 out += tight || (previous === "paragraph" && node.type === "paragraph") ? "\n" : "\n\n";
289 }
290 out += text;
291 previous = node.type;
292 }
293 return out;
294}
295
296/** The composer's document as the Markdown a message is sent as. */
297export function docToMarkdown(doc: DocNode): string {
298 return blocksMarkdown(doc.content ?? [])
299 .replace(/^(?:[ \t]*\n)+/, "")
300 .replace(/(?:\n[ \t]*)+$/, "");
301}