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.

130 linesCodeBlameRaw
1/**
2 * The page tree: order within a parent, moving, and the paths a space's
3 * export uses. Pure.
4 *
5 * Order is a number per page (`position`), compared within one parent. A
6 * page moved between two others takes the midpoint, so a move writes one
7 * row; when two neighbours get too close, the parent's children are
8 * renumbered.
9 */
10
11export type TreeRow = { id: string; parent_id: string | null; position: number };
12
13/** The gap between consecutive positions after renumbering. */
14export const STEP = 1024;
15/** Below this gap, midpoints lose precision: renumber instead. */
16const MIN_GAP = 1e-6;
17
18/** The children of `parent`, in order. */
19export function childrenOf<T extends TreeRow>(rows: T[], parent: string | null): T[] {
20 return rows.filter((r) => r.parent_id === parent).sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
21}
22
23/** A position at the end of `parent`'s children. */
24export function lastPosition(rows: TreeRow[], parent: string | null): number {
25 const kids = childrenOf(rows, parent);
26 return kids.length ? kids[kids.length - 1]!.position + STEP : STEP;
27}
28
29/**
30 * Where `id` goes to sit under `parent` before `beforeId` (null: at the
31 * end): its new position, and any siblings that had to be renumbered to
32 * make room (`renumber`), as id → position.
33 */
34export function placeBefore(rows: TreeRow[], id: string, parent: string | null, beforeId: string | null): { position: number; renumber: Map<string, number> } {
35 const siblings = childrenOf(rows, parent).filter((r) => r.id !== id);
36 const at = beforeId ? siblings.findIndex((r) => r.id === beforeId) : -1;
37 if (at < 0) {
38 const last = siblings[siblings.length - 1];
39 return { position: last ? last.position + STEP : STEP, renumber: new Map() };
40 }
41 const next = siblings[at]!.position;
42 const prev = at > 0 ? siblings[at - 1]!.position : next - 2 * STEP;
43 if (next - prev > MIN_GAP * 2) return { position: (prev + next) / 2, renumber: new Map() };
44 // Too close: renumber every sibling, leaving a slot before `beforeId`.
45 const renumber = new Map<string, number>();
46 let position = 0;
47 let mine = 0;
48 for (let i = 0; i < siblings.length; i++) {
49 if (i === at) {
50 position += STEP;
51 mine = position;
52 }
53 position += STEP;
54 renumber.set(siblings[i]!.id, position);
55 }
56 return { position: mine, renumber };
57}
58
59/** Whether making `parent` the parent of `id` would put a page under itself. */
60export function wouldCycle(rows: TreeRow[], id: string, parent: string | null): boolean {
61 const byId = new Map(rows.map((r) => [r.id, r]));
62 let at = parent;
63 const seen = new Set<string>();
64 while (at) {
65 if (at === id) return true;
66 if (seen.has(at)) return true;
67 seen.add(at);
68 at = byId.get(at)?.parent_id ?? null;
69 }
70 return false;
71}
72
73/** `id` and every page under it. */
74export function descendants(rows: TreeRow[], id: string): string[] {
75 const out = [id];
76 for (let i = 0; i < out.length; i++) {
77 for (const r of rows) if (r.parent_id === out[i]) out.push(r.id);
78 }
79 return out;
80}
81
82/** A page's ancestors, root first (not the page itself). */
83export function ancestors<T extends TreeRow>(rows: T[], id: string): T[] {
84 const byId = new Map(rows.map((r) => [r.id, r]));
85 const out: T[] = [];
86 const seen = new Set<string>([id]);
87 let at = byId.get(id)?.parent_id ?? null;
88 while (at && !seen.has(at)) {
89 seen.add(at);
90 const row = byId.get(at);
91 if (!row) break;
92 out.unshift(row);
93 at = row.parent_id;
94 }
95 return out;
96}
97
98/**
99 * Paths for a space's export: each page at `<title>.md`, and a page with
100 * children also as a folder of the same name holding them. Names are made
101 * safe for file systems and unique among siblings.
102 */
103export function exportPaths<T extends TreeRow & { title: string }>(rows: T[]): Map<string, string> {
104 const out = new Map<string, string>();
105 const walk = (parent: string | null, dir: string) => {
106 const used = new Set<string>();
107 for (const row of childrenOf(rows, parent)) {
108 const base = fileName(row.title);
109 let name = base;
110 for (let n = 2; used.has(name.toLowerCase()); n++) name = `${base} (${n})`;
111 used.add(name.toLowerCase());
112 out.set(row.id, `${dir}${name}.md`);
113 walk(row.id, `${dir}${name}/`);
114 }
115 };
116 walk(null, "");
117 return out;
118}
119
120/** A title as a file name: no path separators or characters file systems refuse. */
121export function fileName(title: string): string {
122 const name = title
123 .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, " ")
124 .replace(/\s+/g, " ")
125 .trim()
126 .replace(/^\.+/, "")
127 .slice(0, 100)
128 .trim();
129 return name || "Untitled";
130}