@cfbender/cesium 0.5.1 → 0.6.0

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 (42) hide show
  1. package/CHANGELOG.md +97 -3
  2. package/README.md +8 -8
  3. package/package.json +19 -17
  4. package/src/cli/commands/ls.ts +62 -65
  5. package/src/cli/commands/open.ts +47 -62
  6. package/src/cli/commands/prune.ts +59 -71
  7. package/src/cli/commands/restart.ts +100 -12
  8. package/src/cli/commands/serve.ts +119 -116
  9. package/src/cli/commands/stop.ts +51 -84
  10. package/src/cli/commands/theme.ts +54 -92
  11. package/src/cli/index.ts +17 -70
  12. package/src/index.ts +4 -1
  13. package/src/prompt/field-reference.ts +2 -2
  14. package/src/prompt/system-fragment.md +46 -16
  15. package/src/render/blocks/catalog.ts +2 -0
  16. package/src/render/blocks/diff/myers.ts +221 -0
  17. package/src/render/blocks/diff/parse-unified.ts +101 -0
  18. package/src/render/blocks/highlight.ts +8 -11
  19. package/src/render/blocks/markdown.ts +28 -7
  20. package/src/render/blocks/render.ts +3 -0
  21. package/src/render/blocks/renderers/code.ts +1 -3
  22. package/src/render/blocks/renderers/compare-table.ts +3 -4
  23. package/src/render/blocks/renderers/diagram.ts +2 -5
  24. package/src/render/blocks/renderers/diff.ts +378 -0
  25. package/src/render/blocks/renderers/prose.ts +1 -2
  26. package/src/render/blocks/renderers/timeline.ts +2 -1
  27. package/src/render/blocks/themes/claret-dark.ts +1 -6
  28. package/src/render/blocks/themes/claret-light.ts +1 -6
  29. package/src/render/blocks/types.ts +13 -1
  30. package/src/render/blocks/validate-block.ts +19 -9
  31. package/src/render/theme.ts +149 -0
  32. package/src/render/validate.ts +53 -9
  33. package/src/server/api.ts +112 -124
  34. package/src/server/favicon.ts +8 -16
  35. package/src/server/http.ts +101 -106
  36. package/src/server/lifecycle.ts +12 -6
  37. package/src/storage/assets.ts +8 -10
  38. package/src/storage/index-gen.ts +2 -3
  39. package/src/storage/theme-write.ts +17 -3
  40. package/src/tools/publish.ts +1 -3
  41. package/src/tools/styleguide.ts +3 -7
  42. package/src/tools/wait.ts +1 -0
@@ -1,15 +1,25 @@
1
1
  // Bun HTTP server bound to 127.0.0.1 (default), serving the cesium state directory.
2
+ //
3
+ // Routing is owned by a Hono app exposed on the returned ServerHandle. Callers
4
+ // (e.g. lifecycle.ts) mount sub-apps for /api/* and /favicon.ico via
5
+ // `handle.app.route("/", subApp)`. Any path that does not match a registered
6
+ // route falls through to the static file handler installed here via
7
+ // `app.notFound` — this preserves the cesium-specific behavior (custom 404 page,
8
+ // 1MB streaming threshold, MIME table) without forcing callers to think about
9
+ // registration order.
2
10
 
3
11
  import { resolve, extname } from "node:path";
4
12
  import { readFile } from "node:fs/promises";
13
+ import { Hono } from "hono";
5
14
 
6
15
  export interface ServerHandle {
7
16
  port: number;
8
17
  url: string; // "http://127.0.0.1:<port>"
18
+ /** Hono app — register routes via `handle.app.route(...)` before requests arrive. */
19
+ app: Hono;
9
20
  stop(): Promise<void>;
10
- onRequest(handler: () => void): void; // for idle-tracking; lifecycle attaches a callback
11
- /** Register a pre-static handler. Returns a Response to short-circuit, or undefined to fall through. */
12
- addHandler(handler: (req: Request) => Promise<Response | undefined>): void;
21
+ /** Register an idle-tracker callback fired on every request. */
22
+ onRequest(handler: () => void): void;
13
23
  }
14
24
 
15
25
  export interface StartServerArgs {
@@ -57,119 +67,106 @@ const FORBIDDEN_HTML = `<!doctype html>
57
67
  <body><div class="box"><h1>403</h1><p>forbidden</p></div></body>
58
68
  </html>`;
59
69
 
70
+ const ONE_MB = 1024 * 1024;
71
+
60
72
  function mimeFor(filePath: string): string {
61
73
  const ext = extname(filePath).toLowerCase();
62
74
  return MIME_TYPES[ext] ?? "application/octet-stream";
63
75
  }
64
76
 
77
+ function htmlResponse(body: string, status: number): Response {
78
+ return new Response(body, {
79
+ status,
80
+ headers: { "Content-Type": "text/html; charset=utf-8" },
81
+ });
82
+ }
83
+
84
+ // Static file handler: resolves the request path under stateDir, enforces
85
+ // traversal containment, falls back to index.html for directories, streams
86
+ // files >= 1MB, and returns the cesium 404 page on miss.
87
+ async function serveStatic(req: Request, stateDirResolved: string): Promise<Response> {
88
+ if (req.method !== "GET") {
89
+ return new Response("Method Not Allowed", { status: 405 });
90
+ }
91
+
92
+ const url = new URL(req.url);
93
+ let reqPath: string;
94
+ try {
95
+ reqPath = decodeURIComponent(url.pathname);
96
+ } catch {
97
+ return htmlResponse(NOT_FOUND_HTML, 404);
98
+ }
99
+
100
+ const joined = resolve(stateDirResolved, "." + reqPath);
101
+
102
+ // Path traversal defense: resolved path must be rooted at stateDir
103
+ if (!joined.startsWith(stateDirResolved + "/") && joined !== stateDirResolved) {
104
+ return htmlResponse(FORBIDDEN_HTML, 403);
105
+ }
106
+
107
+ const filePath = joined;
108
+ const trailingSlash = reqPath.endsWith("/");
109
+ const indexPath = filePath.endsWith("/") ? filePath + "index.html" : filePath + "/index.html";
110
+
111
+ let fileToServe = filePath;
112
+ let isDir = false;
113
+ if (trailingSlash || extname(filePath) === "") {
114
+ isDir = true;
115
+ fileToServe = filePath.endsWith("/") ? filePath + "index.html" : filePath + "/index.html";
116
+ }
117
+
118
+ const bunFile = Bun.file(fileToServe);
119
+ let exists = await bunFile.exists();
120
+
121
+ if (!exists && !isDir) {
122
+ // Try as directory index (e.g. /sub → /sub/index.html)
123
+ fileToServe = indexPath;
124
+ exists = await Bun.file(fileToServe).exists();
125
+ if (!exists) return htmlResponse(NOT_FOUND_HTML, 404);
126
+ } else if (!exists) {
127
+ return htmlResponse(NOT_FOUND_HTML, 404);
128
+ }
129
+
130
+ const contentType = mimeFor(fileToServe);
131
+ const finalFile = Bun.file(fileToServe);
132
+ const size = finalFile.size;
133
+
134
+ // Large files: stream; small files: read fully.
135
+ if (size >= ONE_MB) {
136
+ return new Response(finalFile.stream(), {
137
+ status: 200,
138
+ headers: { "Content-Type": contentType },
139
+ });
140
+ }
141
+
142
+ const buf = await readFile(fileToServe);
143
+ return new Response(buf, { status: 200, headers: { "Content-Type": contentType } });
144
+ }
145
+
65
146
  export async function startServer(args: StartServerArgs): Promise<ServerHandle> {
66
147
  const { stateDir, port, hostname = "127.0.0.1" } = args;
67
148
  const stateDirResolved = resolve(stateDir);
68
149
  const requestHandlers: Array<() => void> = [];
69
- const preHandlers: Array<(req: Request) => Promise<Response | undefined>> = [];
150
+
151
+ const app = new Hono();
152
+
153
+ // Idle-tracker middleware: fires registered callbacks on every request before
154
+ // dispatching to routes. Used by lifecycle.ts to reset the idle-shutdown timer.
155
+ app.use("*", async (_c, next) => {
156
+ for (const h of requestHandlers) {
157
+ h();
158
+ }
159
+ await next();
160
+ });
161
+
162
+ // Anything that doesn't match a registered route falls through to static
163
+ // file serving rooted at stateDir.
164
+ app.notFound((c) => serveStatic(c.req.raw, stateDirResolved));
70
165
 
71
166
  const server = Bun.serve({
72
167
  hostname,
73
168
  port,
74
- async fetch(req) {
75
- // Notify idle tracker
76
- for (const h of requestHandlers) {
77
- h();
78
- }
79
-
80
- // Run pre-static handlers (e.g. API routes) before serving static files
81
- for (const handler of preHandlers) {
82
- const result = await handler(req);
83
- if (result !== undefined) {
84
- return result;
85
- }
86
- }
87
-
88
- if (req.method !== "GET") {
89
- return new Response("Method Not Allowed", { status: 405 });
90
- }
91
-
92
- const url = new URL(req.url);
93
- // Decode and normalize the request path
94
- let reqPath: string;
95
- try {
96
- reqPath = decodeURIComponent(url.pathname);
97
- } catch {
98
- return new Response(NOT_FOUND_HTML, {
99
- status: 404,
100
- headers: { "Content-Type": "text/html; charset=utf-8" },
101
- });
102
- }
103
-
104
- // Resolve the absolute path under stateDir
105
- // Use resolve with stateDir as root to prevent traversal
106
- const joined = resolve(stateDirResolved, "." + reqPath);
107
-
108
- // Path traversal defense: resolved path must be rooted at stateDir
109
- if (!joined.startsWith(stateDirResolved + "/") && joined !== stateDirResolved) {
110
- return new Response(FORBIDDEN_HTML, {
111
- status: 403,
112
- headers: { "Content-Type": "text/html; charset=utf-8" },
113
- });
114
- }
115
-
116
- // Determine final file path: if directory, try index.html
117
- let filePath = joined;
118
- const trailingSlash = reqPath.endsWith("/");
119
-
120
- // Check if it's a directory by trying index.html
121
- const indexPath = filePath.endsWith("/") ? filePath + "index.html" : filePath + "/index.html";
122
-
123
- // Try as-is first, then as directory index
124
- let fileToServe = filePath;
125
- let isDir = false;
126
-
127
- // If path has no extension or trailing slash, it might be a directory
128
- if (trailingSlash || extname(filePath) === "") {
129
- isDir = true;
130
- fileToServe = filePath.endsWith("/") ? filePath + "index.html" : filePath + "/index.html";
131
- }
132
-
133
- const bunFile = Bun.file(fileToServe);
134
- let exists = await bunFile.exists();
135
-
136
- if (!exists && !isDir) {
137
- // Try as directory index (e.g. /sub → /sub/index.html)
138
- fileToServe = indexPath;
139
- const bunFileIdx = Bun.file(fileToServe);
140
- exists = await bunFileIdx.exists();
141
- if (!exists) {
142
- return new Response(NOT_FOUND_HTML, {
143
- status: 404,
144
- headers: { "Content-Type": "text/html; charset=utf-8" },
145
- });
146
- }
147
- } else if (!exists) {
148
- return new Response(NOT_FOUND_HTML, {
149
- status: 404,
150
- headers: { "Content-Type": "text/html; charset=utf-8" },
151
- });
152
- }
153
-
154
- const contentType = mimeFor(fileToServe);
155
- const finalFile = Bun.file(fileToServe);
156
- const size = finalFile.size;
157
-
158
- // Large files (>= 1MB): stream; small files: read fully
159
- const ONE_MB = 1024 * 1024;
160
- if (size >= ONE_MB) {
161
- return new Response(finalFile.stream(), {
162
- status: 200,
163
- headers: { "Content-Type": contentType },
164
- });
165
- }
166
-
167
- const buf = await readFile(fileToServe);
168
- return new Response(buf, {
169
- status: 200,
170
- headers: { "Content-Type": contentType },
171
- });
172
- },
169
+ fetch: app.fetch,
173
170
  });
174
171
 
175
172
  const actualPort = server.port;
@@ -182,14 +179,12 @@ export async function startServer(args: StartServerArgs): Promise<ServerHandle>
182
179
  return {
183
180
  port: actualPort,
184
181
  url: serverUrl,
182
+ app,
185
183
  stop: async () => {
186
184
  await server.stop();
187
185
  },
188
186
  onRequest: (handler: () => void) => {
189
187
  requestHandlers.push(handler);
190
188
  },
191
- addHandler: (handler: (req: Request) => Promise<Response | undefined>) => {
192
- preHandlers.push(handler);
193
- },
194
189
  };
195
190
  }
@@ -8,8 +8,8 @@ import { dirname } from "node:path";
8
8
  import { spawn } from "node:child_process";
9
9
  import { startServer, type ServerHandle } from "./http.ts";
10
10
  import { acquireLock } from "../storage/lock.ts";
11
- import { createApiHandler } from "./api.ts";
12
- import { createFaviconHandler } from "./favicon.ts";
11
+ import { createApiApp } from "./api.ts";
12
+ import { createFaviconApp } from "./favicon.ts";
13
13
  import { ensureThemeCss } from "../storage/assets.ts";
14
14
  import { defaultTheme, type ThemeTokens } from "../render/theme.ts";
15
15
 
@@ -279,11 +279,11 @@ export async function runServerForeground(cfg: LifecycleConfig): Promise<Running
279
279
  // Materialize theme.css before serving — self-heals on plugin upgrade
280
280
  await ensureThemeCss(stateDir, theme);
281
281
 
282
- // Wire API handler before static file fallback
283
- handle.addHandler(createApiHandler({ stateDir }));
282
+ // Mount API routes; unmatched paths fall through to the static handler
283
+ handle.app.route("/", createApiApp({ stateDir }));
284
284
  // /favicon.ico shim — browsers auto-request this even when the page
285
285
  // declares an SVG favicon. Serve the SVG bytes inline so we don't 404.
286
- handle.addHandler(createFaviconHandler());
286
+ handle.app.route("/", createFaviconApp());
287
287
 
288
288
  const startedAt = new Date().toISOString();
289
289
 
@@ -379,7 +379,11 @@ export async function ensureServerRunning(cfg: LifecycleConfig): Promise<Running
379
379
 
380
380
  // Use a spawn-only lock to prevent concurrent spawns. Release it immediately
381
381
  // after spawning so the child can acquire its own (.server-start.lock) lock.
382
- const spawnLock = await acquireLock({ lockPath: spawnLockPath, timeoutMs: 15_000, staleMs: 30_000 });
382
+ const spawnLock = await acquireLock({
383
+ lockPath: spawnLockPath,
384
+ timeoutMs: 15_000,
385
+ staleMs: 30_000,
386
+ });
383
387
  try {
384
388
  // Re-check after acquiring lock
385
389
  const existingAfterLock = readPidFile(pidFilePath);
@@ -448,11 +452,13 @@ export async function ensureServerRunning(cfg: LifecycleConfig): Promise<Running
448
452
  while (Date.now() < deadline) {
449
453
  const waitMs = POLL_SCHEDULE[scheduleIdx] ?? 1000;
450
454
  scheduleIdx = Math.min(scheduleIdx + 1, POLL_SCHEDULE.length - 1);
455
+ // eslint-disable-next-line no-await-in-loop -- poll-with-backoff requires sequential sleeps
451
456
  await sleep(waitMs);
452
457
 
453
458
  const pidContent = readPidFile(pidFilePath);
454
459
  if (pidContent !== null && isAlive(pidContent.pid)) {
455
460
  const probeUrl = `http://${pidContent.hostname}:${pidContent.port}/`;
461
+ // eslint-disable-next-line no-await-in-loop -- probe runs per-iteration after sleep; cannot parallelize
456
462
  const alive = await httpProbe(probeUrl);
457
463
  if (alive) {
458
464
  return {
@@ -1,14 +1,9 @@
1
1
  // Materializes /theme.css in the state directory, atomically and idempotently.
2
2
 
3
3
  import { createHash } from "node:crypto";
4
- import { join } from "node:path";
5
- import {
6
- frameworkRulesCss,
7
- themeTokensCss,
8
- defaultTheme,
9
- type ThemeTokens,
10
- } from "../render/theme.ts";
4
+ import { defaultTheme, type ThemeTokens } from "../render/theme.ts";
11
5
  import { atomicWrite } from "./write.ts";
6
+ import { buildThemeCss, themeCssPath } from "./theme-write.ts";
12
7
  import { readFile } from "node:fs/promises";
13
8
 
14
9
  /** Per-theme CSS cache: built CSS string keyed by theme content hash. */
@@ -19,19 +14,22 @@ function themeKey(theme: ThemeTokens): string {
19
14
  return createHash("sha256").update(JSON.stringify(theme)).digest("hex");
20
15
  }
21
16
 
22
- /** Build the full theme.css string for a given theme (tokens + framework rules). */
17
+ /** Build the full theme.css string for a given theme (tokens + framework rules).
18
+ * Delegates to buildThemeCss so cesium-theme-apply and ensureThemeCss agree on
19
+ * byte-exact output. Caches by theme identity to avoid re-string-concat on the
20
+ * hot publish path. */
23
21
  function buildCss(theme: ThemeTokens): string {
24
22
  const key = themeKey(theme);
25
23
  const cached = cssCache.get(key);
26
24
  if (cached !== undefined) return cached;
27
- const css = themeTokensCss(theme) + "\n" + frameworkRulesCss();
25
+ const css = buildThemeCss(theme);
28
26
  cssCache.set(key, css);
29
27
  return css;
30
28
  }
31
29
 
32
30
  /** Returns the absolute path to theme.css in stateDir. */
33
31
  export function themeCssAssetPath(stateDir: string): string {
34
- return join(stateDir, "theme.css");
32
+ return themeCssPath(stateDir);
35
33
  }
36
34
 
37
35
  /**
@@ -236,9 +236,8 @@ function indexJs(): string {
236
236
  function renderEntryCard(entry: IndexEntry): string {
237
237
  const isSuperseded = entry.supersededBy !== null ? "1" : "0";
238
238
  const kindPill = `<span class="pill">${esc(entry.kind)}</span>`;
239
- const inputModeBadge = entry.inputMode !== undefined
240
- ? ` <span class="tag">${esc(entry.inputMode)}</span>`
241
- : "";
239
+ const inputModeBadge =
240
+ entry.inputMode !== undefined ? ` <span class="tag">${esc(entry.inputMode)}</span>` : "";
242
241
  const dateStr = `<span class="card-date">${esc(formatDate(entry.createdAt))}</span>`;
243
242
  const supersededBadge =
244
243
  entry.supersedes !== null
@@ -1,7 +1,15 @@
1
1
  // Writes theme.css to the state directory for dynamic theme support.
2
+ //
3
+ // Produces the same content as `assets.ts:ensureThemeCss` — tokens + framework
4
+ // rules. The two writers were split during the v1 design (when theme.css held
5
+ // tokens only and framework CSS was inlined into every artifact) and were never
6
+ // re-unified after the phase 1 refactor promoted theme.css to carry the full
7
+ // framework. Keeping them aligned is now an invariant: if they disagree,
8
+ // `cesium theme apply` will clobber the CSS that the server / publish flow
9
+ // just wrote, and artifacts will render unstyled.
2
10
 
3
11
  import { join } from "node:path";
4
- import { themeTokensCss } from "../render/theme.ts";
12
+ import { themeTokensCss, frameworkRulesCss } from "../render/theme.ts";
5
13
  import type { ThemeTokens } from "../render/theme.ts";
6
14
  import { atomicWrite } from "./write.ts";
7
15
 
@@ -10,10 +18,16 @@ export function themeCssPath(stateDir: string): string {
10
18
  return join(stateDir, "theme.css");
11
19
  }
12
20
 
13
- /** Writes <stateDir>/theme.css with token definitions only (no framework rules).
21
+ /** Returns the full theme.css content (tokens + framework rules) for a theme.
22
+ * Must stay byte-identical to `assets.ts:buildCss` — see module docstring. */
23
+ export function buildThemeCss(theme: ThemeTokens): string {
24
+ return themeTokensCss(theme) + "\n" + frameworkRulesCss();
25
+ }
26
+
27
+ /** Writes <stateDir>/theme.css with the full framework CSS (tokens + rules).
14
28
  * Atomic. Returns the absolute path. */
15
29
  export async function writeThemeCss(stateDir: string, theme: ThemeTokens): Promise<string> {
16
30
  const path = themeCssPath(stateDir);
17
- await atomicWrite(path, themeTokensCss(theme) + "\n");
31
+ await atomicWrite(path, buildThemeCss(theme));
18
32
  return path;
19
33
  }
@@ -117,9 +117,7 @@ export function createPublishTool(
117
117
  html: tool.schema
118
118
  .string()
119
119
  .optional()
120
- .describe(
121
- "Body HTML — escape valve / legacy mode. Provide exactly one of html or blocks.",
122
- ),
120
+ .describe("Body HTML — escape valve / legacy mode. Provide exactly one of html or blocks."),
123
121
  blocks: tool.schema
124
122
  .array(tool.schema.any())
125
123
  .optional()
@@ -101,21 +101,17 @@ export async function generateStyleguideMarkdown(): Promise<string> {
101
101
  "- Inline: `**bold**`, `*italic*`, `` `code` ``, `[text](href)` (relative or anchor only).",
102
102
  );
103
103
  lines.push(
104
- "- HTML safelist: `<kbd>`, `<span class=\"pill\">`, `<span class=\"tag\">`. Anything else is escaped.",
104
+ '- HTML safelist: `<kbd>`, `<span class="pill">`, `<span class="tag">`. Anything else is escaped.',
105
105
  );
106
106
  lines.push("");
107
107
  lines.push("## When to reach for raw_html / diagram");
108
108
  lines.push("");
109
- lines.push(
110
- "- `diagram` — inline SVG visualizations or bespoke composed HTML diagrams.",
111
- );
109
+ lines.push("- `diagram` — inline SVG visualizations or bespoke composed HTML diagrams.");
112
110
  lines.push(
113
111
  "- `raw_html` — anything genuinely creative that doesn't fit a known block type." +
114
112
  " Include a `purpose` string describing what you're building.",
115
113
  );
116
- lines.push(
117
- "- Critique flags raw_html overuse (>2 blocks or >30% of body characters).",
118
- );
114
+ lines.push("- Critique flags raw_html overuse (>2 blocks or >30% of body characters).");
119
115
 
120
116
  return lines.join("\n");
121
117
  }
package/src/tools/wait.ts CHANGED
@@ -87,6 +87,7 @@ async function resolveArtifactPath(stateDir: string, id: string): Promise<string
87
87
  const indexPath = join(projectsDir, projectSlug, "index.json");
88
88
  let entries;
89
89
  try {
90
+ // eslint-disable-next-line no-await-in-loop -- short-circuit on first project containing the id
90
91
  entries = await loadIndex(indexPath);
91
92
  } catch {
92
93
  continue;