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.
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 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 | */ | |
| 6 | import type { DocEditTarget, FolioTreeNode } from "@g1t/contracts"; | |
| 7 | ||
| 8 | import { pageSlug } from "../slugs.ts"; | |
| 9 | ||
| 10 | /** The longest title, in characters (FOLIO_MAX_TITLE). */ | |
| 11 | export const MAX_TITLE = 200; | |
| 12 | /** The deepest a folio tree goes (FOLIO_MAX_DEPTH). */ | |
| 13 | export const MAX_DEPTH = 10; | |
| 14 | /** A page of a list, unless asked for fewer (at most FOLIO_LIST_MAX). */ | |
| 15 | export const DEFAULT_LIMIT = 30; | |
| 16 | export const MAX_LIMIT = 100; | |
| 17 | /** A note on an edit, a template's description: at most this long. */ | |
| 18 | export const MAX_NOTE = 500; | |
| 19 | ||
| 20 | export function cleanTitle(title: unknown, max = MAX_TITLE): string { | |
| 21 | return [...String(title ?? "").replace(/\s+/g, " ").trim()].slice(0, max).join(""); | |
| 22 | } | |
| 23 | ||
| 24 | /** One emoji (or a few characters), or null. */ | |
| 25 | export function cleanIcon(icon: unknown): string | null { | |
| 26 | const s = String(icon ?? "").trim(); | |
| 27 | return s ? [...s].slice(0, 4).join("") : null; | |
| 28 | } | |
| 29 | ||
| 30 | export function cleanCover(cover: unknown): string | null { | |
| 31 | const s = String(cover ?? "").trim(); | |
| 32 | if (!s) return null; | |
| 33 | if (/^gradient:\d{1,2}$/.test(s)) return s; | |
| 34 | if (/^https:\/\/[^\s"'<>]{1,500}$/.test(s)) return s; | |
| 35 | return null; | |
| 36 | } | |
| 37 | ||
| 38 | export function cleanNote(note: unknown): string | null { | |
| 39 | const s = String(note ?? "").trim(); | |
| 40 | return s ? s.slice(0, MAX_NOTE) : null; | |
| 41 | } | |
| 42 | ||
| 43 | /** Where it was written up from: a link on this site only. */ | |
| 44 | export function cleanSource(source: unknown): { title: string; href: string } | null { | |
| 45 | const s = source as { title?: unknown; href?: unknown } | null; | |
| 46 | if (!s || typeof s !== "object" || typeof s.href !== "string") return null; | |
| 47 | const href = s.href.trim(); | |
| 48 | if (!href.startsWith("/") || href.startsWith("//") || href.length > 500 || /[\s"'<>]/.test(href)) return null; | |
| 49 | return { title: cleanTitle(s.title || "A conversation", 120) || "A conversation", href }; | |
| 50 | } | |
| 51 | ||
| 52 | export function cleanTarget(target: unknown): DocEditTarget | null { | |
| 53 | const t = target as DocEditTarget | null; | |
| 54 | if (!t || typeof t !== "object") return null; | |
| 55 | switch (t.kind) { | |
| 56 | case "append": | |
| 57 | case "document": | |
| 58 | return { kind: t.kind }; | |
| 59 | case "section": | |
| 60 | return typeof t.heading === "string" && t.heading.trim() ? { kind: "section", heading: t.heading.trim().slice(0, 300) } : null; | |
| 61 | case "blocks": | |
| 62 | return typeof t.from_block === "string" && typeof t.to_block === "string" ? { kind: "blocks", from_block: t.from_block, to_block: t.to_block } : null; | |
| 63 | default: | |
| 64 | return null; | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | export function listLimit(limit: unknown): number { | |
| 69 | const n = Math.floor(Number(limit)); | |
| 70 | if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT; | |
| 71 | return Math.min(n, MAX_LIMIT); | |
| 72 | } | |
| 73 | ||
| 74 | /** Where the next page of a list starts: after this sort key and id. */ | |
| 75 | export type Cursor = { k: string; id: string }; | |
| 76 | ||
| 77 | export function encodeCursor(cursor: Cursor): string { | |
| 78 | return btoa(JSON.stringify([cursor.k, cursor.id])).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 79 | } | |
| 80 | ||
| 81 | export function decodeCursor(value: unknown): Cursor | null { | |
| 82 | if (typeof value !== "string" || !value || value.length > 400) return null; | |
| 83 | try { | |
| 84 | const parsed = JSON.parse(atob(value.replace(/-/g, "+").replace(/_/g, "/"))) as unknown; | |
| 85 | if (Array.isArray(parsed) && typeof parsed[0] === "string" && typeof parsed[1] === "string") return { k: parsed[0], id: parsed[1] }; | |
| 86 | } catch { | |
| 87 | // Not one of ours. | |
| 88 | } | |
| 89 | return null; | |
| 90 | } | |
| 91 | ||
| 92 | /** A folio's last address segment (`<title-slug>-<id>`), as `folioSlug` in contracts. */ | |
| 93 | export function slugOf(title: string, id: string): string { | |
| 94 | return pageSlug(title, id); | |
| 95 | } | |
| 96 | ||
| 97 | type TreeRow = { id: string; kind: FolioTreeNode["kind"]; parent_id: string | null; position: number; title: string; icon: string | null; inherit: number | boolean }; | |
| 98 | ||
| 99 | /** | |
| 100 | * A sidebar tree: the rows a reader may see, each under its parent when | |
| 101 | * they may see the parent too, otherwise at the top. Ordered by position. | |
| 102 | */ | |
| 103 | export function treeNodes(rows: readonly TreeRow[], stale: ReadonlySet<string> = new Set()): FolioTreeNode[] { | |
| 104 | const shown = new Set(rows.map((r) => r.id)); | |
| 105 | return [...rows] | |
| 106 | .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id)) | |
| 107 | .map((r) => ({ | |
| 108 | id: r.id, | |
| 109 | kind: r.kind, | |
| 110 | parent_id: r.parent_id && shown.has(r.parent_id) ? r.parent_id : null, | |
| 111 | position: r.position, | |
| 112 | title: r.title, | |
| 113 | icon: r.icon, | |
| 114 | slug: slugOf(r.title, r.id), | |
| 115 | restricted: !r.inherit && !!r.parent_id, | |
| 116 | ...(stale.has(r.id) ? { stale: true } : {}), | |
| 117 | })); | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * The tops of what is shared with someone: of the folios they can read | |
| 122 | * that aren't theirs, those whose parent they can't read (or that have | |
| 123 | * none), leaving out anything already in their other sections. | |
| 124 | */ | |
| 125 | export function sharedTops<T extends { id: string; parent_id: string | null }>(readable: readonly T[], elsewhere: ReadonlySet<string>): T[] { | |
| 126 | const ids = new Set(readable.map((r) => r.id)); | |
| 127 | return readable.filter((r) => !elsewhere.has(r.id) && (!r.parent_id || (!ids.has(r.parent_id) && !elsewhere.has(r.parent_id)))); | |
| 128 | } | |
| 129 | ||
| 130 | /** How deep a folio is: 1 at the top. */ | |
| 131 | export function depthOf(path: string): number { | |
| 132 | return String(path ?? "") | |
| 133 | .split("/") | |
| 134 | .filter(Boolean).length; | |
| 135 | } | |
| 136 | ||
| 137 | /** How many levels a subtree spans below its top (0 for a leaf), from its rows' paths. */ | |
| 138 | export function subtreeHeight(top: { path: string }, rows: readonly { path: string }[]): number { | |
| 139 | const base = depthOf(top.path); | |
| 140 | return rows.reduce((h, r) => Math.max(h, depthOf(r.path) - base), 0); | |
| 141 | } |