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.

344 linesCodeBlameRaw
1/**
2 * A page's Markdown, derived from its Yjs document: what search indexes,
3 * agents read, export writes and the read view renders. Pure.
4 *
5 * The document is BlockNote's, as y-prosemirror stores it: the fragment
6 * holds one `blockGroup` of `blockContainer`s (each with an `id`), each
7 * holding one content node (`paragraph`, `heading`, `codeBlock`, ...) and,
8 * for nested blocks, a `blockGroup` of children. Inline text is an
9 * `XmlText` whose formatting attributes are the marks (`bold: {}`,
10 * `link: { href }`, `comment--<hash>: {...}`); inline nodes (mentions,
11 * dates) are `XmlElement`s between texts.
12 *
13 * g1t's own blocks (the editor's schema, apps/web components/docs):
14 * `callout` (`kind`: info | warning | success | danger), `mermaid`
15 * (`code`), `math` (`expression`), `embed` (`kind`, `title`, `url`), and
16 * inline `mention` (`kind`: user | agent | page; `id`: a person's
17 * username, an agent's id or a page's id; `name`; `href` for a page),
18 * `date` (`date`) and `citation` (`repo`, `path`, `kind`, `label`, `ref`:
19 * code the page cites, src/citations.ts).
20 */
21import type { DocCitation } from "@g1t/contracts";
22import * as Y from "yjs";
23
24import { citationMarkdown, cleanCitation } from "./citations.ts";
25
26export type Outline = { id: string; type: string; level: number | null; markdown: string };
27
28/** The callout kinds, as GitHub's alert names. */
29export const CALLOUT_ALERTS: Record<string, string> = { info: "NOTE", warning: "WARNING", success: "TIP", danger: "CAUTION" };
30
31/** The top-level blocks: the containers in the fragment's first block group. */
32export function topContainers(fragment: Y.XmlFragment): Y.XmlElement[] {
33 const group = fragment.toArray().find((n): n is Y.XmlElement => n instanceof Y.XmlElement && n.nodeName === "blockGroup");
34 if (!group) return [];
35 return group.toArray().filter((n): n is Y.XmlElement => n instanceof Y.XmlElement && n.nodeName === "blockContainer");
36}
37
38/** A container's content node and its children's group. */
39export function partsOf(container: Y.XmlElement): { content: Y.XmlElement | null; children: Y.XmlElement[] } {
40 let content: Y.XmlElement | null = null;
41 let children: Y.XmlElement[] = [];
42 for (const node of container.toArray()) {
43 if (!(node instanceof Y.XmlElement)) continue;
44 if (node.nodeName === "blockGroup")
45 children = node.toArray().filter((n): n is Y.XmlElement => n instanceof Y.XmlElement && n.nodeName === "blockContainer");
46 else if (!content) content = node;
47 }
48 return { content, children };
49}
50
51type Delta = { insert: string | object; attributes?: Record<string, unknown> };
52
53/** A text run with its marks applied, whitespace kept outside the markers. */
54function marked(text: string, attrs: Record<string, unknown> | undefined): string {
55 if (!attrs || !text) return text;
56 const keys = Object.keys(attrs).map((k) => k.replace(/--[a-zA-Z0-9+/=]{8}$/, ""));
57 if (attrs.code !== undefined && attrs.code !== null) {
58 const ticks = text.includes("`") ? "``" : "`";
59 return `${ticks}${text}${ticks}`;
60 }
61 const lead = /^\s*/.exec(text)![0];
62 const trail = /\s*$/.exec(text)![0];
63 let core = text.slice(lead.length, text.length - trail.length);
64 if (!core) return text;
65 if (keys.includes("bold")) core = `**${core}**`;
66 if (keys.includes("italic")) core = `_${core}_`;
67 if (keys.includes("strike")) core = `~~${core}~~`;
68 if (keys.includes("underline")) core = `<u>${core}</u>`;
69 const link = attrs.link as { href?: string } | undefined;
70 if (link?.href) core = `[${core}](${link.href})`;
71 return lead + core + trail;
72}
73
74/** An inline node (mention, date) as text. */
75function inlineNode(node: Y.XmlElement): string {
76 const a = node.getAttributes() as Record<string, string | undefined>;
77 if (node.nodeName === "mention") {
78 if (a.kind === "page") return a.href ? `[${a.name || "Untitled"}](${a.href})` : `[[${a.name || "Untitled"}]]`;
79 return `@${a.name ?? ""}`;
80 }
81 if (node.nodeName === "date") return a.date ?? "";
82 if (node.nodeName === "citation") {
83 const c = cleanCitation(a as Partial<DocCitation>, "body");
84 return c ? citationMarkdown(c) : "";
85 }
86 // An unknown inline node: its text, if any.
87 return node
88 .toArray()
89 .map((c) => (c instanceof Y.XmlText ? c.toString() : ""))
90 .join("");
91}
92
93/** A content node's inline content as Markdown. */
94export function inlineMarkdown(node: Y.XmlElement): string {
95 let out = "";
96 for (const child of node.toArray()) {
97 if (child instanceof Y.XmlText) {
98 for (const d of child.toDelta() as Delta[]) {
99 if (typeof d.insert === "string") out += marked(d.insert, d.attributes);
100 }
101 } else if (child instanceof Y.XmlElement) {
102 out += inlineNode(child);
103 }
104 }
105 // Hard breaks inside a paragraph.
106 return out.replace(/\n/g, "\\\n");
107}
108
109/** A content node's plain text. */
110export function plainText(node: Y.XmlElement): string {
111 let out = "";
112 for (const child of node.toArray()) {
113 if (child instanceof Y.XmlText) out += child.toString().replace(/<[^>]+>/g, "");
114 else if (child instanceof Y.XmlElement) out += inlineNode(child).replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
115 }
116 return out;
117}
118
119function codeText(node: Y.XmlElement): string {
120 return node
121 .toArray()
122 .map((c) => (c instanceof Y.XmlText ? (c.toDelta() as Delta[]).map((d) => (typeof d.insert === "string" ? d.insert : "")).join("") : ""))
123 .join("");
124}
125
126function fence(body: string, info: string): string {
127 const ticks = body.includes("```") ? "````" : "```";
128 return `${ticks}${info}\n${body}\n${ticks}`;
129}
130
131function table(node: Y.XmlElement): string {
132 const rows = node
133 .toArray()
134 .filter((r): r is Y.XmlElement => r instanceof Y.XmlElement && r.nodeName === "tableRow")
135 .map((row) =>
136 row
137 .toArray()
138 .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
139 .map((cell) =>
140 cell
141 .toArray()
142 .filter((p): p is Y.XmlElement => p instanceof Y.XmlElement)
143 .map((p) => inlineMarkdown(p).replace(/\\\n/g, " "))
144 .join(" ")
145 .replace(/\|/g, "\\|"),
146 ),
147 );
148 if (!rows.length) return "";
149 const width = Math.max(...rows.map((r) => r.length));
150 const line = (cells: string[]) => `| ${Array.from({ length: width }, (_, i) => cells[i] ?? "").join(" | ")} |`;
151 return [line(rows[0]!), `| ${Array.from({ length: width }, () => "---").join(" | ")} |`, ...rows.slice(1).map(line)].join("\n");
152}
153
154const LIST = new Set(["bulletListItem", "numberedListItem", "checkListItem"]);
155
156function indent(text: string, by: string): string {
157 return text
158 .split("\n")
159 .map((l) => (l ? by + l : l))
160 .join("\n");
161}
162
163/**
164 * One block (and its children) as Markdown. `number` is a numbered item's
165 * place in its run of numbered items.
166 */
167export function blockMarkdown(container: Y.XmlElement, number = 1): string {
168 const { content, children } = partsOf(container);
169 if (!content) return "";
170 const a = content.getAttributes() as Record<string, unknown>;
171 const kids = () => blocksMarkdown(children);
172 const nested = (prefix: string) => {
173 const body = kids();
174 return body ? `\n${indent(body, " ".repeat(prefix.length))}` : "";
175 };
176 switch (content.nodeName) {
177 case "heading": {
178 const level = Math.min(Math.max(Number(a.level) || 1, 1), 6);
179 const own = `${"#".repeat(level)} ${inlineMarkdown(content)}`;
180 const body = kids();
181 return body ? `${own}\n\n${body}` : own;
182 }
183 case "bulletListItem":
184 return `- ${inlineMarkdown(content)}${nested("- ")}`;
185 case "numberedListItem": {
186 const prefix = `${number}. `;
187 return `${prefix}${inlineMarkdown(content)}${nested(prefix)}`;
188 }
189 case "checkListItem": {
190 const checked = a.checked === true || a.checked === "true";
191 return `- [${checked ? "x" : " "}] ${inlineMarkdown(content)}${nested("- ")}`;
192 }
193 case "toggleListItem": {
194 const body = kids();
195 return `<details>\n<summary>${inlineMarkdown(content)}</summary>\n${body ? `\n${body}\n` : ""}\n</details>`;
196 }
197 case "quote":
198 return `${indent(inlineMarkdown(content), "> ").replace(/^$/gm, ">")}${children.length ? `\n\n${kids()}` : ""}`;
199 case "callout": {
200 const alert = CALLOUT_ALERTS[String(a.kind ?? "info")] ?? "NOTE";
201 return `> [!${alert}]\n${indent(inlineMarkdown(content) || " ", "> ")}${children.length ? `\n\n${kids()}` : ""}`;
202 }
203 case "codeBlock":
204 return fence(codeText(content), String(a.language ?? "") === "text" ? "" : String(a.language ?? ""));
205 case "mermaid":
206 return fence(String(a.code ?? ""), "mermaid");
207 case "math":
208 return `$$\n${String(a.expression ?? "")}\n$$`;
209 case "divider":
210 return "---";
211 case "pageBreak":
212 return "---";
213 case "image": {
214 const url = String(a.url ?? "");
215 if (!url) return "";
216 return `![${String(a.caption ?? a.name ?? "")}](${url})`;
217 }
218 case "video":
219 case "audio":
220 case "file": {
221 const url = String(a.url ?? "");
222 if (!url) return "";
223 return `[${String(a.name || a.caption || url)}](${url})`;
224 }
225 case "embed": {
226 const url = String(a.url ?? "");
227 const title = String(a.title ?? "") || url;
228 return url ? `[${title}](${url})` : title;
229 }
230 case "table":
231 return table(content);
232 case "paragraph":
233 default: {
234 const own = inlineMarkdown(content);
235 const body = kids();
236 if (!own) return body;
237 return body ? `${own}\n\n${body}` : own;
238 }
239 }
240}
241
242/** Sibling blocks as Markdown: list items close together, other blocks a blank line apart. */
243export function blocksMarkdown(containers: Y.XmlElement[]): string {
244 const parts: string[] = [];
245 let prevType: string | null = null;
246 let number = 0;
247 for (const container of containers) {
248 const type = partsOf(container).content?.nodeName ?? "";
249 number = type === "numberedListItem" ? (prevType === "numberedListItem" ? number + 1 : Number(partsOf(container).content?.getAttribute("start")) || 1) : 0;
250 const text = blockMarkdown(container, number);
251 if (text === "" && type === "paragraph") {
252 prevType = type;
253 continue;
254 }
255 // One list: items of the same kind (bullets and to-dos mix), close together.
256 const family = (t: string) => (t === "numberedListItem" ? "numbered" : "bullet");
257 const tight = prevType !== null && LIST.has(type) && LIST.has(prevType) && family(type) === family(prevType);
258 parts.push((parts.length ? (tight ? "\n" : "\n\n") : "") + text);
259 prevType = type;
260 }
261 return parts.join("");
262}
263
264/** The whole document as Markdown. */
265export function documentMarkdown(fragment: Y.XmlFragment): string {
266 const text = blocksMarkdown(topContainers(fragment)).trim();
267 return text ? `${text}\n` : "";
268}
269
270/** The top-level blocks, as agents see them. */
271export function outline(fragment: Y.XmlFragment): Outline[] {
272 const out: Outline[] = [];
273 const tops = topContainers(fragment);
274 let number = 0;
275 let prev: string | null = null;
276 for (const container of tops) {
277 const content = partsOf(container).content;
278 const type = content?.nodeName ?? "paragraph";
279 number = type === "numberedListItem" ? (prev === "numberedListItem" ? number + 1 : 1) : 0;
280 prev = type;
281 out.push({
282 id: String(container.getAttribute("id") ?? ""),
283 type,
284 level: type === "heading" ? Number(content?.getAttribute("level")) || 1 : null,
285 markdown: blockMarkdown(container, number),
286 });
287 }
288 return out;
289}
290
291/** Plain text for search: Markdown without its markup. */
292export function searchText(markdown: string): string {
293 return markdown
294 .replace(/```[^\n]*\n/g, "")
295 .replace(/```/g, "")
296 .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
297 .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
298 .replace(/<\/?(details|summary|u)>/g, " ")
299 .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+(\[[ x]\]\s+)?/gm, "")
300 .replace(/\[!(NOTE|WARNING|TIP|CAUTION|IMPORTANT)\]/g, "")
301 .replace(/[*_~`|]+/g, " ")
302 .replace(/[ \t]+/g, " ")
303 .replace(/\n{2,}/g, "\n")
304 .trim();
305}
306
307/** The first words of a page, for cards. */
308export function excerpt(markdown: string, max = 180): string {
309 const text = searchText(markdown).replace(/\s+/g, " ").trim();
310 return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
311}
312
313/** The people (usernames, lowercased) and agents (agent ids) a document mentions. */
314export function mentionedIds(fragment: Y.XmlFragment): { users: string[]; agents: string[] } {
315 const users = new Set<string>();
316 const agents = new Set<string>();
317 const walk = (node: Y.XmlElement | Y.XmlFragment) => {
318 for (const child of node.toArray()) {
319 if (!(child instanceof Y.XmlElement)) continue;
320 if (child.nodeName === "mention") {
321 const kind = child.getAttribute("kind");
322 const id = String(child.getAttribute("id") ?? "");
323 if (kind === "user" && id) users.add(id.toLowerCase());
324 if (kind === "agent" && id) agents.add(id);
325 } else walk(child);
326 }
327 };
328 walk(fragment);
329 return { users: [...users], agents: [...agents] };
330}
331
332/** The citation chips in a document, as their attributes say (src/citations.ts cleans them). */
333export function citationNodes(fragment: Y.XmlFragment): Partial<DocCitation>[] {
334 const out: Partial<DocCitation>[] = [];
335 const walk = (node: Y.XmlElement | Y.XmlFragment) => {
336 for (const child of node.toArray()) {
337 if (!(child instanceof Y.XmlElement)) continue;
338 if (child.nodeName === "citation") out.push(child.getAttributes() as Partial<DocCitation>);
339 else walk(child);
340 }
341 };
342 walk(fragment);
343 return out;
344}