Skip to content
137 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.

Docs: a workspace knowledge base people and agents write together1/**
2 * Docs mode's pure helpers: page trees, addresses, cursor colours, covers
3 * and how search snippets mark their matches. No Workers or DOM imports,
4 * so it is tested under Node.
5 */
6import type { DocRole, DocTreeNode } from "@g1t/contracts";
7
8export type TreeItem = DocTreeNode & { children: TreeItem[]; depth: number };
9
10/** A space's flat page list as a tree, each level in position order. */
11export function buildTree(nodes: DocTreeNode[]): TreeItem[] {
12 const byParent = new Map<string | null, DocTreeNode[]>();
13 const ids = new Set(nodes.map((n) => n.id));
14 for (const node of nodes) {
15 // A page whose parent is gone (in the trash) shows at the top.
16 const parent = node.parent_id && ids.has(node.parent_id) ? node.parent_id : null;
17 const list = byParent.get(parent) ?? [];
18 list.push(node);
19 byParent.set(parent, list);
20 }
21 const seen = new Set<string>();
22 const build = (parent: string | null, depth: number): TreeItem[] =>
23 (byParent.get(parent) ?? [])
24 .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id))
25 .filter((n) => !seen.has(n.id) && seen.add(n.id))
26 .map((n) => ({ ...n, depth, children: build(n.id, depth + 1) }));
27 return build(null, 0);
28}
29
30/** Every page in a tree, depth first, as a move dialog lists them. */
31export function flatten(tree: TreeItem[]): TreeItem[] {
32 const out: TreeItem[] = [];
33 const walk = (items: TreeItem[]) => {
34 for (const item of items) {
35 out.push(item);
36 walk(item.children);
37 }
38 };
39 walk(tree);
40 return out;
41}
42
43/** The ids on the way to `id` (its ancestors), so the sidebar opens them. */
44export function pathTo(nodes: DocTreeNode[], id: string | null): string[] {
45 if (!id) return [];
46 const byId = new Map(nodes.map((n) => [n.id, n]));
47 const out: string[] = [];
48 let at = byId.get(id)?.parent_id ?? null;
49 while (at && !out.includes(at)) {
50 out.unshift(at);
51 at = byId.get(at)?.parent_id ?? null;
52 }
53 return out;
54}
55
56/** The words of a title as an address: `release-plan`. */
57export function titleSlug(title: string): string {
58 return title
59 .normalize("NFKD")
60 .replace(/[\u0300-\u036f]/g, "")
61 .toLowerCase()
62 .replace(/[^a-z0-9]+/g, "-")
63 .replace(/^-+|-+$/g, "")
64 .slice(0, 50)
65 .replace(/-+$/g, "");
66}
67
68/** A page's address, from its space and current title; the id at the end is what is read. */
69export function pagePath(workspace: string, space: string, title: string, id: string): string {
70 const words = titleSlug(title);
71 return `/${workspace}/-/docs/${space}/${words ? `${words}-${id}` : id}`;
72}
73
74/** The page id at the end of a page's address segment. */
75export function pageIdOf(segment: string | undefined): string | null {
76 return /(pag_[0-9a-hjkmnp-tv-z]{26})$/.exec(segment ?? "")?.[1] ?? null;
77}
78
79/** A search snippet's parts: `[[word]]` marks a match. */
80export function snippetParts(snippet: string): { text: string; match: boolean }[] {
81 const out: { text: string; match: boolean }[] = [];
82 const re = /\[\[([\s\S]*?)\]\]/g;
83 let at = 0;
84 for (let m = re.exec(snippet); m; m = re.exec(snippet)) {
85 if (m.index > at) out.push({ text: snippet.slice(at, m.index), match: false });
86 out.push({ text: m[1]!, match: true });
87 at = m.index + m[0].length;
88 }
89 if (at < snippet.length) out.push({ text: snippet.slice(at), match: false });
90 return out;
91}
92
93/** Cursor colours: distinct, readable on the dark page. */
94export const CURSOR_COLOURS = ["#b8a6ff", "#7dd3fc", "#86efac", "#fcd34d", "#fca5a5", "#f9a8d4", "#a5b4fc", "#5eead4", "#fdba74", "#c4b5fd"];
95
96/** A person's cursor colour, stable for their name. */
97export function cursorColour(name: string): string {
98 let hash = 0;
99 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
100 return CURSOR_COLOURS[Math.abs(hash) % CURSOR_COLOURS.length]!;
101}
102
103/** The covers a page can take without an upload. */
104export const COVER_GRADIENTS = [
105 "linear-gradient(120deg, #2a2340 0%, #4b3d7a 50%, #8f7ee0 100%)",
106 "linear-gradient(120deg, #11222c 0%, #1f4b5c 55%, #4fb3c8 100%)",
107 "linear-gradient(120deg, #1d1f17 0%, #3d4a24 55%, #a3c45a 100%)",
108 "linear-gradient(120deg, #2b1a1a 0%, #5c2e2e 55%, #d9776a 100%)",
109 "linear-gradient(120deg, #241a2b 0%, #5a2d5f 55%, #d27bd8 100%)",
110 "linear-gradient(120deg, #1a1d2b 0%, #2c3466 55%, #7a8cf0 100%)",
111];
112
113/** A cover's CSS background: one of the gradients, or an image. */
114export function coverStyle(cover: string | null | undefined): string | null {
115 if (!cover) return null;
116 const gradient = /^gradient:(\d{1,2})$/.exec(cover);
117 if (gradient) return COVER_GRADIENTS[Number(gradient[1]) % COVER_GRADIENTS.length]!;
118 if (/^https:\/\//.test(cover)) return `center / cover no-repeat url("${cover.replace(/["\\]/g, "")}")`;
119 return null;
120}
121
122const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 };
123
124export function canDo(role: DocRole | null | undefined, need: DocRole): boolean {
125 return !!role && RANK[role] >= RANK[need];
126}
127
128/** How many words a page has, and the minutes it takes to read. */
129export function readingTime(markdown: string): { words: number; minutes: number } {
130 const words = markdown.replace(/```[\s\S]*?```/g, " ").split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
131 return { words, minutes: Math.max(1, Math.round(words / 230)) };
132}
133
134/** A Markdown file's name for a page. */
135export function markdownFileName(title: string): string {
136 return `${title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "Untitled"}.md`;
137}