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