Skip to content
299 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[] => [{ 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 }
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. */
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}