@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.
- package/ARCHITECTURE.md +304 -0
- package/CHANGELOG.md +335 -0
- package/LICENSE +21 -0
- package/README.md +479 -0
- package/agents/cesium.md +39 -0
- package/assets/styleguide.html +857 -0
- package/package.json +61 -0
- package/src/cli/commands/ls.ts +186 -0
- package/src/cli/commands/open.ts +208 -0
- package/src/cli/commands/prune.ts +348 -0
- package/src/cli/commands/restart.ts +38 -0
- package/src/cli/commands/serve.ts +214 -0
- package/src/cli/commands/stop.ts +130 -0
- package/src/cli/commands/theme.ts +333 -0
- package/src/cli/index.ts +78 -0
- package/src/config.ts +94 -0
- package/src/index.ts +35 -0
- package/src/prompt/system-fragment.md +97 -0
- package/src/render/client-js.ts +316 -0
- package/src/render/controls.ts +302 -0
- package/src/render/critique.ts +360 -0
- package/src/render/extract.ts +83 -0
- package/src/render/scrub.ts +141 -0
- package/src/render/theme.ts +712 -0
- package/src/render/validate.ts +524 -0
- package/src/render/wrap.ts +165 -0
- package/src/server/api.ts +166 -0
- package/src/server/http.ts +195 -0
- package/src/server/lifecycle.ts +331 -0
- package/src/server/stop.ts +124 -0
- package/src/storage/index-cache.ts +71 -0
- package/src/storage/index-gen.ts +447 -0
- package/src/storage/lock.ts +108 -0
- package/src/storage/mutate.ts +396 -0
- package/src/storage/paths.ts +159 -0
- package/src/storage/project-summaries.ts +19 -0
- package/src/storage/theme-write.ts +19 -0
- package/src/storage/write.ts +75 -0
- package/src/tools/ask.ts +353 -0
- package/src/tools/critique.ts +66 -0
- package/src/tools/publish.ts +404 -0
- package/src/tools/stop.ts +53 -0
- package/src/tools/styleguide.ts +23 -0
- 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, "&")
|
|
60
|
+
.replace(/</g, "<")
|
|
61
|
+
.replace(/>/g, ">")
|
|
62
|
+
.replace(/"/g, """);
|
|
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 ${esc(entry.supersedes.slice(0, 6))}</span>`
|
|
237
|
+
: "";
|
|
238
|
+
const supersededByBadge =
|
|
239
|
+
entry.supersededBy !== null
|
|
240
|
+
? ` <span class="superseded-badge">superseded by ${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" : ""} · <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)} · ${p.count} artifact${p.count !== 1 ? "s" : ""} · 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" : ""} · ${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
|
+
}
|