| 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 | } |
| 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 | } |