@cfbender/cesium 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/ARCHITECTURE.md +304 -0
  2. package/CHANGELOG.md +335 -0
  3. package/LICENSE +21 -0
  4. package/README.md +479 -0
  5. package/agents/cesium.md +39 -0
  6. package/assets/styleguide.html +857 -0
  7. package/package.json +61 -0
  8. package/src/cli/commands/ls.ts +186 -0
  9. package/src/cli/commands/open.ts +208 -0
  10. package/src/cli/commands/prune.ts +348 -0
  11. package/src/cli/commands/restart.ts +38 -0
  12. package/src/cli/commands/serve.ts +214 -0
  13. package/src/cli/commands/stop.ts +130 -0
  14. package/src/cli/commands/theme.ts +333 -0
  15. package/src/cli/index.ts +78 -0
  16. package/src/config.ts +94 -0
  17. package/src/index.ts +35 -0
  18. package/src/prompt/system-fragment.md +97 -0
  19. package/src/render/client-js.ts +316 -0
  20. package/src/render/controls.ts +302 -0
  21. package/src/render/critique.ts +360 -0
  22. package/src/render/extract.ts +83 -0
  23. package/src/render/scrub.ts +141 -0
  24. package/src/render/theme.ts +712 -0
  25. package/src/render/validate.ts +524 -0
  26. package/src/render/wrap.ts +165 -0
  27. package/src/server/api.ts +166 -0
  28. package/src/server/http.ts +195 -0
  29. package/src/server/lifecycle.ts +331 -0
  30. package/src/server/stop.ts +124 -0
  31. package/src/storage/index-cache.ts +71 -0
  32. package/src/storage/index-gen.ts +447 -0
  33. package/src/storage/lock.ts +108 -0
  34. package/src/storage/mutate.ts +396 -0
  35. package/src/storage/paths.ts +159 -0
  36. package/src/storage/project-summaries.ts +19 -0
  37. package/src/storage/theme-write.ts +19 -0
  38. package/src/storage/write.ts +75 -0
  39. package/src/tools/ask.ts +353 -0
  40. package/src/tools/critique.ts +66 -0
  41. package/src/tools/publish.ts +404 -0
  42. package/src/tools/stop.ts +53 -0
  43. package/src/tools/styleguide.ts +23 -0
  44. package/src/tools/wait.ts +192 -0
@@ -0,0 +1,108 @@
1
+ // File-lock implementation to serialize concurrent index writes.
2
+
3
+ import { open, unlink, stat, utimes } from "node:fs/promises";
4
+
5
+ export interface LockHandle {
6
+ release(): Promise<void>;
7
+ }
8
+
9
+ export interface AcquireLockArgs {
10
+ lockPath: string;
11
+ timeoutMs?: number;
12
+ retryMs?: number;
13
+ staleMs?: number;
14
+ }
15
+
16
+ async function sleep(ms: number): Promise<void> {
17
+ return new Promise((resolve) => setTimeout(resolve, ms));
18
+ }
19
+
20
+ async function tryAcquireOnce(lockPath: string): Promise<"acquired" | "exists" | "disappeared"> {
21
+ try {
22
+ const fh = await open(lockPath, "wx");
23
+ const content = JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() });
24
+ await fh.writeFile(content, "utf8");
25
+ await fh.close();
26
+ return "acquired";
27
+ } catch (err) {
28
+ const nodeErr = err as NodeJS.ErrnoException;
29
+ if (nodeErr.code === "EEXIST") return "exists";
30
+ throw err;
31
+ }
32
+ }
33
+
34
+ async function checkStale(lockPath: string, staleMs: number): Promise<boolean> {
35
+ try {
36
+ const s = await stat(lockPath);
37
+ return Date.now() - s.mtimeMs > staleMs;
38
+ } catch {
39
+ return false; // disappeared between EEXIST and stat
40
+ }
41
+ }
42
+
43
+ async function stealLock(lockPath: string): Promise<void> {
44
+ await utimes(lockPath, new Date(), new Date()).catch(() => {
45
+ // ignore — another process may have stolen it
46
+ });
47
+ await unlink(lockPath).catch(() => {
48
+ // ignore — another process may have stolen it
49
+ });
50
+ }
51
+
52
+ async function acquireLoop(
53
+ lockPath: string,
54
+ deadline: number,
55
+ retryMs: number,
56
+ staleMs: number,
57
+ timeoutMs: number,
58
+ ): Promise<void> {
59
+ const result = await tryAcquireOnce(lockPath);
60
+ if (result === "acquired") return;
61
+
62
+ // Lock file exists or disappeared — check staleness
63
+ const isStale = await checkStale(lockPath, staleMs);
64
+ if (isStale) {
65
+ await stealLock(lockPath);
66
+ return acquireLoop(lockPath, deadline, retryMs, staleMs, timeoutMs);
67
+ }
68
+
69
+ if (Date.now() >= deadline) {
70
+ throw new Error(`acquireLock: timed out after ${timeoutMs}ms waiting for ${lockPath}`);
71
+ }
72
+
73
+ await sleep(retryMs);
74
+
75
+ if (Date.now() >= deadline) {
76
+ throw new Error(`acquireLock: timed out after ${timeoutMs}ms waiting for ${lockPath}`);
77
+ }
78
+
79
+ return acquireLoop(lockPath, deadline, retryMs, staleMs, timeoutMs);
80
+ }
81
+
82
+ export async function acquireLock(args: AcquireLockArgs): Promise<LockHandle> {
83
+ const { lockPath, timeoutMs = 5000, retryMs = 50, staleMs = 30_000 } = args;
84
+ const deadline = Date.now() + timeoutMs;
85
+
86
+ await acquireLoop(lockPath, deadline, retryMs, staleMs, timeoutMs);
87
+
88
+ let released = false;
89
+ return {
90
+ release: async () => {
91
+ if (released) return;
92
+ released = true;
93
+ await unlink(lockPath).catch((err: unknown) => {
94
+ const nodeErr = err as NodeJS.ErrnoException;
95
+ if (nodeErr.code !== "ENOENT") throw err;
96
+ });
97
+ },
98
+ };
99
+ }
100
+
101
+ export async function withLock<T>(args: AcquireLockArgs, fn: () => Promise<T>): Promise<T> {
102
+ const handle = await acquireLock(args);
103
+ try {
104
+ return await fn();
105
+ } finally {
106
+ await handle.release();
107
+ }
108
+ }
@@ -0,0 +1,396 @@
1
+ // Read-mutate-write pipeline for interactive artifacts.
2
+ //
3
+ // submitAnswer: locks the artifact file, validates the answer, patches the HTML
4
+ // body and the embedded cesium-meta JSON, and atomically rewrites the file.
5
+ //
6
+ // getState: reads the artifact and returns its current status/answers without
7
+ // acquiring a lock (read-only, no mutation).
8
+
9
+ import { readFile } from "node:fs/promises";
10
+ import { parseFragment, serialize, defaultTreeAdapter as ta } from "parse5";
11
+ import type { DefaultTreeAdapterTypes } from "parse5";
12
+ import { atomicWrite } from "./write.ts";
13
+ import { withLock } from "./lock.ts";
14
+ import { renderAnswered } from "../render/controls.ts";
15
+ import { validateAnswerValue } from "../render/validate.ts";
16
+ import type { Question, AnswerValue, InteractiveData } from "../render/validate.ts";
17
+
18
+ type ChildNode = DefaultTreeAdapterTypes.ChildNode;
19
+ type Element = DefaultTreeAdapterTypes.Element;
20
+
21
+ // ─── Public types ─────────────────────────────────────────────────────────────
22
+
23
+ export type SubmitAnswerInput = {
24
+ artifactPath: string;
25
+ questionId: string;
26
+ value: AnswerValue;
27
+ };
28
+
29
+ export type SubmitAnswerOutcome =
30
+ | { ok: true; status: "open" | "complete"; remaining: string[]; replacementHtml: string }
31
+ | { ok: false; reason: "not-found" }
32
+ | { ok: false; reason: "not-interactive" }
33
+ | { ok: false; reason: "session-ended"; status: "complete" | "expired" | "cancelled" }
34
+ | { ok: false; reason: "expired" }
35
+ | { ok: false; reason: "unknown-question"; questionId: string }
36
+ | { ok: false; reason: "invalid-value"; message: string };
37
+
38
+ export type StateOutcome =
39
+ | {
40
+ ok: true;
41
+ status: InteractiveData["status"];
42
+ answers: Record<string, AnswerValue>;
43
+ remaining: string[];
44
+ }
45
+ | { ok: false; reason: "not-found" | "not-interactive" };
46
+
47
+ // ─── Embedded metadata regex (mirrors storage/write.ts) ──────────────────────
48
+
49
+ const META_RE =
50
+ /<script\s[^>]*type="application\/json"[^>]*id="cesium-meta"[^>]*>([\s\S]*?)<\/script>/i;
51
+
52
+ function parseEmbeddedMeta(html: string): Record<string, unknown> | null {
53
+ const m = META_RE.exec(html);
54
+ if (!m) return null;
55
+ const raw = m[1];
56
+ if (raw === undefined) return null;
57
+ try {
58
+ const parsed: unknown = JSON.parse(raw);
59
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
60
+ return parsed as Record<string, unknown>;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function isInteractiveData(v: unknown): v is InteractiveData {
67
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
68
+ const raw = v as Record<string, unknown>;
69
+ return (
70
+ (raw["status"] === "open" ||
71
+ raw["status"] === "complete" ||
72
+ raw["status"] === "expired" ||
73
+ raw["status"] === "cancelled") &&
74
+ Array.isArray(raw["questions"])
75
+ );
76
+ }
77
+
78
+ // ─── Cross-validation beyond structural check ─────────────────────────────────
79
+
80
+ const EPSILON = 1e-9;
81
+
82
+ function crossValidate(question: Question, value: AnswerValue): string | null {
83
+ switch (question.type) {
84
+ case "pick_one": {
85
+ if (value.type !== "pick_one") return null; // structural validator catches this
86
+ const validIds = new Set(question.options.map((o) => o.id));
87
+ if (!validIds.has(value.selected)) {
88
+ return `pick_one: selected "${value.selected}" is not a valid option id`;
89
+ }
90
+ return null;
91
+ }
92
+ case "pick_many": {
93
+ if (value.type !== "pick_many") return null;
94
+ const validIds = new Set(question.options.map((o) => o.id));
95
+ for (const sel of value.selected) {
96
+ if (!validIds.has(sel)) {
97
+ return `pick_many: selected "${sel}" is not a valid option id`;
98
+ }
99
+ }
100
+ if (question.min !== undefined && value.selected.length < question.min) {
101
+ return `pick_many: at least ${question.min} selection(s) required, got ${value.selected.length}`;
102
+ }
103
+ if (question.max !== undefined && value.selected.length > question.max) {
104
+ return `pick_many: at most ${question.max} selection(s) allowed, got ${value.selected.length}`;
105
+ }
106
+ return null;
107
+ }
108
+ case "slider": {
109
+ if (value.type !== "slider") return null;
110
+ if (value.value < question.min) {
111
+ return `slider: value ${value.value} is below minimum ${question.min}`;
112
+ }
113
+ if (value.value > question.max) {
114
+ return `slider: value ${value.value} is above maximum ${question.max}`;
115
+ }
116
+ if (question.step !== undefined) {
117
+ const remainder = Math.abs((value.value - question.min) % question.step);
118
+ if (remainder > EPSILON && Math.abs(remainder - question.step) > EPSILON) {
119
+ return `slider: value ${value.value} is not aligned to step ${question.step} from ${question.min}`;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ case "react": {
125
+ if (value.type !== "react") return null;
126
+ const mode = question.mode ?? "approve";
127
+ if (mode === "thumbs") {
128
+ if (value.decision !== "up" && value.decision !== "down") {
129
+ return `react (thumbs): decision must be "up" or "down", got "${value.decision}"`;
130
+ }
131
+ } else {
132
+ // approve mode — valid decisions from controls.ts: "approve", "reject", "comment"
133
+ const validDecisions = new Set(["approve", "reject", "comment"]);
134
+ if (!validDecisions.has(value.decision)) {
135
+ return `react (approve): decision must be "approve", "reject", or "comment", got "${value.decision}"`;
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+ case "confirm":
141
+ // Structural validation is sufficient
142
+ return null;
143
+ case "ask_text":
144
+ if (value.type !== "ask_text") return null;
145
+ if (value.text === "" && !question.optional) {
146
+ return "ask_text answer cannot be empty (question is required)";
147
+ }
148
+ return null;
149
+ }
150
+ }
151
+
152
+ // ─── parse5 section replacement ───────────────────────────────────────────────
153
+
154
+ function findSectionByQuestionId(nodes: ChildNode[], questionId: string): Element | null {
155
+ for (const node of nodes) {
156
+ if (!ta.isElementNode(node)) continue;
157
+ const el = node as Element;
158
+ const tag = ta.getTagName(el);
159
+ if (tag === "section") {
160
+ const attrs = ta.getAttrList(el);
161
+ const qidAttr = attrs.find((a) => a.name === "data-question-id");
162
+ if (qidAttr?.value === questionId) {
163
+ return el;
164
+ }
165
+ }
166
+ // Recurse
167
+ const found = findSectionByQuestionId(ta.getChildNodes(el) as ChildNode[], questionId);
168
+ if (found !== null) return found;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ function replaceSectionInHtml(html: string, questionId: string, replacementHtml: string): string {
174
+ const doc = parseFragment(html);
175
+ const nodes = ta.getChildNodes(doc) as ChildNode[];
176
+ const target = findSectionByQuestionId(nodes, questionId);
177
+
178
+ if (target === null) {
179
+ // Section not found — return HTML unchanged (defensive)
180
+ return html;
181
+ }
182
+
183
+ const replacement = parseFragment(replacementHtml);
184
+ const replacementNodes = ta.getChildNodes(replacement) as ChildNode[];
185
+
186
+ const parent = ta.getParentNode(target) as Element | null;
187
+ if (parent === null) return html;
188
+
189
+ // Insert replacement nodes before the target, then remove target
190
+ for (const rn of replacementNodes) {
191
+ ta.insertBefore(parent, rn, target);
192
+ }
193
+ ta.detachNode(target);
194
+
195
+ return serialize(doc);
196
+ }
197
+
198
+ // ─── Client script removal ────────────────────────────────────────────────────
199
+
200
+ function removeClientScriptFromHtml(html: string): string {
201
+ // Remove <script data-cesium-client>...</script> when session completes.
202
+ // Uses parse5 to avoid regex brittleness with script content.
203
+ const doc = parseFragment(html);
204
+ const nodes = ta.getChildNodes(doc) as ChildNode[];
205
+
206
+ function removeClientScript(children: ChildNode[]): boolean {
207
+ for (const node of children) {
208
+ if (ta.isElementNode(node)) {
209
+ const el = node as Element;
210
+ const tag = ta.getTagName(el);
211
+ if (tag === "script") {
212
+ const attrs = ta.getAttrList(el);
213
+ const hasCesiumClient = attrs.some((a) => a.name === "data-cesium-client");
214
+ if (hasCesiumClient) {
215
+ ta.detachNode(el);
216
+ return true;
217
+ }
218
+ }
219
+ // Recurse
220
+ if (removeClientScript(ta.getChildNodes(el) as ChildNode[])) return true;
221
+ }
222
+ }
223
+ return false;
224
+ }
225
+
226
+ removeClientScript(nodes);
227
+ return serialize(doc);
228
+ }
229
+
230
+ // ─── Patch cesium-meta JSON inside the HTML string ────────────────────────────
231
+
232
+ function patchMetaInHtml(html: string, interactive: InteractiveData): string {
233
+ const m = META_RE.exec(html);
234
+ if (!m) return html;
235
+
236
+ const raw = m[1];
237
+ if (raw === undefined) return html;
238
+
239
+ let existing: unknown;
240
+ try {
241
+ existing = JSON.parse(raw);
242
+ } catch {
243
+ return html;
244
+ }
245
+
246
+ if (existing === null || typeof existing !== "object" || Array.isArray(existing)) return html;
247
+
248
+ const merged = { ...(existing as Record<string, unknown>), interactive };
249
+ const newJson = JSON.stringify(merged, null, 2).replace(/<\/script>/gi, "<\\/script>");
250
+
251
+ const fullMatch = m[0];
252
+ const newBlock = fullMatch.replace(raw, newJson);
253
+ return html.replace(fullMatch, newBlock);
254
+ }
255
+
256
+ // ─── submitAnswer ─────────────────────────────────────────────────────────────
257
+
258
+ export async function submitAnswer(input: SubmitAnswerInput): Promise<SubmitAnswerOutcome> {
259
+ const { artifactPath, questionId, value } = input;
260
+ const lockPath = `${artifactPath}.lock`;
261
+
262
+ return withLock({ lockPath }, async () => {
263
+ // 1. Read the file
264
+ let html: string;
265
+ try {
266
+ html = await readFile(artifactPath, "utf8");
267
+ } catch (err) {
268
+ const e = err as NodeJS.ErrnoException;
269
+ if (e.code === "ENOENT") return { ok: false, reason: "not-found" };
270
+ throw err;
271
+ }
272
+
273
+ // 2. Parse cesium-meta
274
+ const meta = parseEmbeddedMeta(html);
275
+ if (meta === null || !isInteractiveData(meta["interactive"])) {
276
+ return { ok: false, reason: "not-interactive" };
277
+ }
278
+
279
+ const interactive = meta["interactive"] as InteractiveData;
280
+
281
+ // 3. Check session status
282
+ if (interactive.status === "complete" || interactive.status === "cancelled") {
283
+ return { ok: false, reason: "session-ended", status: interactive.status };
284
+ }
285
+ if (interactive.status === "expired") {
286
+ return { ok: false, reason: "session-ended", status: "expired" };
287
+ }
288
+
289
+ // Check expiresAt
290
+ if (interactive.status === "open" && Date.parse(interactive.expiresAt) < Date.now()) {
291
+ // Patch status to expired and write back
292
+ interactive.status = "expired";
293
+ const patchedHtml = patchMetaInHtml(html, interactive);
294
+ await atomicWrite(artifactPath, patchedHtml);
295
+ return { ok: false, reason: "expired" };
296
+ }
297
+
298
+ // 4. Find the question
299
+ const question = interactive.questions.find((q) => q.id === questionId);
300
+ if (question === undefined) {
301
+ return { ok: false, reason: "unknown-question", questionId };
302
+ }
303
+
304
+ // 5. Validate value — structural check
305
+ const structuralResult = validateAnswerValue(question.type, value);
306
+ if (!structuralResult.ok) {
307
+ return { ok: false, reason: "invalid-value", message: structuralResult.error };
308
+ }
309
+ const validatedValue = structuralResult.value;
310
+
311
+ // Cross-checks (option membership, range, decision strings)
312
+ const crossError = crossValidate(question, validatedValue);
313
+ if (crossError !== null) {
314
+ return { ok: false, reason: "invalid-value", message: crossError };
315
+ }
316
+
317
+ // 6. Record the answer
318
+ if (interactive.answers === undefined) {
319
+ interactive.answers = {};
320
+ }
321
+ interactive.answers[questionId] = {
322
+ value: validatedValue,
323
+ answeredAt: new Date().toISOString(),
324
+ };
325
+
326
+ // 7. Compute remaining
327
+ const remaining = interactive.questions
328
+ .map((q) => q.id)
329
+ .filter((id) => interactive.answers[id] === undefined);
330
+
331
+ // 8. Compute next status
332
+ // requireAll: true → complete when no remaining
333
+ // requireAll: false → complete on first answer (MVP rule)
334
+ let nextStatus: "open" | "complete" = "open";
335
+ if (interactive.requireAll) {
336
+ if (remaining.length === 0) nextStatus = "complete";
337
+ } else {
338
+ nextStatus = "complete";
339
+ }
340
+
341
+ interactive.status = nextStatus;
342
+ if (nextStatus === "complete") {
343
+ interactive.completedAt = new Date().toISOString();
344
+ }
345
+
346
+ // 9. Patch the section HTML (replace cs-control-* with cs-answered)
347
+ const replacementHtml = renderAnswered(question, validatedValue);
348
+ let patchedHtml = replaceSectionInHtml(html, questionId, replacementHtml);
349
+
350
+ // 10. If complete, strip the client <script data-cesium-client>
351
+ if (nextStatus === "complete") {
352
+ patchedHtml = removeClientScriptFromHtml(patchedHtml);
353
+ }
354
+
355
+ // 11. Patch cesium-meta JSON
356
+ patchedHtml = patchMetaInHtml(patchedHtml, interactive);
357
+
358
+ // 12. Atomic write
359
+ await atomicWrite(artifactPath, patchedHtml);
360
+
361
+ // 13. Return outcome
362
+ return { ok: true, status: nextStatus, remaining, replacementHtml };
363
+ });
364
+ }
365
+
366
+ // ─── getState ─────────────────────────────────────────────────────────────────
367
+
368
+ export async function getState(artifactPath: string): Promise<StateOutcome> {
369
+ let html: string;
370
+ try {
371
+ html = await readFile(artifactPath, "utf8");
372
+ } catch (err) {
373
+ const e = err as NodeJS.ErrnoException;
374
+ if (e.code === "ENOENT") return { ok: false, reason: "not-found" };
375
+ throw err;
376
+ }
377
+
378
+ const meta = parseEmbeddedMeta(html);
379
+ if (meta === null || !isInteractiveData(meta["interactive"])) {
380
+ return { ok: false, reason: "not-interactive" };
381
+ }
382
+
383
+ const interactive = meta["interactive"] as InteractiveData;
384
+
385
+ // Extract answer values (drop the answeredAt wrapper)
386
+ const answers: Record<string, AnswerValue> = {};
387
+ for (const [id, entry] of Object.entries(interactive.answers)) {
388
+ answers[id] = entry.value;
389
+ }
390
+
391
+ const remaining = interactive.questions
392
+ .map((q) => q.id)
393
+ .filter((id) => interactive.answers[id] === undefined);
394
+
395
+ return { ok: true, status: interactive.status, answers, remaining };
396
+ }
@@ -0,0 +1,159 @@
1
+ // Derives the state directory, project slug, and artifact filenames.
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { basename, join } from "node:path";
5
+
6
+ export interface ProjectIdentity {
7
+ slug: string;
8
+ name: string;
9
+ cwd: string;
10
+ worktree: string | null;
11
+ gitRemote: string | null;
12
+ }
13
+
14
+ export interface DeriveIdentityArgs {
15
+ cwd: string;
16
+ gitRemote: string | null;
17
+ worktree?: string | null;
18
+ }
19
+
20
+ function sanitizeSlug(raw: string): string {
21
+ return (
22
+ raw
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, "-")
25
+ .replace(/^-+|-+$/g, "") || "project"
26
+ );
27
+ }
28
+
29
+ function parseGitRemote(remote: string): { slug: string; name: string } | null {
30
+ let normalized = remote.trim();
31
+
32
+ // SSH: git@github.com:cfb/cesium.git
33
+ const sshMatch = /^git@([^:]+):(.+?)(?:\.git)?$/.exec(normalized);
34
+ if (sshMatch) {
35
+ const host = sshMatch[1] ?? "";
36
+ const path = sshMatch[2] ?? "";
37
+ const pathParts = path.split("/").filter(Boolean);
38
+ const hostSlug = host.replace(/\./g, "-");
39
+ const slug = sanitizeSlug(`${hostSlug}-${pathParts.join("-")}`);
40
+ const name =
41
+ pathParts.length >= 2
42
+ ? `${pathParts[pathParts.length - 2]}/${pathParts[pathParts.length - 1]}`
43
+ : (pathParts[0] ?? host);
44
+ return { slug, name };
45
+ }
46
+
47
+ // HTTPS / other URL
48
+ try {
49
+ if (!normalized.includes("://")) normalized = `https://${normalized}`;
50
+ const url = new URL(normalized);
51
+ const host = url.hostname;
52
+ const pathRaw = url.pathname.replace(/\.git$/, "").replace(/^\//, "");
53
+ const pathParts = pathRaw.split("/").filter(Boolean);
54
+ const hostSlug = host.replace(/\./g, "-");
55
+ const slug = sanitizeSlug(`${hostSlug}-${pathParts.join("-")}`);
56
+ const name =
57
+ pathParts.length >= 2
58
+ ? `${pathParts[pathParts.length - 2]}/${pathParts[pathParts.length - 1]}`
59
+ : (pathParts[0] ?? host);
60
+ return { slug, name };
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ export function deriveProjectIdentity(args: DeriveIdentityArgs): ProjectIdentity {
67
+ const { cwd, gitRemote, worktree = null } = args;
68
+
69
+ if (gitRemote) {
70
+ const parsed = parseGitRemote(gitRemote);
71
+ if (parsed) {
72
+ return {
73
+ slug: parsed.slug,
74
+ name: parsed.name,
75
+ cwd,
76
+ worktree: worktree ?? null,
77
+ gitRemote,
78
+ };
79
+ }
80
+ }
81
+
82
+ // Fallback: basename + 6-char hex hash of absolute cwd
83
+ const base = basename(cwd) || "project";
84
+ const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 6);
85
+ const slug = sanitizeSlug(`${base}-${hash}`);
86
+
87
+ return {
88
+ slug,
89
+ name: base,
90
+ cwd,
91
+ worktree: worktree ?? null,
92
+ gitRemote,
93
+ };
94
+ }
95
+
96
+ export function slugifyTitle(title: string, maxLen = 60): string {
97
+ const slug = title
98
+ .toLowerCase()
99
+ .replace(/[^a-z0-9]+/g, "-")
100
+ .replace(/^-+|-+$/g, "");
101
+ const result = slug.slice(0, maxLen).replace(/-+$/, "");
102
+ return result || "untitled";
103
+ }
104
+
105
+ export interface ArtifactFilenameArgs {
106
+ title: string;
107
+ id: string;
108
+ createdAt: Date;
109
+ }
110
+
111
+ export function artifactFilename(args: ArtifactFilenameArgs): string {
112
+ const { title, id, createdAt } = args;
113
+ const iso = createdAt
114
+ .toISOString()
115
+ .replace(/\.\d{3}Z$/, "Z")
116
+ .replace(/:/g, "-");
117
+ const slug = slugifyTitle(title);
118
+ return `${iso}__${slug}__${id}.html`;
119
+ }
120
+
121
+ export interface ArtifactPaths {
122
+ stateDir: string;
123
+ projectDir: string;
124
+ artifactsDir: string;
125
+ artifactPath: string;
126
+ projectIndexPath: string;
127
+ projectIndexJsonPath: string;
128
+ globalIndexPath: string;
129
+ globalIndexJsonPath: string;
130
+ fileUrl: string;
131
+ serverPath: string;
132
+ }
133
+
134
+ export interface PathsForArgs {
135
+ stateDir: string;
136
+ projectSlug: string;
137
+ filename: string;
138
+ }
139
+
140
+ export function pathsFor(args: PathsForArgs): ArtifactPaths {
141
+ const { stateDir, projectSlug, filename } = args;
142
+ const projectDir = join(stateDir, "projects", projectSlug);
143
+ const artifactsDir = join(projectDir, "artifacts");
144
+ const artifactPath = join(artifactsDir, filename);
145
+ const serverPath = `/projects/${projectSlug}/artifacts/${filename}`;
146
+
147
+ return {
148
+ stateDir,
149
+ projectDir,
150
+ artifactsDir,
151
+ artifactPath,
152
+ projectIndexPath: join(projectDir, "index.html"),
153
+ projectIndexJsonPath: join(projectDir, "index.json"),
154
+ globalIndexPath: join(stateDir, "index.html"),
155
+ globalIndexJsonPath: join(stateDir, "index.json"),
156
+ fileUrl: `file://${artifactPath}`,
157
+ serverPath,
158
+ };
159
+ }
@@ -0,0 +1,19 @@
1
+ // Shared utility: groups a flat list of IndexEntry records into per-project summaries.
2
+ // Used by both cesium_publish and cesium_ask when regenerating the global index.
3
+
4
+ import type { IndexEntry } from "./index-cache.ts";
5
+ import { summarizeProject, type ProjectSummary } from "./index-gen.ts";
6
+
7
+ export type { ProjectSummary };
8
+
9
+ export function buildProjectSummaries(entries: IndexEntry[]): ProjectSummary[] {
10
+ const bySlug = new Map<string, { name: string; entries: IndexEntry[] }>();
11
+ for (const e of entries) {
12
+ const group = bySlug.get(e.projectSlug) ?? { name: e.projectName, entries: [] };
13
+ group.entries.push(e);
14
+ bySlug.set(e.projectSlug, group);
15
+ }
16
+ return [...bySlug.entries()].map(([slug, { name, entries: es }]) =>
17
+ summarizeProject({ slug, name, entries: es }),
18
+ );
19
+ }
@@ -0,0 +1,19 @@
1
+ // Writes theme.css to the state directory for dynamic theme support.
2
+
3
+ import { join } from "node:path";
4
+ import { themeTokensCss } from "../render/theme.ts";
5
+ import type { ThemeTokens } from "../render/theme.ts";
6
+ import { atomicWrite } from "./write.ts";
7
+
8
+ /** Returns the absolute path to the theme.css file in the given stateDir. */
9
+ export function themeCssPath(stateDir: string): string {
10
+ return join(stateDir, "theme.css");
11
+ }
12
+
13
+ /** Writes <stateDir>/theme.css with token definitions only (no framework rules).
14
+ * Atomic. Returns the absolute path. */
15
+ export async function writeThemeCss(stateDir: string, theme: ThemeTokens): Promise<string> {
16
+ const path = themeCssPath(stateDir);
17
+ await atomicWrite(path, themeTokensCss(theme) + "\n");
18
+ return path;
19
+ }