@cfbender/cesium 0.4.0 → 0.5.1

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 (45) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +2 -5
  3. package/package.json +3 -2
  4. package/src/cli/commands/serve.ts +18 -2
  5. package/src/index.ts +4 -1
  6. package/src/prompt/field-reference.ts +94 -0
  7. package/src/prompt/system-fragment.md +56 -65
  8. package/src/render/blocks/catalog.ts +39 -0
  9. package/src/render/blocks/escape.ts +27 -0
  10. package/src/render/blocks/highlight.ts +188 -0
  11. package/src/render/blocks/index.ts +6 -0
  12. package/src/render/blocks/markdown.ts +217 -0
  13. package/src/render/blocks/render.ts +104 -0
  14. package/src/render/blocks/renderers/callout.ts +38 -0
  15. package/src/render/blocks/renderers/code.ts +46 -0
  16. package/src/render/blocks/renderers/compare-table.ts +56 -0
  17. package/src/render/blocks/renderers/diagram.ts +48 -0
  18. package/src/render/blocks/renderers/divider.ts +31 -0
  19. package/src/render/blocks/renderers/hero.ts +66 -0
  20. package/src/render/blocks/renderers/kv.ts +45 -0
  21. package/src/render/blocks/renderers/list.ts +51 -0
  22. package/src/render/blocks/renderers/pill-row.ts +45 -0
  23. package/src/render/blocks/renderers/prose.ts +29 -0
  24. package/src/render/blocks/renderers/raw-html.ts +32 -0
  25. package/src/render/blocks/renderers/risk-table.ts +76 -0
  26. package/src/render/blocks/renderers/section.ts +97 -0
  27. package/src/render/blocks/renderers/timeline.ts +58 -0
  28. package/src/render/blocks/renderers/tldr.ts +30 -0
  29. package/src/render/blocks/themes/claret-dark.ts +206 -0
  30. package/src/render/blocks/themes/claret-light.ts +227 -0
  31. package/src/render/blocks/types.ts +127 -0
  32. package/src/render/blocks/validate-block.ts +202 -0
  33. package/src/render/critique.ts +410 -10
  34. package/src/render/fallback.ts +18 -0
  35. package/src/render/theme.ts +154 -0
  36. package/src/render/validate.ts +282 -17
  37. package/src/render/wrap.ts +7 -7
  38. package/src/server/lifecycle.ts +190 -3
  39. package/src/storage/assets.ts +66 -0
  40. package/src/storage/index-cache.ts +1 -0
  41. package/src/storage/index-gen.ts +13 -14
  42. package/src/tools/ask.ts +7 -5
  43. package/src/tools/critique.ts +41 -6
  44. package/src/tools/publish.ts +43 -14
  45. package/src/tools/styleguide.ts +118 -9
@@ -2,11 +2,16 @@
2
2
 
3
3
  import { join } from "node:path";
4
4
  import { readFileSync, unlinkSync } from "node:fs";
5
- import { unlink, writeFile } from "node:fs/promises";
5
+ import { mkdir, unlink, writeFile } from "node:fs/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname } from "node:path";
8
+ import { spawn } from "node:child_process";
6
9
  import { startServer, type ServerHandle } from "./http.ts";
7
10
  import { acquireLock } from "../storage/lock.ts";
8
11
  import { createApiHandler } from "./api.ts";
9
12
  import { createFaviconHandler } from "./favicon.ts";
13
+ import { ensureThemeCss } from "../storage/assets.ts";
14
+ import { defaultTheme, type ThemeTokens } from "../render/theme.ts";
10
15
 
11
16
  export interface LifecycleConfig {
12
17
  stateDir: string;
@@ -14,6 +19,7 @@ export interface LifecycleConfig {
14
19
  portMax: number; // upper bound (inclusive)
15
20
  idleTimeoutMs: number;
16
21
  hostname?: string; // default "127.0.0.1"
22
+ theme?: ThemeTokens; // default: defaultTheme()
17
23
  }
18
24
 
19
25
  export interface RunningInfo {
@@ -199,8 +205,19 @@ export async function stopRunning(stateDir: string): Promise<void> {
199
205
  }
200
206
  }
201
207
 
202
- export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo> {
203
- const { stateDir, port, portMax, idleTimeoutMs, hostname = "127.0.0.1" } = cfg;
208
+ // ─── In-process (foreground) server start ────────────────────────────────────
209
+ // Used by `cesium serve` CLI. Runs Bun.serve() in-process; killing the process
210
+ // IS stopping the server, which is the user's intent for a foreground invocation.
211
+
212
+ export async function runServerForeground(cfg: LifecycleConfig): Promise<RunningInfo> {
213
+ const {
214
+ stateDir,
215
+ port,
216
+ portMax,
217
+ idleTimeoutMs,
218
+ hostname = "127.0.0.1",
219
+ theme = defaultTheme(),
220
+ } = cfg;
204
221
  const pidFilePath = join(stateDir, ".server.pid");
205
222
  const lockPath = join(stateDir, ".server-start.lock");
206
223
 
@@ -259,6 +276,9 @@ export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo>
259
276
  const handle = await tryBindPort(port);
260
277
  const boundPort = handle.port;
261
278
 
279
+ // Materialize theme.css before serving — self-heals on plugin upgrade
280
+ await ensureThemeCss(stateDir, theme);
281
+
262
282
  // Wire API handler before static file fallback
263
283
  handle.addHandler(createApiHandler({ stateDir }));
264
284
  // /favicon.ico shim — browsers auto-request this even when the page
@@ -304,6 +324,173 @@ export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo>
304
324
  }
305
325
  }
306
326
 
327
+ // ─── Detached (lazy) server start ────────────────────────────────────────────
328
+ // Used by plugin callers (publish, ask). Spawns `cesium serve` as a detached
329
+ // subprocess so the subprocess PID is what ends up in the PID file. Sending a
330
+ // signal to that PID kills only the server child, never the plugin host.
331
+
332
+ // Locate CLI entry relative to this file: src/server/lifecycle.ts → src/cli/index.ts
333
+ const HERE = dirname(fileURLToPath(import.meta.url));
334
+ const CLI_ENTRY = join(HERE, "..", "cli", "index.ts");
335
+
336
+ // Readiness poll backoff schedule (ms between attempts)
337
+ const POLL_SCHEDULE = [50, 100, 200, 500, 1000, 1000, 1000, 1000, 1000, 1000];
338
+
339
+ async function sleep(ms: number): Promise<void> {
340
+ return new Promise((resolve) => setTimeout(resolve, ms));
341
+ }
342
+
343
+ async function httpProbe(url: string): Promise<boolean> {
344
+ try {
345
+ const res = await fetch(url, { signal: AbortSignal.timeout(1000) });
346
+ // Any HTTP response (even 404) means the server is up
347
+ return res.status < 600;
348
+ } catch {
349
+ return false;
350
+ }
351
+ }
352
+
353
+ export async function ensureServerRunning(cfg: LifecycleConfig): Promise<RunningInfo> {
354
+ const { stateDir, port, idleTimeoutMs } = cfg;
355
+ const pidFilePath = join(stateDir, ".server.pid");
356
+ // Use a separate lock from runServerForeground's ".server-start.lock" to avoid
357
+ // deadlock: the child process runs runServerForeground which acquires that lock,
358
+ // so the parent must not hold it while waiting for the child.
359
+ const spawnLockPath = join(stateDir, ".server-spawn.lock");
360
+
361
+ // Fast path: read existing PID file and probe liveness
362
+ const existing = readPidFile(pidFilePath);
363
+ if (existing !== null && isAlive(existing.pid)) {
364
+ const probeUrl = `http://${existing.hostname}:${existing.port}/`;
365
+ const alive = await httpProbe(probeUrl);
366
+ if (alive) {
367
+ return {
368
+ port: existing.port,
369
+ url: `http://${existing.hostname}:${existing.port}`,
370
+ pid: existing.pid,
371
+ startedAt: existing.startedAt,
372
+ };
373
+ }
374
+ // Process alive but not responding — fall through to spawn fresh
375
+ }
376
+
377
+ // Ensure state dir exists before trying to acquire lock or write files
378
+ await mkdir(stateDir, { recursive: true });
379
+
380
+ // Use a spawn-only lock to prevent concurrent spawns. Release it immediately
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 });
383
+ try {
384
+ // Re-check after acquiring lock
385
+ const existingAfterLock = readPidFile(pidFilePath);
386
+ if (existingAfterLock !== null && isAlive(existingAfterLock.pid)) {
387
+ const probeUrl = `http://${existingAfterLock.hostname}:${existingAfterLock.port}/`;
388
+ const alive = await httpProbe(probeUrl);
389
+ if (alive) {
390
+ return {
391
+ port: existingAfterLock.port,
392
+ url: `http://${existingAfterLock.hostname}:${existingAfterLock.port}`,
393
+ pid: existingAfterLock.pid,
394
+ startedAt: existingAfterLock.startedAt,
395
+ };
396
+ }
397
+ }
398
+
399
+ // Clean up stale PID file if present
400
+ try {
401
+ await unlink(pidFilePath);
402
+ } catch {
403
+ // ENOENT is fine
404
+ }
405
+
406
+ // Build spawn args — rely on env vars for config; CLI flags as defense in depth.
407
+ // portMax is not a serve flag; the child will scan ports starting from `port`.
408
+ // Port 0 means "auto-assign" — the CLI flag rejects 0, so rely on CESIUM_PORT=0 env var.
409
+ const spawnArgs: string[] = ["run", CLI_ENTRY, "serve", "--state-dir", stateDir];
410
+ if (port > 0) {
411
+ spawnArgs.push("--port", String(port));
412
+ }
413
+ // Pass idle timeout so the detached child self-terminates on inactivity.
414
+ // Serve command defaults to 0 (never) for foreground use; we override for daemon mode.
415
+ if (idleTimeoutMs > 0) {
416
+ spawnArgs.push("--idle-timeout", String(idleTimeoutMs));
417
+ }
418
+
419
+ const child = spawn("bun", spawnArgs, {
420
+ detached: true,
421
+ stdio: "ignore",
422
+ env: {
423
+ ...process.env,
424
+ CESIUM_STATE_DIR: stateDir,
425
+ CESIUM_PORT: String(port),
426
+ },
427
+ });
428
+
429
+ // Unref so the parent can exit without waiting for the child
430
+ child.unref();
431
+
432
+ if (child.pid === undefined) {
433
+ throw new Error("cesium: failed to spawn server subprocess (no PID assigned)");
434
+ }
435
+ } finally {
436
+ // Release spawn lock immediately — the child needs to acquire its own lock
437
+ // (.server-start.lock via runServerForeground). Holding the spawn lock any
438
+ // longer would deadlock the child.
439
+ await spawnLock.release();
440
+ }
441
+
442
+ // Wait for the child to write its PID file and respond to HTTP.
443
+ // This polling happens OUTSIDE the spawn lock so the child can run freely.
444
+ const deadline = Date.now() + 10_000;
445
+ let lastError = "timeout";
446
+ let scheduleIdx = 0;
447
+
448
+ while (Date.now() < deadline) {
449
+ const waitMs = POLL_SCHEDULE[scheduleIdx] ?? 1000;
450
+ scheduleIdx = Math.min(scheduleIdx + 1, POLL_SCHEDULE.length - 1);
451
+ await sleep(waitMs);
452
+
453
+ const pidContent = readPidFile(pidFilePath);
454
+ if (pidContent !== null && isAlive(pidContent.pid)) {
455
+ const probeUrl = `http://${pidContent.hostname}:${pidContent.port}/`;
456
+ const alive = await httpProbe(probeUrl);
457
+ if (alive) {
458
+ return {
459
+ port: pidContent.port,
460
+ url: `http://${pidContent.hostname}:${pidContent.port}`,
461
+ pid: pidContent.pid,
462
+ startedAt: pidContent.startedAt,
463
+ };
464
+ }
465
+ lastError = `pid ${pidContent.pid} alive but not yet responding on port ${pidContent.port}`;
466
+ } else if (pidContent !== null) {
467
+ lastError = `pid ${pidContent.pid} in PID file is not alive`;
468
+ } else {
469
+ lastError = "PID file not yet written";
470
+ }
471
+ }
472
+
473
+ // Timeout — try to clean up the spawned process
474
+ const staleContent = readPidFile(pidFilePath);
475
+ if (staleContent !== null && isAlive(staleContent.pid)) {
476
+ try {
477
+ process.kill(staleContent.pid, "SIGTERM");
478
+ } catch {
479
+ // best-effort
480
+ }
481
+ }
482
+
483
+ throw new Error(
484
+ `cesium: timed out waiting for server to start in ${stateDir} (last: ${lastError})`,
485
+ );
486
+ }
487
+
488
+ // ─── Backward-compat alias ────────────────────────────────────────────────────
489
+ // Internal callers have been updated to use runServerForeground or ensureServerRunning.
490
+ // Keep ensureRunning exported for any external consumers that haven't migrated.
491
+
492
+ export { runServerForeground as ensureRunning };
493
+
307
494
  // ─── Test reset hook ──────────────────────────────────────────────────────────
308
495
  // This function is intended for test use only. It clears module-level singleton
309
496
  // state, stops any running server, and removes signal/exit listeners.
@@ -0,0 +1,66 @@
1
+ // Materializes /theme.css in the state directory, atomically and idempotently.
2
+
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";
11
+ import { atomicWrite } from "./write.ts";
12
+ import { readFile } from "node:fs/promises";
13
+
14
+ /** Per-theme CSS cache: built CSS string keyed by theme content hash. */
15
+ const cssCache = new Map<string, string>();
16
+
17
+ /** Returns a stable cache key for a theme (hash of its JSON representation). */
18
+ function themeKey(theme: ThemeTokens): string {
19
+ return createHash("sha256").update(JSON.stringify(theme)).digest("hex");
20
+ }
21
+
22
+ /** Build the full theme.css string for a given theme (tokens + framework rules). */
23
+ function buildCss(theme: ThemeTokens): string {
24
+ const key = themeKey(theme);
25
+ const cached = cssCache.get(key);
26
+ if (cached !== undefined) return cached;
27
+ const css = themeTokensCss(theme) + "\n" + frameworkRulesCss();
28
+ cssCache.set(key, css);
29
+ return css;
30
+ }
31
+
32
+ /** Returns the absolute path to theme.css in stateDir. */
33
+ export function themeCssAssetPath(stateDir: string): string {
34
+ return join(stateDir, "theme.css");
35
+ }
36
+
37
+ /**
38
+ * Writes <stateDir>/theme.css with the full framework CSS (tokens + rules)
39
+ * for the given theme, iff the on-disk file is missing or its content hash
40
+ * differs from the expected content. Idempotent and self-healing on plugin
41
+ * upgrade or theme change.
42
+ *
43
+ * When called without a theme argument, falls back to defaultTheme() so
44
+ * existing call sites remain valid.
45
+ */
46
+ export async function ensureThemeCss(
47
+ stateDir: string,
48
+ theme: ThemeTokens = defaultTheme(),
49
+ ): Promise<void> {
50
+ const dest = themeCssAssetPath(stateDir);
51
+ const bundledCss = buildCss(theme);
52
+ const bundledHash = createHash("sha256").update(bundledCss).digest("hex");
53
+
54
+ // Fast path: compare hash of existing file to expected hash.
55
+ try {
56
+ const existing = await readFile(dest, "utf8");
57
+ const existingHash = createHash("sha256").update(existing).digest("hex");
58
+ if (existingHash === bundledHash) {
59
+ return; // already up-to-date
60
+ }
61
+ } catch {
62
+ // ENOENT or unreadable — fall through to write
63
+ }
64
+
65
+ await atomicWrite(dest, bundledCss);
66
+ }
@@ -19,6 +19,7 @@ export interface IndexEntry {
19
19
  projectSlug: string;
20
20
  projectName: string;
21
21
  bodyText: string;
22
+ inputMode?: "html" | "blocks";
22
23
  }
23
24
 
24
25
  export async function loadIndex(jsonPath: string): Promise<IndexEntry[]> {
@@ -2,8 +2,8 @@
2
2
 
3
3
  import type { IndexEntry } from "./index-cache.ts";
4
4
  import type { ThemeTokens } from "../render/theme.ts";
5
- import { frameworkRulesCss, themeTokensCss } from "../render/theme.ts";
6
5
  import { faviconLinkTag, faviconEmblemSvg } from "../render/favicon.ts";
6
+ import { fallbackCss } from "../render/fallback.ts";
7
7
 
8
8
  export interface RenderProjectIndexArgs {
9
9
  projectSlug: string;
@@ -236,6 +236,9 @@ 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
242
  const dateStr = `<span class="card-date">${esc(formatDate(entry.createdAt))}</span>`;
240
243
  const supersededBadge =
241
244
  entry.supersedes !== null
@@ -255,7 +258,7 @@ function renderEntryCard(entry: IndexEntry): string {
255
258
  : "";
256
259
 
257
260
  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}">
258
- <div class="card-top">${kindPill}${supersededBadge}${supersededByBadge}${dateStr}</div>
261
+ <div class="card-top">${kindPill}${inputModeBadge}${supersededBadge}${supersededByBadge}${dateStr}</div>
259
262
  <div class="card-title"><a href="artifacts/${esc(entry.filename)}">${esc(entry.title)}</a></div>
260
263
  ${summaryHtml}${tagsHtml}
261
264
  <div class="card-footer"><a class="open-link" href="artifacts/${esc(entry.filename)}">Open →</a></div>
@@ -265,7 +268,7 @@ function renderEntryCard(entry: IndexEntry): string {
265
268
  // ─── renderProjectIndex ──────────────────────────────────────────────────────
266
269
 
267
270
  export function renderProjectIndex(args: RenderProjectIndexArgs): string {
268
- const { projectSlug, projectName, entries, theme } = args;
271
+ const { projectSlug, projectName, entries } = args;
269
272
  const href =
270
273
  args.themeCssHref === undefined
271
274
  ? "../../theme.css"
@@ -274,8 +277,7 @@ export function renderProjectIndex(args: RenderProjectIndexArgs): string {
274
277
  : args.themeCssHref;
275
278
  const suppressLink = args.themeCssHref === null;
276
279
 
277
- const rules = frameworkRulesCss();
278
- const tokens = themeTokensCss(theme);
280
+ const fallback = fallbackCss();
279
281
  const iCss = indexCss();
280
282
  const iJs = indexJs();
281
283
 
@@ -358,9 +360,8 @@ ${cardsHtml}
358
360
  <meta charset="utf-8">
359
361
  <meta name="viewport" content="width=device-width, initial-scale=1">
360
362
  <title>${esc(projectName)} · cesium</title>
361
- <style>${rules}
362
- /* fallback theme tokens — used when theme.css is missing or unreachable */
363
- ${tokens}${iCss}</style>${linkTag}${faviconTag}
363
+ <style>/* fallback — standalone-readable; full styles served from /theme.css */
364
+ ${fallback}${iCss}</style>${linkTag}${faviconTag}
364
365
  </head>
365
366
  <body>
366
367
  <div class="page">
@@ -381,7 +382,7 @@ ${tokens}${iCss}</style>${linkTag}${faviconTag}
381
382
  // ─── renderGlobalIndex ───────────────────────────────────────────────────────
382
383
 
383
384
  export function renderGlobalIndex(args: RenderGlobalIndexArgs): string {
384
- const { projects, theme } = args;
385
+ const { projects } = args;
385
386
  const href =
386
387
  args.themeCssHref === undefined
387
388
  ? "theme.css"
@@ -390,8 +391,7 @@ export function renderGlobalIndex(args: RenderGlobalIndexArgs): string {
390
391
  : args.themeCssHref;
391
392
  const suppressLink = args.themeCssHref === null;
392
393
 
393
- const rules = frameworkRulesCss();
394
- const tokens = themeTokensCss(theme);
394
+ const fallback = fallbackCss();
395
395
  const iCss = indexCss();
396
396
 
397
397
  const linkTag = suppressLink ? "" : `\n <link rel="stylesheet" href="${href}">`;
@@ -448,9 +448,8 @@ export function renderGlobalIndex(args: RenderGlobalIndexArgs): string {
448
448
  <meta charset="utf-8">
449
449
  <meta name="viewport" content="width=device-width, initial-scale=1">
450
450
  <title>All projects · cesium</title>
451
- <style>${rules}
452
- /* fallback theme tokens — used when theme.css is missing or unreachable */
453
- ${tokens}${iCss}</style>${linkTag}${faviconTag}
451
+ <style>/* fallback — standalone-readable; full styles served from /theme.css */
452
+ ${fallback}${iCss}</style>${linkTag}${faviconTag}
454
453
  </head>
455
454
  <body>
456
455
  <div class="page">
package/src/tools/ask.ts CHANGED
@@ -13,14 +13,14 @@ import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
13
13
  import type { InteractiveData } from "../render/validate.ts";
14
14
  import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
15
15
  import { atomicWrite } from "../storage/write.ts";
16
- import { writeThemeCss } from "../storage/theme-write.ts";
16
+ import { ensureThemeCss } from "../storage/assets.ts";
17
17
  import { writeFaviconSvg } from "../storage/favicon-write.ts";
18
18
  import { loadIndex, writeIndex, appendEntry, type IndexEntry } from "../storage/index-cache.ts";
19
19
  import { withLock } from "../storage/lock.ts";
20
20
  import { renderProjectIndex, renderGlobalIndex } from "../storage/index-gen.ts";
21
21
  import { buildProjectSummaries } from "../storage/project-summaries.ts";
22
22
  import {
23
- ensureRunning as defaultEnsureRunning,
23
+ ensureServerRunning as defaultEnsureServerRunning,
24
24
  type RunningInfo,
25
25
  type LifecycleConfig,
26
26
  } from "../server/lifecycle.ts";
@@ -58,7 +58,7 @@ export function createAskTool(
58
58
  const resolveConfig = overrides?.loadConfig ?? loadConfig;
59
59
  const now = overrides?.now ?? (() => new Date());
60
60
  const genId = overrides?.nanoid ?? defaultNanoid;
61
- const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureRunning;
61
+ const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureServerRunning;
62
62
 
63
63
  return tool({
64
64
  description: TOOL_DESCRIPTION,
@@ -204,6 +204,7 @@ export function createAskTool(
204
204
  supersedes: null,
205
205
  supersededBy: null,
206
206
  contentSha256,
207
+ inputMode: "html",
207
208
  };
208
209
 
209
210
  // 12. Build interactive data
@@ -228,8 +229,8 @@ export function createAskTool(
228
229
  // 14. Build theme + wrap document
229
230
  const theme = mergeTheme(themeFromPreset(config.themePreset), config.theme);
230
231
 
231
- // 14a. Write theme.css + favicon.svg (idempotent, outside index lock — separate files)
232
- await writeThemeCss(config.stateDir, theme);
232
+ // 14a. Ensure theme.css + favicon.svg (idempotent, outside index lock — separate files)
233
+ await ensureThemeCss(config.stateDir, theme);
233
234
  await writeFaviconSvg(config.stateDir);
234
235
 
235
236
  const fullHtml = wrapDocument({
@@ -309,6 +310,7 @@ export function createAskTool(
309
310
  portMax: config.portMax,
310
311
  idleTimeoutMs: config.idleTimeoutMs,
311
312
  hostname: config.hostname,
313
+ theme,
312
314
  });
313
315
  if (maybeInfo !== null) {
314
316
  serverInfo = maybeInfo;
@@ -1,8 +1,15 @@
1
- // Tool handler for cesium_critique — runs the body analyzer and returns a human-readable report.
1
+ // Tool handler for cesium_critique — mode-aware body analyzer.
2
+ // Accepts either { html: string } (html mode) or { blocks: Block[] } (blocks mode). Exactly one required.
2
3
 
3
4
  import { tool } from "@opencode-ai/plugin";
4
5
  import type { PluginInput } from "@opencode-ai/plugin";
5
- import { critique, type CritiqueResult, type CritiqueSeverity } from "../render/critique.ts";
6
+ import {
7
+ critiqueHtml,
8
+ critiqueBlocks,
9
+ type CritiqueResult,
10
+ type CritiqueSeverity,
11
+ } from "../render/critique.ts";
12
+ import type { Block } from "../render/blocks/types.ts";
6
13
 
7
14
  const TOOL_DESCRIPTION = `Analyze a draft HTML body for adherence to the cesium design
8
15
  system before publishing. Returns a 0-100 score and findings (warn/suggest/info).
@@ -18,6 +25,7 @@ HTML only, no <!doctype>/<html>/<head>/<body> wrappers.`;
18
25
  * Format a CritiqueResult into a concise human-readable string the agent can parse.
19
26
  * Format:
20
27
  * score: 87/100
28
+ * mode: html
21
29
  *
22
30
  * warn:
23
31
  * - [external-resource] External resource will be stripped...
@@ -29,7 +37,7 @@ HTML only, no <!doctype>/<html>/<head>/<body> wrappers.`;
29
37
  * - [code-without-highlights] Code blocks render without...
30
38
  */
31
39
  export function formatCritiqueForAgent(result: CritiqueResult): string {
32
- const lines: string[] = [`score: ${result.score}/100`];
40
+ const lines: string[] = [`score: ${result.score}/100`, `mode: ${result.mode}`];
33
41
 
34
42
  const bySeverity: Record<CritiqueSeverity, typeof result.findings> = {
35
43
  warn: [],
@@ -47,7 +55,8 @@ export function formatCritiqueForAgent(result: CritiqueResult): string {
47
55
  lines.push("");
48
56
  lines.push(`${sev}:`);
49
57
  for (const f of group) {
50
- lines.push(`- [${f.code}] ${f.message}`);
58
+ const pathSuffix = f.path !== undefined ? ` (${f.path})` : "";
59
+ lines.push(`- [${f.code}] ${f.message}${pathSuffix}`);
51
60
  }
52
61
  }
53
62
 
@@ -57,9 +66,35 @@ export function formatCritiqueForAgent(result: CritiqueResult): string {
57
66
  export function createCritiqueTool(_ctx: PluginInput): ReturnType<typeof tool> {
58
67
  return tool({
59
68
  description: TOOL_DESCRIPTION,
60
- args: { html: tool.schema.string() },
69
+ args: {
70
+ html: tool.schema.string().optional(),
71
+ blocks: tool.schema.any().optional(),
72
+ },
61
73
  async execute(args) {
62
- const result = critique(args.html);
74
+ const hasHtml = args.html !== undefined && args.html !== null;
75
+ const hasBlocks = args.blocks !== undefined && args.blocks !== null;
76
+
77
+ if (hasHtml && hasBlocks) {
78
+ return "error: provide exactly one of html or blocks, not both";
79
+ }
80
+ if (!hasHtml && !hasBlocks) {
81
+ return "error: provide exactly one of html or blocks";
82
+ }
83
+
84
+ let result: CritiqueResult;
85
+
86
+ if (hasHtml) {
87
+ if (typeof args.html !== "string") {
88
+ return "error: html must be a string";
89
+ }
90
+ result = critiqueHtml(args.html);
91
+ } else {
92
+ if (!Array.isArray(args.blocks)) {
93
+ return "error: blocks must be an array";
94
+ }
95
+ result = critiqueBlocks(args.blocks as Block[]);
96
+ }
97
+
63
98
  return formatCritiqueForAgent(result);
64
99
  },
65
100
  });
@@ -10,10 +10,12 @@ import { scrub } from "../render/scrub.ts";
10
10
  import { extractTextContent } from "../render/extract.ts";
11
11
  import { themeFromPreset, mergeTheme } from "../render/theme.ts";
12
12
  import { validatePublishInput, htmlBodyWarnings, PUBLISH_KINDS } from "../render/validate.ts";
13
+ import { renderBlocks } from "../render/blocks/render.ts";
14
+ import { resolveHighlightTheme } from "../render/blocks/highlight.ts";
13
15
  import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
14
16
  import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
15
17
  import { atomicWrite, patchEmbeddedMetadata } from "../storage/write.ts";
16
- import { writeThemeCss } from "../storage/theme-write.ts";
18
+ import { ensureThemeCss } from "../storage/assets.ts";
17
19
  import { writeFaviconSvg } from "../storage/favicon-write.ts";
18
20
  import {
19
21
  loadIndex,
@@ -26,7 +28,7 @@ import { withLock } from "../storage/lock.ts";
26
28
  import { renderProjectIndex, renderGlobalIndex } from "../storage/index-gen.ts";
27
29
  import { buildProjectSummaries } from "../storage/project-summaries.ts";
28
30
  import {
29
- ensureRunning as defaultEnsureRunning,
31
+ ensureServerRunning as defaultEnsureServerRunning,
30
32
  type RunningInfo,
31
33
  type LifecycleConfig,
32
34
  } from "../server/lifecycle.ts";
@@ -105,14 +107,25 @@ export function createPublishTool(
105
107
  const resolveConfig = overrides?.loadConfig ?? loadConfig;
106
108
  const now = overrides?.now ?? (() => new Date());
107
109
  const genId = overrides?.nanoid ?? defaultNanoid;
108
- const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureRunning;
110
+ const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureServerRunning;
109
111
 
110
112
  return tool({
111
113
  description: TOOL_DESCRIPTION,
112
114
  args: {
113
115
  title: tool.schema.string(),
114
116
  kind: tool.schema.enum([...PUBLISH_KINDS] as [string, ...string[]]),
115
- html: tool.schema.string(),
117
+ html: tool.schema
118
+ .string()
119
+ .optional()
120
+ .describe(
121
+ "Body HTML — escape valve / legacy mode. Provide exactly one of html or blocks.",
122
+ ),
123
+ blocks: tool.schema
124
+ .array(tool.schema.any())
125
+ .optional()
126
+ .describe(
127
+ "Structured block array — preferred for token efficiency. Provide exactly one of html or blocks.",
128
+ ),
116
129
  summary: tool.schema.string().optional(),
117
130
  tags: tool.schema.array(tool.schema.string()).optional(),
118
131
  supersedes: tool.schema.string().optional(),
@@ -174,11 +187,24 @@ export function createPublishTool(
174
187
  // 6. Timestamps
175
188
  const createdAt = now();
176
189
 
177
- // 7. Scrub
178
- const scrubbed = scrub(input.html);
190
+ // 7. Render body (blocks path or html path)
191
+ let bodyHtml: string;
192
+ let scrubRemovedCount = 0;
193
+ const inputMode: "html" | "blocks" = input.blocks !== undefined ? "blocks" : "html";
194
+
195
+ if (input.blocks !== undefined) {
196
+ // Blocks path: render structured blocks → trusted HTML
197
+ const highlightTheme = resolveHighlightTheme(config.themePreset);
198
+ bodyHtml = await renderBlocks(input.blocks, { highlightTheme });
199
+ } else {
200
+ // HTML path: scrub agent-supplied HTML
201
+ const scrubbed = scrub(input.html);
202
+ bodyHtml = scrubbed.html;
203
+ scrubRemovedCount = scrubbed.removed.length;
204
+ }
179
205
 
180
206
  // 7a. Extract body text for full-text search
181
- const bodyText = extractTextContent(scrubbed.html);
207
+ const bodyText = extractTextContent(bodyHtml);
182
208
 
183
209
  // 8. Compute filename + paths
184
210
  const filename = artifactFilename({ title: input.title, id, createdAt });
@@ -189,7 +215,7 @@ export function createPublishTool(
189
215
  });
190
216
 
191
217
  // 9. Content SHA-256
192
- const contentSha256 = createHash("sha256").update(scrubbed.html).digest("hex");
218
+ const contentSha256 = createHash("sha256").update(bodyHtml).digest("hex");
193
219
 
194
220
  // 10. Build ArtifactMeta
195
221
  const meta: ArtifactMeta = {
@@ -210,14 +236,15 @@ export function createPublishTool(
210
236
  supersedes: input.supersedes ?? null,
211
237
  supersededBy: null,
212
238
  contentSha256,
239
+ inputMode,
213
240
  };
214
241
 
215
242
  // 11. Build warnings
216
243
  const warnings: string[] = [];
217
- if (scrubbed.removed.length > 0) {
218
- warnings.push(`Removed ${scrubbed.removed.length} external resource(s) during scrub.`);
244
+ if (scrubRemovedCount > 0) {
245
+ warnings.push(`Removed ${scrubRemovedCount} external resource(s) during scrub.`);
219
246
  }
220
- const bodyWarnings = htmlBodyWarnings(scrubbed.html);
247
+ const bodyWarnings = input.html !== undefined ? htmlBodyWarnings(bodyHtml) : [];
221
248
  for (const w of bodyWarnings) {
222
249
  warnings.push(w);
223
250
  }
@@ -225,12 +252,12 @@ export function createPublishTool(
225
252
  // 12. Build theme + wrap document
226
253
  const theme = mergeTheme(themeFromPreset(config.themePreset), config.theme);
227
254
 
228
- // 12a. Write theme.css + favicon.svg (idempotent, outside index lock — separate files)
229
- await writeThemeCss(config.stateDir, theme);
255
+ // 12a. Ensure theme.css + favicon.svg (idempotent, outside index lock — separate files)
256
+ await ensureThemeCss(config.stateDir, theme);
230
257
  await writeFaviconSvg(config.stateDir);
231
258
 
232
259
  const fullHtml = wrapDocument({
233
- body: scrubbed.html,
260
+ body: bodyHtml,
234
261
  meta,
235
262
  theme,
236
263
  warnings,
@@ -257,6 +284,7 @@ export function createPublishTool(
257
284
  projectSlug: identity.slug,
258
285
  projectName: identity.name,
259
286
  bodyText,
287
+ inputMode,
260
288
  };
261
289
 
262
290
  const lockPath = join(config.stateDir, ".index.lock");
@@ -336,6 +364,7 @@ export function createPublishTool(
336
364
  portMax: config.portMax,
337
365
  idleTimeoutMs: config.idleTimeoutMs,
338
366
  hostname: config.hostname,
367
+ theme,
339
368
  });
340
369
  if (maybeInfo !== null) {
341
370
  serverInfo = maybeInfo;