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 together | 1 | /** |
| 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 | */ | |
| 6 | import type { DocRole, DocTreeNode } from "@g1t/contracts"; | |
| 7 | ||
| 8 | export type TreeItem = DocTreeNode & { children: TreeItem[]; depth: number }; | |
| 9 | ||
| 10 | /** A space's flat page list as a tree, each level in position order. */ | |
| 11 | export 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. */ | |
| 31 | export 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. */ | |
| 44 | export 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`. */ | |
| 57 | export 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. */ | |
| 69 | export 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. */ | |
| 75 | export 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. */ | |
| 80 | export 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. */ | |
| 94 | export const CURSOR_COLOURS = ["#b8a6ff", "#7dd3fc", "#86efac", "#fcd34d", "#fca5a5", "#f9a8d4", "#a5b4fc", "#5eead4", "#fdba74", "#c4b5fd"]; | |
| 95 | ||
| 96 | /** A person's cursor colour, stable for their name. */ | |
| 97 | export 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. */ | |
| 104 | export 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. */ | |
| 114 | export 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 | ||
| 122 | const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 }; | |
| 123 | ||
| 124 | export 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. */ | |
| 129 | export 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. */ | |
| 135 | export function markdownFileName(title: string): string { | |
| 136 | return `${title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "Untitled"}.md`; | |
| 137 | } | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 138 | |
| 139 | /** | |
| 140 | * Where a citation links: the file or folder in Code at the commit it was | |
| 141 | * cited at, or the default branch (`HEAD`). A glob links to the folder it | |
| 142 | * starts from. Mirrors `citationHref` in services/docs src/citations.ts. | |
| 143 | */ | |
| 144 | export function citationHref(c: { repo: string; path: string; ref: string | null }): string { | |
| 145 | const parts = c.path.split("/").filter(Boolean); | |
| 146 | const globAt = parts.findIndex((p) => /[*?]/.test(p)); | |
| 147 | const glob = globAt >= 0; | |
| 148 | const shown = (glob ? parts.slice(0, globAt) : parts).map(encodeURIComponent).join("/"); | |
| 149 | const kind = glob || !/\.[A-Za-z0-9]{1,10}$/.test(c.path) ? "tree" : "blob"; | |
| 150 | return `/${c.repo}/${kind}/${encodeURIComponent(c.ref || "HEAD")}${shown ? `/${shown}` : ""}`; | |
| 151 | } | |
| 152 | ||
| 153 | /** A project's docs file's address in Docs. */ | |
| 154 | export function repoFilePath(workspace: string, repo: string, path: string): string { | |
| 155 | return `/${workspace}/-/docs/repo/${repo}/${path.split("/").map(encodeURIComponent).join("/")}`; | |
| 156 | } | |
| 157 | ||
| 158 | /** A folder of a project's docs, as the sidebar shows it: files, then folders, each by name. */ | |
| 159 | export type RepoFolder = { name: string; path: string; files: { path: string; title: string }[]; folders: RepoFolder[] }; | |
| 160 | ||
| 161 | /** A project's docs files as folders: README and `docs/` at the top, `docs/a/b.md` under `a`. */ | |
| 162 | export function repoFolders(files: { path: string; title: string }[]): RepoFolder { | |
| 163 | const root: RepoFolder = { name: "", path: "", files: [], folders: [] }; | |
| 164 | for (const file of files) { | |
| 165 | // `docs/` is the space itself: its files sit at the top beside the README. | |
| 166 | const parts = file.path.replace(/^docs\//i, "").split("/"); | |
| 167 | parts.pop(); | |
| 168 | let at = root; | |
| 169 | for (const part of parts) { | |
| 170 | let next = at.folders.find((f) => f.name === part); | |
| 171 | if (!next) { | |
| 172 | next = { name: part, path: at.path ? `${at.path}/${part}` : part, files: [], folders: [] }; | |
| 173 | at.folders.push(next); | |
| 174 | } | |
| 175 | at = next; | |
| 176 | } | |
| 177 | at.files.push(file); | |
| 178 | } | |
| 179 | const sort = (f: RepoFolder) => { | |
| 180 | f.folders.sort((a, b) => a.name.localeCompare(b.name)); | |
| 181 | f.folders.forEach(sort); | |
| 182 | }; | |
| 183 | sort(root); | |
| 184 | return root; | |
| 185 | } |