@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,124 @@
1
+ // Cross-process server-stop logic — shared by the CLI and the cesium_stop tool.
2
+
3
+ import { join } from "node:path";
4
+ import { unlink } from "node:fs/promises";
5
+ import { readPidFile, isAlive as defaultIsAlive } from "./lifecycle.ts";
6
+
7
+ export interface StopServerArgs {
8
+ stateDir: string;
9
+ force?: boolean;
10
+ timeoutMs?: number;
11
+ isAlive?: (pid: number) => boolean;
12
+ killProcess?: (pid: number, sig: NodeJS.Signals) => void;
13
+ sleep?: (ms: number) => Promise<void>;
14
+ }
15
+
16
+ export type StopOutcome =
17
+ | { kind: "not-running" }
18
+ | { kind: "stale"; pid: number }
19
+ | { kind: "stopped"; pid: number; port: number; signal: "SIGTERM" | "SIGKILL" }
20
+ | { kind: "permission-denied"; pid: number };
21
+
22
+ function defaultSleep(ms: number): Promise<void> {
23
+ return new Promise((r) => setTimeout(r, ms));
24
+ }
25
+
26
+ export async function stopServer(args: StopServerArgs): Promise<StopOutcome> {
27
+ const {
28
+ stateDir,
29
+ force = false,
30
+ timeoutMs = 3000,
31
+ isAlive: isAliveFn = defaultIsAlive,
32
+ killProcess: killFn = (pid: number, signal: NodeJS.Signals) => {
33
+ process.kill(pid, signal);
34
+ },
35
+ sleep: sleepFn = defaultSleep,
36
+ } = args;
37
+
38
+ const pidFilePath = join(stateDir, ".server.pid");
39
+
40
+ // 1. Read PID file
41
+ const pidContent = readPidFile(pidFilePath);
42
+ if (pidContent === null) {
43
+ return { kind: "not-running" };
44
+ }
45
+
46
+ const { pid, port } = pidContent;
47
+
48
+ // 2. Check if alive (stale PID file)
49
+ if (!isAliveFn(pid)) {
50
+ try {
51
+ await unlink(pidFilePath);
52
+ } catch {
53
+ // ENOENT is fine
54
+ }
55
+ return { kind: "stale", pid };
56
+ }
57
+
58
+ // 3. Kill the process
59
+ const doKill = (signal: NodeJS.Signals): "ok" | "permission-denied" => {
60
+ try {
61
+ killFn(pid, signal);
62
+ return "ok";
63
+ } catch (err) {
64
+ const e = err as NodeJS.ErrnoException;
65
+ if (e.code === "ESRCH") {
66
+ // Process already gone — treat as success
67
+ return "ok";
68
+ }
69
+ if (e.code === "EPERM") {
70
+ return "permission-denied";
71
+ }
72
+ // Re-throw unexpected errors
73
+ throw err;
74
+ }
75
+ };
76
+
77
+ let usedSignal: "SIGTERM" | "SIGKILL";
78
+
79
+ if (force) {
80
+ // SIGKILL immediately
81
+ const result = doKill("SIGKILL");
82
+ if (result === "permission-denied") {
83
+ return { kind: "permission-denied", pid };
84
+ }
85
+ usedSignal = "SIGKILL";
86
+ } else {
87
+ // SIGTERM first, then poll, then SIGKILL if still alive
88
+ const termResult = doKill("SIGTERM");
89
+ if (termResult === "permission-denied") {
90
+ return { kind: "permission-denied", pid };
91
+ }
92
+
93
+ // Poll every 100ms until dead or timeout — recursive helper avoids await-in-loop
94
+ const deadline = Date.now() + timeoutMs;
95
+
96
+ async function poll(): Promise<boolean> {
97
+ if (!isAliveFn(pid)) return true;
98
+ if (Date.now() >= deadline) return false;
99
+ await sleepFn(100);
100
+ return poll();
101
+ }
102
+
103
+ const died = await poll();
104
+ if (!died) {
105
+ // Escalate to SIGKILL
106
+ const killResult = doKill("SIGKILL");
107
+ if (killResult === "permission-denied") {
108
+ return { kind: "permission-denied", pid };
109
+ }
110
+ usedSignal = "SIGKILL";
111
+ } else {
112
+ usedSignal = "SIGTERM";
113
+ }
114
+ }
115
+
116
+ // 4. Remove PID file (best-effort; the server may have already done it)
117
+ try {
118
+ await unlink(pidFilePath);
119
+ } catch {
120
+ // ENOENT is fine
121
+ }
122
+
123
+ return { kind: "stopped", pid, port, signal: usedSignal };
124
+ }
@@ -0,0 +1,71 @@
1
+ // Reads and writes the per-project and global index.json cache files.
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import { atomicWrite } from "./write.ts";
5
+
6
+ export interface IndexEntry {
7
+ id: string;
8
+ title: string;
9
+ kind: string;
10
+ summary: string | null;
11
+ tags: string[];
12
+ createdAt: string;
13
+ filename: string;
14
+ supersedes: string | null;
15
+ supersededBy: string | null;
16
+ gitBranch: string | null;
17
+ gitCommit: string | null;
18
+ contentSha256: string;
19
+ projectSlug: string;
20
+ projectName: string;
21
+ bodyText: string;
22
+ }
23
+
24
+ export async function loadIndex(jsonPath: string): Promise<IndexEntry[]> {
25
+ let raw: string;
26
+ try {
27
+ raw = await readFile(jsonPath, "utf8");
28
+ } catch (err) {
29
+ const nodeErr = err as NodeJS.ErrnoException;
30
+ if (nodeErr.code === "ENOENT") return [];
31
+ throw err;
32
+ }
33
+ const parsed: unknown = JSON.parse(raw);
34
+ if (!Array.isArray(parsed)) {
35
+ throw new Error(`index.json at ${jsonPath} is not an array`);
36
+ }
37
+ // Backward-compat: entries written before v0.1.5 may lack bodyText.
38
+ // Use Object.assign to avoid the no-map-spread lint rule; assign bodyText
39
+ // default so entries from older index.json files still type-check.
40
+ return (parsed as IndexEntry[]).map((e) => {
41
+ const entry = e as IndexEntry & { bodyText?: string };
42
+ if (typeof entry.bodyText !== "string") {
43
+ entry.bodyText = "";
44
+ }
45
+ return entry;
46
+ });
47
+ }
48
+
49
+ export async function writeIndex(jsonPath: string, entries: IndexEntry[]): Promise<void> {
50
+ await atomicWrite(jsonPath, JSON.stringify(entries, null, 2));
51
+ }
52
+
53
+ export function appendEntry(entries: IndexEntry[], entry: IndexEntry): IndexEntry[] {
54
+ const next = [...entries, entry];
55
+ return next.toSorted((a: IndexEntry, b: IndexEntry) => {
56
+ const ta = new Date(a.createdAt).getTime();
57
+ const tb = new Date(b.createdAt).getTime();
58
+ return tb - ta;
59
+ });
60
+ }
61
+
62
+ export function patchEntry(
63
+ entries: IndexEntry[],
64
+ id: string,
65
+ patch: Partial<IndexEntry>,
66
+ ): IndexEntry[] {
67
+ const idx = entries.findIndex((e) => e.id === id);
68
+ if (idx === -1) return entries;
69
+ const updated = entries.map((e, i) => (i === idx ? { ...e, ...patch } : e));
70
+ return updated;
71
+ }
@@ -0,0 +1,447 @@
1
+ // Generates index.html (per-project and global) from the index.json cache.
2
+
3
+ import type { IndexEntry } from "./index-cache.ts";
4
+ import type { ThemeTokens } from "../render/theme.ts";
5
+ import { frameworkRulesCss, themeTokensCss } from "../render/theme.ts";
6
+
7
+ export interface RenderProjectIndexArgs {
8
+ projectSlug: string;
9
+ projectName: string;
10
+ entries: IndexEntry[];
11
+ theme: ThemeTokens;
12
+ /** Relative href for the dynamic theme <link> tag.
13
+ * Default: "../../theme.css" (project index context).
14
+ * Pass null to suppress the <link> entirely. */
15
+ themeCssHref?: string | null;
16
+ }
17
+
18
+ export interface ProjectSummary {
19
+ slug: string;
20
+ name: string;
21
+ count: number;
22
+ latestCreatedAt: string;
23
+ latestEntries: IndexEntry[];
24
+ }
25
+
26
+ export interface RenderGlobalIndexArgs {
27
+ projects: ProjectSummary[];
28
+ theme: ThemeTokens;
29
+ /** Relative href for the dynamic theme <link> tag.
30
+ * Default: "theme.css" (global index context).
31
+ * Pass null to suppress the <link> entirely. */
32
+ themeCssHref?: string | null;
33
+ }
34
+
35
+ export function summarizeProject(args: {
36
+ slug: string;
37
+ name: string;
38
+ entries: IndexEntry[];
39
+ topN?: number;
40
+ }): ProjectSummary {
41
+ const { slug, name, entries, topN = 5 } = args;
42
+ const sorted = [...entries].toSorted(
43
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
44
+ );
45
+ const latestCreatedAt = sorted[0]?.createdAt ?? new Date(0).toISOString();
46
+ return {
47
+ slug,
48
+ name,
49
+ count: entries.length,
50
+ latestCreatedAt,
51
+ latestEntries: sorted.slice(0, topN),
52
+ };
53
+ }
54
+
55
+ // ─── Helpers ────────────────────────────────────────────────────────────────
56
+
57
+ function esc(str: string): string {
58
+ return str
59
+ .replace(/&/g, "&amp;")
60
+ .replace(/</g, "&lt;")
61
+ .replace(/>/g, "&gt;")
62
+ .replace(/"/g, "&quot;");
63
+ }
64
+
65
+ /** Returns ISO date string for the Monday of the week containing `date` (UTC). */
66
+ function isoWeekMonday(date: Date): string {
67
+ const d = new Date(date);
68
+ const day = d.getUTCDay(); // 0=Sun,1=Mon,...,6=Sat
69
+ const diff = day === 0 ? -6 : 1 - day; // shift to Monday
70
+ d.setUTCDate(d.getUTCDate() + diff);
71
+ return d.toISOString().slice(0, 10);
72
+ }
73
+
74
+ function weekLabel(mondayIso: string, nowMondayIso: string): string {
75
+ const diff = (new Date(nowMondayIso).getTime() - new Date(mondayIso).getTime()) / (7 * 86400_000);
76
+ if (diff === 0) return "This week";
77
+ if (diff === 1) return "Last week";
78
+ if (diff === 2) return "Two weeks ago";
79
+ return `Week of ${mondayIso}`;
80
+ }
81
+
82
+ function formatDate(iso: string): string {
83
+ return new Date(iso).toISOString().slice(0, 10);
84
+ }
85
+
86
+ // ─── Index-specific CSS ──────────────────────────────────────────────────────
87
+
88
+ function indexCss(): string {
89
+ return `
90
+ /* index-page chrome */
91
+ .filter-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; align-items: center; }
92
+ .filter-chip {
93
+ display: inline-block; font-family: var(--sans); font-size: 0.8em; font-weight: 500;
94
+ background: var(--oat); border-radius: 20px; padding: 4px 14px; color: var(--ink-soft);
95
+ white-space: nowrap; cursor: pointer; border: 1.5px solid transparent; transition: all 0.15s;
96
+ }
97
+ .filter-chip:hover { border-color: var(--rule); }
98
+ .filter-chip[data-active="1"] {
99
+ background: var(--accent); color: #fff; border-color: var(--accent);
100
+ }
101
+ .search-wrap { margin-bottom: 20px; }
102
+ .search-input {
103
+ width: 100%; padding: 8px 14px; font-family: var(--sans); font-size: 0.95rem;
104
+ border: 1.5px solid var(--rule); border-radius: 12px; background: var(--surface);
105
+ color: var(--ink); outline: none;
106
+ }
107
+ .search-input:focus { border-color: var(--accent); }
108
+ .week-section { margin-bottom: 40px; }
109
+ .week-label {
110
+ font-family: var(--mono); font-size: 0.7rem; font-weight: 600;
111
+ letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted);
112
+ margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--rule);
113
+ }
114
+ .entry-card {
115
+ background: var(--surface); border: 1.5px solid var(--rule); border-radius: 12px;
116
+ padding: 18px 22px; margin-bottom: 14px; transition: box-shadow 0.15s, border-color 0.15s;
117
+ }
118
+ .entry-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.07); border-color: var(--oat); }
119
+ .entry-card a { text-decoration: none; color: inherit; }
120
+ .entry-card a:hover { opacity: 1; }
121
+ .card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
122
+ .card-date { margin-left: auto; font-family: var(--mono); font-size: 0.75rem; color: var(--muted); }
123
+ .card-title {
124
+ font-family: var(--serif); font-size: 1.1rem; font-weight: 600;
125
+ color: var(--ink); margin-bottom: 6px; line-height: 1.3;
126
+ }
127
+ .card-summary { font-size: 0.9rem; color: var(--inkSoft, var(--ink-soft)); margin-bottom: 10px; }
128
+ .card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
129
+ .card-footer { margin-top: 12px; text-align: right; }
130
+ .open-link {
131
+ font-family: var(--mono); font-size: 0.8rem; color: var(--accent);
132
+ text-decoration: none; font-weight: 600;
133
+ }
134
+ .open-link:hover { opacity: 0.8; }
135
+ .superseded-badge {
136
+ display: inline-block; font-family: var(--mono); font-size: 0.7rem; font-weight: 600;
137
+ background: var(--surface-2); border: 1px solid var(--rule); border-radius: 6px;
138
+ padding: 2px 8px; color: var(--muted);
139
+ }
140
+ [data-superseded="1"] { opacity: 0.55; }
141
+ body:not([data-show-superseded]) [data-superseded="1"] { display: none; }
142
+ .show-superseded-wrap { margin-bottom: 16px; }
143
+ .show-superseded-btn {
144
+ font-family: var(--mono); font-size: 0.75rem; color: var(--muted); background: none;
145
+ border: 1px solid var(--rule); border-radius: 6px; padding: 4px 10px; cursor: pointer;
146
+ }
147
+ .empty-state {
148
+ background: var(--surface); border: 1.5px solid var(--rule); border-radius: 12px;
149
+ padding: 40px 22px; text-align: center; color: var(--muted);
150
+ font-family: var(--sans); font-size: 0.95rem; margin-top: 24px;
151
+ }
152
+ /* grid for wider screens */
153
+ @media (min-width: 720px) {
154
+ .cards-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
155
+ .cards-grid .entry-card { margin-bottom: 0; }
156
+ }
157
+ /* project cards in global index */
158
+ .project-card {
159
+ background: var(--surface); border: 1.5px solid var(--rule); border-radius: 12px;
160
+ padding: 20px 24px; margin-bottom: 16px; text-decoration: none; display: block; color: inherit;
161
+ }
162
+ .project-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.07); border-color: var(--oat); opacity: 1; }
163
+ .project-card-name { font-family: var(--serif); font-size: 1.25rem; font-weight: 600; color: var(--ink); margin-bottom: 4px; }
164
+ .project-card-meta { font-family: var(--mono); font-size: 0.75rem; color: var(--muted); margin-bottom: 12px; }
165
+ .project-recent-list { list-style: none; padding: 0; margin: 0; }
166
+ .project-recent-list li {
167
+ display: flex; align-items: baseline; gap: 8px; padding: 4px 0;
168
+ border-bottom: 1px solid var(--rule); font-size: 0.875rem;
169
+ }
170
+ .project-recent-list li:last-child { border-bottom: none; }
171
+ .project-recent-title { color: var(--ink-soft); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
172
+ .project-recent-date { font-family: var(--mono); font-size: 0.7rem; color: var(--muted); white-space: nowrap; }
173
+ `;
174
+ }
175
+
176
+ // ─── Inline JS ───────────────────────────────────────────────────────────────
177
+
178
+ function indexJs(): string {
179
+ return `
180
+ (function() {
181
+ var body = document.body;
182
+ // Kind filter chips
183
+ document.querySelectorAll('.filter-chip').forEach(function(chip) {
184
+ chip.addEventListener('click', function() {
185
+ document.querySelectorAll('.filter-chip').forEach(function(c) { c.setAttribute('data-active','0'); });
186
+ chip.setAttribute('data-active','1');
187
+ var kind = chip.getAttribute('data-kind') || '';
188
+ if (kind) { body.setAttribute('data-active-kind', kind); }
189
+ else { body.removeAttribute('data-active-kind'); }
190
+ applyFilters();
191
+ });
192
+ });
193
+ // Search
194
+ var search = document.getElementById('cesium-search');
195
+ if (search) {
196
+ search.addEventListener('input', function() { applyFilters(); });
197
+ }
198
+ // Show superseded toggle
199
+ var toggleBtn = document.getElementById('cesium-toggle-superseded');
200
+ if (toggleBtn) {
201
+ toggleBtn.addEventListener('click', function() {
202
+ if (body.hasAttribute('data-show-superseded')) {
203
+ body.removeAttribute('data-show-superseded');
204
+ toggleBtn.textContent = 'Show superseded versions';
205
+ } else {
206
+ body.setAttribute('data-show-superseded','1');
207
+ toggleBtn.textContent = 'Hide superseded versions';
208
+ }
209
+ });
210
+ }
211
+ function applyFilters() {
212
+ var activeKind = body.getAttribute('data-active-kind') || '';
213
+ var query = (search ? search.value.toLowerCase() : '');
214
+ document.querySelectorAll('[data-card]').forEach(function(card) {
215
+ var kind = card.getAttribute('data-kind') || '';
216
+ var titleLower = card.getAttribute('data-title-lower') || '';
217
+ var bodyText = card.getAttribute('data-body-text') || '';
218
+ var kindMatch = !activeKind || kind === activeKind;
219
+ var haystack = titleLower + ' ' + bodyText;
220
+ var searchMatch = !query || haystack.includes(query);
221
+ card.style.display = (kindMatch && searchMatch) ? '' : 'none';
222
+ });
223
+ }
224
+ })();
225
+ `;
226
+ }
227
+
228
+ // ─── Card rendering ──────────────────────────────────────────────────────────
229
+
230
+ function renderEntryCard(entry: IndexEntry): string {
231
+ const isSuperseded = entry.supersededBy !== null ? "1" : "0";
232
+ const kindPill = `<span class="pill">${esc(entry.kind)}</span>`;
233
+ const dateStr = `<span class="card-date">${esc(formatDate(entry.createdAt))}</span>`;
234
+ const supersededBadge =
235
+ entry.supersedes !== null
236
+ ? ` <span class="superseded-badge">revises&nbsp;${esc(entry.supersedes.slice(0, 6))}</span>`
237
+ : "";
238
+ const supersededByBadge =
239
+ entry.supersededBy !== null
240
+ ? ` <span class="superseded-badge">superseded&nbsp;by&nbsp;${esc(entry.supersededBy.slice(0, 6))}</span>`
241
+ : "";
242
+
243
+ const summaryHtml =
244
+ entry.summary !== null ? `<div class="card-summary">${esc(entry.summary)}</div>` : "";
245
+
246
+ const tagsHtml =
247
+ entry.tags.length > 0
248
+ ? `<div class="card-tags">${entry.tags.map((t) => `<span class="tag">${esc(t)}</span>`).join(" ")}</div>`
249
+ : "";
250
+
251
+ return `<div class="entry-card" data-card data-kind="${esc(entry.kind)}" data-title-lower="${esc(entry.title.toLowerCase())}" data-body-text="${esc(entry.bodyText.toLowerCase())}" data-superseded="${isSuperseded}">
252
+ <div class="card-top">${kindPill}${supersededBadge}${supersededByBadge}${dateStr}</div>
253
+ <div class="card-title"><a href="artifacts/${esc(entry.filename)}">${esc(entry.title)}</a></div>
254
+ ${summaryHtml}${tagsHtml}
255
+ <div class="card-footer"><a class="open-link" href="artifacts/${esc(entry.filename)}">Open →</a></div>
256
+ </div>`;
257
+ }
258
+
259
+ // ─── renderProjectIndex ──────────────────────────────────────────────────────
260
+
261
+ export function renderProjectIndex(args: RenderProjectIndexArgs): string {
262
+ const { projectSlug, projectName, entries, theme } = args;
263
+ const href =
264
+ args.themeCssHref === undefined
265
+ ? "../../theme.css"
266
+ : args.themeCssHref === ""
267
+ ? "../../theme.css"
268
+ : args.themeCssHref;
269
+ const suppressLink = args.themeCssHref === null;
270
+
271
+ const rules = frameworkRulesCss();
272
+ const tokens = themeTokensCss(theme);
273
+ const iCss = indexCss();
274
+ const iJs = indexJs();
275
+
276
+ const linkTag = suppressLink ? "" : `\n <link rel="stylesheet" href="${href}">`;
277
+
278
+ // Sort entries newest-first
279
+ const sorted = [...entries].toSorted(
280
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
281
+ );
282
+
283
+ // Unique kinds
284
+ const kinds = [...new Set(sorted.map((e) => e.kind))];
285
+
286
+ // Filter chips
287
+ const chipAll = `<button class="filter-chip" data-active="1" data-kind="">All</button>`;
288
+ const kindChips = kinds
289
+ .map(
290
+ (k) => `<button class="filter-chip" data-active="0" data-kind="${esc(k)}">${esc(k)}</button>`,
291
+ )
292
+ .join("");
293
+ const filterRow = `<div class="filter-row">${chipAll}${kindChips}</div>`;
294
+ const searchBar = `<div class="search-wrap"><input id="cesium-search" class="search-input" type="search" placeholder="Filter by title or content…" autocomplete="off"></div>`;
295
+
296
+ // Has superseded entries?
297
+ const hasSuperseded = sorted.some((e) => e.supersededBy !== null);
298
+ const supersededToggle = hasSuperseded
299
+ ? `<div class="show-superseded-wrap"><button id="cesium-toggle-superseded" class="show-superseded-btn">Show superseded versions</button></div>`
300
+ : "";
301
+
302
+ // Group by ISO week (Monday)
303
+ const nowMonday = isoWeekMonday(new Date());
304
+ const weekMap = new Map<string, IndexEntry[]>();
305
+ for (const entry of sorted) {
306
+ const monday = isoWeekMonday(new Date(entry.createdAt));
307
+ const group = weekMap.get(monday) ?? [];
308
+ group.push(entry);
309
+ weekMap.set(monday, group);
310
+ }
311
+ // Sort weeks newest-first
312
+ const weeks = [...weekMap.entries()].toSorted(
313
+ ([a], [b]) => new Date(b).getTime() - new Date(a).getTime(),
314
+ );
315
+
316
+ let bodyContent: string;
317
+ if (sorted.length === 0) {
318
+ bodyContent = `<div class="empty-state">No artifacts published yet.</div>`;
319
+ } else {
320
+ bodyContent = weeks
321
+ .map(([monday, weekEntries]) => {
322
+ const label = weekLabel(monday, nowMonday);
323
+ const cardsHtml = weekEntries.map(renderEntryCard).join("\n");
324
+ return `<div class="week-section">
325
+ <div class="week-label">${esc(label)}</div>
326
+ <div class="cards-grid">
327
+ ${cardsHtml}
328
+ </div>
329
+ </div>`;
330
+ })
331
+ .join("\n");
332
+ }
333
+
334
+ const subhead = `<p style="color:var(--muted);font-family:var(--mono);font-size:0.8rem;margin-top:-0.5em;margin-bottom:1.5em;">
335
+ ${sorted.length} artifact${sorted.length !== 1 ? "s" : ""} &nbsp;·&nbsp; <span class="pill">${esc(projectSlug)}</span>
336
+ </p>`;
337
+
338
+ const footer = `<footer class="byline">
339
+ <span>${esc(projectSlug)}</span>
340
+ <span><a href="../../index.html">← All projects</a></span>
341
+ </footer>`;
342
+
343
+ return `<!doctype html>
344
+ <html lang="en">
345
+ <head>
346
+ <meta charset="utf-8">
347
+ <meta name="viewport" content="width=device-width, initial-scale=1">
348
+ <title>${esc(projectName)} · cesium</title>
349
+ <style>${rules}
350
+ /* fallback theme tokens — used when theme.css is missing or unreachable */
351
+ ${tokens}${iCss}</style>${linkTag}
352
+ </head>
353
+ <body>
354
+ <div class="page">
355
+ <p class="eyebrow">cesium · project</p>
356
+ <h1 class="h-display">${esc(projectName)}</h1>
357
+ ${subhead}
358
+ ${filterRow}
359
+ ${searchBar}
360
+ ${supersededToggle}
361
+ ${bodyContent}
362
+ ${footer}
363
+ </div>
364
+ <script>${iJs}</script>
365
+ </body>
366
+ </html>`;
367
+ }
368
+
369
+ // ─── renderGlobalIndex ───────────────────────────────────────────────────────
370
+
371
+ export function renderGlobalIndex(args: RenderGlobalIndexArgs): string {
372
+ const { projects, theme } = args;
373
+ const href =
374
+ args.themeCssHref === undefined
375
+ ? "theme.css"
376
+ : args.themeCssHref === ""
377
+ ? "theme.css"
378
+ : args.themeCssHref;
379
+ const suppressLink = args.themeCssHref === null;
380
+
381
+ const rules = frameworkRulesCss();
382
+ const tokens = themeTokensCss(theme);
383
+ const iCss = indexCss();
384
+
385
+ const linkTag = suppressLink ? "" : `\n <link rel="stylesheet" href="${href}">`;
386
+
387
+ const sorted = [...projects].toSorted(
388
+ (a, b) => new Date(b.latestCreatedAt).getTime() - new Date(a.latestCreatedAt).getTime(),
389
+ );
390
+
391
+ const total = projects.reduce((sum, p) => sum + p.count, 0);
392
+
393
+ const projectCards = sorted.map((p) => {
394
+ const recentItems = p.latestEntries
395
+ .map(
396
+ (e) =>
397
+ `<li><span class="pill" style="font-size:0.7em">${esc(e.kind)}</span> <span class="project-recent-title">${esc(e.title)}</span> <span class="project-recent-date">${esc(formatDate(e.createdAt))}</span></li>`,
398
+ )
399
+ .join("\n");
400
+ const recentList =
401
+ p.latestEntries.length > 0
402
+ ? `<ul class="project-recent-list">${recentItems}</ul>`
403
+ : `<p style="color:var(--muted);font-size:0.85rem;margin:0">No artifacts yet.</p>`;
404
+
405
+ return `<a class="project-card" href="projects/${esc(p.slug)}/index.html">
406
+ <div class="project-card-name">${esc(p.name)}</div>
407
+ <div class="project-card-meta">${esc(p.slug)} &nbsp;·&nbsp; ${p.count} artifact${p.count !== 1 ? "s" : ""} &nbsp;·&nbsp; latest ${esc(formatDate(p.latestCreatedAt))}</div>
408
+ ${recentList}
409
+ </a>`;
410
+ });
411
+
412
+ let bodyContent: string;
413
+ if (sorted.length === 0) {
414
+ bodyContent = `<div class="empty-state">No projects published yet.</div>`;
415
+ } else {
416
+ bodyContent = projectCards.join("\n");
417
+ }
418
+
419
+ const subhead = `<p style="color:var(--muted);font-family:var(--mono);font-size:0.8rem;margin-top:-0.5em;margin-bottom:1.5em;">
420
+ ${sorted.length} project${sorted.length !== 1 ? "s" : ""} &nbsp;·&nbsp; ${total} artifact${total !== 1 ? "s" : ""}
421
+ </p>`;
422
+
423
+ const footer = `<footer class="byline">
424
+ <span>cesium v0.0.0</span>
425
+ </footer>`;
426
+
427
+ return `<!doctype html>
428
+ <html lang="en">
429
+ <head>
430
+ <meta charset="utf-8">
431
+ <meta name="viewport" content="width=device-width, initial-scale=1">
432
+ <title>All projects · cesium</title>
433
+ <style>${rules}
434
+ /* fallback theme tokens — used when theme.css is missing or unreachable */
435
+ ${tokens}${iCss}</style>${linkTag}
436
+ </head>
437
+ <body>
438
+ <div class="page">
439
+ <p class="eyebrow">cesium</p>
440
+ <h1 class="h-display">All projects</h1>
441
+ ${subhead}
442
+ ${bodyContent}
443
+ ${footer}
444
+ </div>
445
+ </body>
446
+ </html>`;
447
+ }