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.

174 linesCodeBlameRaw
1/**
2 * Folio lists' small rules, apart from where rows come from: cleaning
3 * input, the paging cursor, sidebar trees and the "Shared" section's
4 * tops, and a tree's depth. Pure.
5 */
6import type { DocEditTarget, FolioTreeNode } from "@g1t/contracts";
7
8import { pageSlug } from "../slugs.ts";
9
10/** The longest title, in characters (FOLIO_MAX_TITLE). */
11export const MAX_TITLE = 200;
12/** The deepest a folio tree goes (FOLIO_MAX_DEPTH). */
13export const MAX_DEPTH = 10;
14/** A page of a list, unless asked for fewer (at most FOLIO_LIST_MAX). */
15export const DEFAULT_LIMIT = 30;
16export const MAX_LIMIT = 100;
17/** A note on an edit, a template's description: at most this long. */
18export const MAX_NOTE = 500;
19/**
20 * The largest saved document a page carries to the browser (base64, in
21 * the page's data). Past it the editor waits for the room, as it did for
22 * every doc before the state was kept.
23 */
24export const MAX_PAGE_STATE = 512 * 1024;
25
26/** A folio's saved Yjs state as the page carries it: base64, or null when none is kept or it is too large. */
27export function pageState(state: ArrayBuffer | ArrayLike<number> | null | undefined): string | null {
28 if (!state) return null;
29 const bytes = new Uint8Array(state as ArrayBuffer);
30 if (!bytes.byteLength || bytes.byteLength > MAX_PAGE_STATE) return null;
31 let binary = "";
32 for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
33 return btoa(binary);
34}
35
36export function cleanTitle(title: unknown, max = MAX_TITLE): string {
37 return [
38 ...String(title ?? "")
39 .replace(/\s+/g, " ")
40 .trim(),
41 ]
42 .slice(0, max)
43 .join("");
44}
45
46/** One emoji (or a few characters), or null. */
47export function cleanIcon(icon: unknown): string | null {
48 const s = String(icon ?? "").trim();
49 return s ? [...s].slice(0, 4).join("") : null;
50}
51
52export function cleanCover(cover: unknown): string | null {
53 const s = String(cover ?? "").trim();
54 if (!s) return null;
55 if (/^gradient:\d{1,2}$/.test(s)) return s;
56 if (/^https:\/\/[^\s"'<>]{1,500}$/.test(s)) return s;
57 return null;
58}
59
60export function cleanNote(note: unknown): string | null {
61 const s = String(note ?? "").trim();
62 return s ? s.slice(0, MAX_NOTE) : null;
63}
64
65/** Where it was written up from: a link on this site only. */
66export function cleanSource(source: unknown): { title: string; href: string } | null {
67 const s = source as { title?: unknown; href?: unknown } | null;
68 if (!s || typeof s !== "object" || typeof s.href !== "string") return null;
69 const href = s.href.trim();
70 if (!href.startsWith("/") || href.startsWith("//") || href.length > 500 || /[\s"'<>]/.test(href)) return null;
71 return { title: cleanTitle(s.title || "A conversation", 120) || "A conversation", href };
72}
73
74export function cleanTarget(target: unknown): DocEditTarget | null {
75 const t = target as DocEditTarget | null;
76 if (!t || typeof t !== "object") return null;
77 switch (t.kind) {
78 case "append":
79 case "document":
80 return { kind: t.kind };
81 case "section":
82 return typeof t.heading === "string" && t.heading.trim() ? { kind: "section", heading: t.heading.trim().slice(0, 300) } : null;
83 case "blocks":
84 return typeof t.from_block === "string" && typeof t.to_block === "string" ? { kind: "blocks", from_block: t.from_block, to_block: t.to_block } : null;
85 default:
86 return null;
87 }
88}
89
90export function listLimit(limit: unknown): number {
91 const n = Math.floor(Number(limit));
92 if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
93 return Math.min(n, MAX_LIMIT);
94}
95
96/** Where the next page of a list starts: after this sort key and id. */
97export type Cursor = { k: string; id: string };
98
99export function encodeCursor(cursor: Cursor): string {
100 return btoa(JSON.stringify([cursor.k, cursor.id]))
101 .replace(/\+/g, "-")
102 .replace(/\//g, "_")
103 .replace(/=+$/, "");
104}
105
106export function decodeCursor(value: unknown): Cursor | null {
107 if (typeof value !== "string" || !value || value.length > 400) return null;
108 try {
109 const parsed = JSON.parse(atob(value.replace(/-/g, "+").replace(/_/g, "/"))) as unknown;
110 if (Array.isArray(parsed) && typeof parsed[0] === "string" && typeof parsed[1] === "string") return { k: parsed[0], id: parsed[1] };
111 } catch {
112 // Not one of ours.
113 }
114 return null;
115}
116
117/** A folio's last address segment (`<title-slug>-<id>`), as `folioSlug` in contracts. */
118export function slugOf(title: string, id: string): string {
119 return pageSlug(title, id);
120}
121
122type TreeRow = {
123 id: string;
124 kind: FolioTreeNode["kind"];
125 parent_id: string | null;
126 position: number;
127 title: string;
128 icon: string | null;
129 inherit: number | boolean;
130};
131
132/**
133 * A sidebar tree: the rows a reader may see, each under its parent when
134 * they may see the parent too, otherwise at the top. Ordered by position.
135 */
136export function treeNodes(rows: readonly TreeRow[], stale: ReadonlySet<string> = new Set()): FolioTreeNode[] {
137 const shown = new Set(rows.map((r) => r.id));
138 return [...rows]
139 .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id))
140 .map((r) => ({
141 id: r.id,
142 kind: r.kind,
143 parent_id: r.parent_id && shown.has(r.parent_id) ? r.parent_id : null,
144 position: r.position,
145 title: r.title,
146 icon: r.icon,
147 slug: slugOf(r.title, r.id),
148 restricted: !r.inherit && !!r.parent_id,
149 ...(stale.has(r.id) ? { stale: true } : {}),
150 }));
151}
152
153/**
154 * The tops of what is shared with someone: of the folios they can read
155 * that aren't theirs, those whose parent they can't read (or that have
156 * none), leaving out anything already in their other sections.
157 */
158export function sharedTops<T extends { id: string; parent_id: string | null }>(readable: readonly T[], elsewhere: ReadonlySet<string>): T[] {
159 const ids = new Set(readable.map((r) => r.id));
160 return readable.filter((r) => !elsewhere.has(r.id) && (!r.parent_id || (!ids.has(r.parent_id) && !elsewhere.has(r.parent_id))));
161}
162
163/** How deep a folio is: 1 at the top. */
164export function depthOf(path: string): number {
165 return String(path ?? "")
166 .split("/")
167 .filter(Boolean).length;
168}
169
170/** How many levels a subtree spans below its top (0 for a leaf), from its rows' paths. */
171export function subtreeHeight(top: { path: string }, rows: readonly { path: string }[]): number {
172 const base = depthOf(top.path);
173 return rows.reduce((h, r) => Math.max(h, depthOf(r.path) - base), 0);
174}