@cfbender/cesium 0.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.1 — 2026-05-12
4
+
5
+ Server-side syntax highlighting for `code` blocks via shiki, custom claret
6
+ themes derived from the canonical `claret.nvim` sources, and a critical fix
7
+ to the lazy-start lifecycle so `cesium stop` (or test friendly-fire) can no
8
+ longer kill the plugin host process.
9
+
10
+ - **fix (critical):** Lazy-started cesium server now runs as a detached
11
+ subprocess. Previously `ensureRunning` (called from publish/ask plugin
12
+ paths) ran `Bun.serve()` in-process and wrote `pid: process.pid` to the PID
13
+ file — meaning that PID was the *plugin host* (e.g. opencode). Any
14
+ invocation of `cesium stop` (CLI, tool, or test) would signal the host
15
+ process and kill it. Now lazy-start spawns `bun run cli serve` as a
16
+ detached child; the PID file points at that child. Foreground `cesium
17
+ serve` still runs in-process (correct for its semantics).
18
+ - **api:** Split `ensureRunning` into `runServerForeground` (in-process, for
19
+ the foreground CLI) and `ensureServerRunning` (detached subprocess, for
20
+ plugins). `ensureRunning` is kept as a backward-compat alias for
21
+ `runServerForeground`.
22
+ - **fix:** `test/cli-entry.test.ts` now sets `CESIUM_STATE_DIR` to a temp
23
+ dir per spawned subprocess, so the `cesium stop` test no longer reads the
24
+ user's real PID file.
25
+ - **feat:** Server-side syntax highlighting via shiki. `code` blocks are
26
+ tokenized at publish time and emit styled token spans. Async cascade
27
+ through `renderBlock`/`renderBlocks`/`renderSection`/`renderCode`.
28
+ - **feat:** Custom `claret-dark` shiki theme — converted directly from
29
+ `claret.nvim`'s `ports/bat/ClaretDark.tmTheme` so colors are faithful to
30
+ the canonical claret palette (keywords claret rose, strings olive,
31
+ comments muted italic, functions gold, etc.).
32
+ - **feat:** Custom `claret-light` shiki theme — derived from claret.nvim's
33
+ light palette using the same scope grammar as the dark theme.
34
+ - **feat:** `resolveHighlightTheme(cesiumThemeName)` — maps cesium presets
35
+ to the right shiki theme. `claret`/`claret-dark` → claret-dark,
36
+ `claret-light` → claret-light, all others → `vitesse-dark`.
37
+ - **feat:** `RenderCtx.highlightTheme` threaded from publish through the
38
+ render context. Default is `claret-dark` (matches the framework default).
39
+ - **feat:** `cesium serve` gains a `--state-dir` flag and respects
40
+ `CESIUM_STATE_DIR` / `CESIUM_PORT` env vars (used by the detached spawn).
41
+ - **dep:** Added `shiki@^4.0.0` as a runtime dependency (~3.8MB on disk;
42
+ languages loaded lazily).
43
+
3
44
  ## v0.5.0 — 2026-05-12
4
45
 
5
46
  Block-mode refactor — `cesium_publish` now accepts a structured `blocks` array
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfbender/cesium",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Beautiful self-contained HTML artifacts from your opencode agent.",
5
5
  "license": "MIT",
6
6
  "author": "Cody Bender",
@@ -50,7 +50,8 @@
50
50
  "dependencies": {
51
51
  "@opencode-ai/plugin": "latest",
52
52
  "nanoid": "^5.0.0",
53
- "parse5": "^7.1.0"
53
+ "parse5": "^7.1.0",
54
+ "shiki": "^4.0.2"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@types/bun": "latest",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { parseArgs } from "node:util";
4
4
  import { loadConfig, type CesiumConfig } from "../../config.ts";
5
- import { ensureRunning, stopRunning } from "../../server/lifecycle.ts";
5
+ import { runServerForeground, stopRunning } from "../../server/lifecycle.ts";
6
6
  import { resolveDisplayHost } from "../../tools/publish.ts";
7
7
  import { themeFromPreset, mergeTheme } from "../../render/theme.ts";
8
8
 
@@ -22,6 +22,7 @@ function defaultCtx(): ServeContext {
22
22
  export interface ServeOptions {
23
23
  port?: number;
24
24
  hostname?: string;
25
+ stateDir?: string;
25
26
  /**
26
27
  * Idle timeout in milliseconds. 0 (the default for `cesium serve`) means the
27
28
  * server runs forever until SIGINT/SIGTERM. Override with --idle-timeout to
@@ -54,6 +55,7 @@ export function parseServeArgs(
54
55
  port: string | undefined;
55
56
  hostname: string | undefined;
56
57
  "idle-timeout": string | undefined;
58
+ "state-dir": string | undefined;
57
59
  help: boolean;
58
60
  };
59
61
 
@@ -64,6 +66,7 @@ export function parseServeArgs(
64
66
  port: { type: "string", short: "p" },
65
67
  hostname: { type: "string", short: "H" },
66
68
  "idle-timeout": { type: "string" },
69
+ "state-dir": { type: "string" },
67
70
  help: { type: "boolean", short: "h", default: false },
68
71
  },
69
72
  allowPositionals: false,
@@ -85,6 +88,7 @@ export function parseServeArgs(
85
88
  "Options:",
86
89
  " --port, -p N Override configured port (default: 3030)",
87
90
  " --hostname, -H H Override configured bind address (default: 127.0.0.1)",
91
+ " --state-dir DIR Override the cesium state directory",
88
92
  " --idle-timeout DUR Auto-shutdown after DUR of inactivity. Accepts plain",
89
93
  " milliseconds or a suffixed value (90s, 30m, 2h).",
90
94
  " Use 0 / never / off to disable. Default: 0 (never).",
@@ -121,6 +125,14 @@ export function parseServeArgs(
121
125
  opts.hostname = values.hostname;
122
126
  }
123
127
 
128
+ if (values["state-dir"] !== undefined) {
129
+ if (values["state-dir"].length === 0) {
130
+ ctx.stderr.write(`cesium serve: --state-dir must not be empty\n`);
131
+ return null;
132
+ }
133
+ opts.stateDir = values["state-dir"];
134
+ }
135
+
124
136
  if (values["idle-timeout"] !== undefined) {
125
137
  const ms = parseDuration(values["idle-timeout"]);
126
138
  if (ms === null) {
@@ -154,6 +166,7 @@ export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>):
154
166
  // Apply overrides from CLI flags
155
167
  const effectiveCfg = {
156
168
  ...cfg,
169
+ ...(opts.stateDir !== undefined ? { stateDir: opts.stateDir } : {}),
157
170
  ...(opts.port !== undefined ? { port: opts.port, portMax: opts.port } : {}),
158
171
  ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
159
172
  idleTimeoutMs: effectiveIdleTimeoutMs,
@@ -162,7 +175,7 @@ export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>):
162
175
  let serverInfo: { port: number; url: string };
163
176
  try {
164
177
  const theme = mergeTheme(themeFromPreset(effectiveCfg.themePreset), effectiveCfg.theme);
165
- serverInfo = await ensureRunning({
178
+ serverInfo = await runServerForeground({
166
179
  stateDir: effectiveCfg.stateDir,
167
180
  port: effectiveCfg.port,
168
181
  portMax: effectiveCfg.portMax,
@@ -0,0 +1,188 @@
1
+ // Server-side syntax highlighting via shiki.
2
+ // src/render/blocks/highlight.ts
3
+ //
4
+ // Lazy-initializes a shared highlighter on first call.
5
+ // Returns styled <span> tokens only — no <pre> wrapper.
6
+ // The caller (code renderer) is responsible for the <pre><code> panel chrome.
7
+
8
+ import type { ThemedToken, BundledLanguage } from "shiki";
9
+ import { escapeHtml } from "./escape.ts";
10
+ import { claretDark } from "./themes/claret-dark.ts";
11
+ import { claretLight } from "./themes/claret-light.ts";
12
+ import { THEME_PRESETS, isThemePresetName } from "../../render/theme.ts";
13
+
14
+ // ─── Highlight theme type ─────────────────────────────────────────────────────
15
+
16
+ export type HighlightTheme =
17
+ | "claret-dark"
18
+ | "claret-light"
19
+ | "vitesse-dark"
20
+ | "vitesse-light";
21
+
22
+ // ─── Supported languages ─────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * The curated language list loaded into the highlighter.
26
+ * ~25 languages covering 95 %+ of real use.
27
+ */
28
+ export const SUPPORTED_LANGUAGES: readonly string[] = [
29
+ "typescript",
30
+ "ts",
31
+ "tsx",
32
+ "javascript",
33
+ "js",
34
+ "jsx",
35
+ "json",
36
+ "html",
37
+ "css",
38
+ "markdown",
39
+ "md",
40
+ "shellscript",
41
+ "sh",
42
+ "bash",
43
+ "shell",
44
+ "python",
45
+ "py",
46
+ "rust",
47
+ "go",
48
+ "ruby",
49
+ "rb",
50
+ "yaml",
51
+ "yml",
52
+ "sql",
53
+ "toml",
54
+ "dockerfile",
55
+ "diff",
56
+ ];
57
+
58
+ // ─── Theme resolution ─────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Map a cesium theme preset name to the appropriate shiki highlight theme.
62
+ *
63
+ * - "claret" / "claret-dark" → "claret-dark" (custom)
64
+ * - "claret-light" → "claret-light" (custom)
65
+ * - other named preset → vitesse-dark if codeBg is dark, else vitesse-light
66
+ * - unknown / undefined → "claret-dark" (matches framework default in themeFromPreset)
67
+ */
68
+ export function resolveHighlightTheme(cesiumThemeName: string | undefined): HighlightTheme {
69
+ if (cesiumThemeName === "claret" || cesiumThemeName === "claret-dark") {
70
+ return "claret-dark";
71
+ }
72
+ if (cesiumThemeName === "claret-light") {
73
+ return "claret-light";
74
+ }
75
+ if (cesiumThemeName !== undefined && isThemePresetName(cesiumThemeName)) {
76
+ const palette = THEME_PRESETS[cesiumThemeName];
77
+ // Use the code panel background color to choose the shiki theme.
78
+ // All current non-claret presets have a dark codeBg, but check anyway.
79
+ return isHexDark(palette.codeBg) ? "vitesse-dark" : "vitesse-light";
80
+ }
81
+ // undefined / unknown — match the framework theme default (claret-dark).
82
+ return "claret-dark";
83
+ }
84
+
85
+ /**
86
+ * Returns true if the hex color's perceived luminance is dark (< 0.5).
87
+ * Accepts 3- or 6-digit hex with or without leading '#'.
88
+ */
89
+ function isHexDark(hex: string): boolean {
90
+ const clean = hex.replace("#", "");
91
+ const full = clean.length === 3
92
+ ? clean
93
+ .split("")
94
+ .map((c) => c + c)
95
+ .join("")
96
+ : clean;
97
+ const r = parseInt(full.slice(0, 2), 16);
98
+ const g = parseInt(full.slice(2, 4), 16);
99
+ const b = parseInt(full.slice(4, 6), 16);
100
+ // Simple average luminance threshold
101
+ const avg = (r + g + b) / 3;
102
+ return avg < 128;
103
+ }
104
+
105
+ // ─── Highlighter singleton ────────────────────────────────────────────────────
106
+
107
+ /** Promise cache — concurrent first-calls share one init. */
108
+ let highlighterPromise: Promise<import("shiki").Highlighter> | null = null;
109
+
110
+ async function getHighlighter(): Promise<import("shiki").Highlighter> {
111
+ if (highlighterPromise === null) {
112
+ const { createHighlighter } = await import("shiki");
113
+ highlighterPromise = createHighlighter({
114
+ themes: [
115
+ // Custom claret themes passed as ThemeRegistration objects
116
+ claretDark,
117
+ claretLight,
118
+ // Vitesse themes loaded by name from the bundled set
119
+ "vitesse-dark",
120
+ "vitesse-light",
121
+ ],
122
+ langs: SUPPORTED_LANGUAGES as string[],
123
+ });
124
+ }
125
+ return highlighterPromise;
126
+ }
127
+
128
+ // ─── Public API ───────────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Highlight `code` in language `lang` with the given `theme`.
132
+ * Returns the inner HTML for a `<code>` element: one `<span class="line">` per
133
+ * source line, each containing `<span style="color:...">` token spans.
134
+ *
135
+ * If `lang` is not in SUPPORTED_LANGUAGES, falls back to plain-escaped output
136
+ * wrapped the same way (one `<span class="line">` per line, no color spans).
137
+ *
138
+ * shiki internally escapes `<`, `>`, `&` in token content, so XSS is covered.
139
+ * The plain-text fallback goes through `escapeHtml` for the same guarantee.
140
+ */
141
+ export async function highlightCode(
142
+ code: string,
143
+ lang: string,
144
+ theme: HighlightTheme = "claret-dark",
145
+ ): Promise<string> {
146
+ const supported = SUPPORTED_LANGUAGES.includes(lang);
147
+
148
+ if (!supported) {
149
+ return plainFallback(code);
150
+ }
151
+
152
+ const hi = await getHighlighter();
153
+ const result = hi.codeToTokens(code, { theme, lang: lang as BundledLanguage });
154
+
155
+ return tokensToHtml(result.tokens);
156
+ }
157
+
158
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Render shiki token lines into `<span class="line">` HTML.
162
+ * Token `content` is already HTML-escaped by shiki.
163
+ */
164
+ function tokensToHtml(lines: ThemedToken[][]): string {
165
+ return lines
166
+ .map((line) => {
167
+ const inner = line
168
+ .map((token) => {
169
+ if (token.color !== undefined && token.color !== "") {
170
+ return `<span style="color:${token.color}">${token.content}</span>`;
171
+ }
172
+ // No color info — emit bare content (shiki has already escaped it)
173
+ return token.content;
174
+ })
175
+ .join("");
176
+ return `<span class="line">${inner}</span>`;
177
+ })
178
+ .join("\n");
179
+ }
180
+
181
+ /**
182
+ * Plain-text fallback: escape HTML and wrap each line in a `<span class="line">`.
183
+ * Used when the requested language is not in the supported set.
184
+ */
185
+ function plainFallback(code: string): string {
186
+ const lines = code.split("\n");
187
+ return lines.map((line) => `<span class="line">${escapeHtml(line)}</span>`).join("\n");
188
+ }
@@ -2,6 +2,7 @@
2
2
  // src/render/blocks/render.ts
3
3
 
4
4
  import type { Block } from "./types.ts";
5
+ import type { HighlightTheme } from "./highlight.ts";
5
6
  import { renderHero } from "./renderers/hero.ts";
6
7
  import { renderTldr } from "./renderers/tldr.ts";
7
8
  import { renderSection } from "./renderers/section.ts";
@@ -31,18 +32,21 @@ export interface RenderCtx {
31
32
  depth: number;
32
33
  /** Path string for error messages (e.g. "blocks[2].children[1]"). */
33
34
  path: string;
35
+ /** Shiki highlight theme derived from the active cesium theme preset. */
36
+ highlightTheme: HighlightTheme;
34
37
  }
35
38
 
36
- function makeRootCtx(): RenderCtx {
39
+ function makeRootCtx(highlightTheme: HighlightTheme = "claret-dark"): RenderCtx {
37
40
  return {
38
41
  sectionCounter: { value: 1 },
39
42
  depth: 0,
40
43
  path: "blocks",
44
+ highlightTheme,
41
45
  };
42
46
  }
43
47
 
44
48
  /** Dispatch a single block to its renderer. */
45
- export function renderBlock(block: Block, ctx: RenderCtx): string {
49
+ export async function renderBlock(block: Block, ctx: RenderCtx): Promise<string> {
46
50
  switch (block.type) {
47
51
  case "hero":
48
52
  return renderHero(block, ctx);
@@ -78,8 +82,11 @@ export function renderBlock(block: Block, ctx: RenderCtx): string {
78
82
  }
79
83
 
80
84
  /** Render an array of blocks, returning the concatenated HTML body string. */
81
- export function renderBlocks(blocks: Block[], opts?: { title?: string }): string {
82
- const ctx = makeRootCtx();
85
+ export async function renderBlocks(
86
+ blocks: Block[],
87
+ opts?: { title?: string; highlightTheme?: HighlightTheme },
88
+ ): Promise<string> {
89
+ const ctx = makeRootCtx(opts?.highlightTheme);
83
90
  const parts: string[] = [];
84
91
  for (let i = 0; i < blocks.length; i++) {
85
92
  const block = blocks[i];
@@ -88,7 +95,8 @@ export function renderBlocks(blocks: Block[], opts?: { title?: string }): string
88
95
  ...ctx,
89
96
  path: `blocks[${i}]`,
90
97
  };
91
- parts.push(renderBlock(block, blockCtx));
98
+ // eslint-disable-next-line no-await-in-loop -- sequential render required; section counter is a shared mutable ref
99
+ parts.push(await renderBlock(block, blockCtx));
92
100
  }
93
101
  // Unused opts.title kept for API compatibility; wrapDocument handles the title
94
102
  void opts;
@@ -1,12 +1,13 @@
1
- // Code block renderer.
1
+ // Code block renderer — server-side syntax highlighting via shiki.
2
2
  // src/render/blocks/renderers/code.ts
3
3
 
4
4
  import type { CodeBlock } from "../types.ts";
5
5
  import type { BlockMeta } from "../types.ts";
6
6
  import type { RenderCtx } from "../render.ts";
7
7
  import { escapeHtml, escapeAttr } from "../escape.ts";
8
+ import { highlightCode } from "../highlight.ts";
8
9
 
9
- export function renderCode(block: CodeBlock, _ctx: RenderCtx): string {
10
+ export async function renderCode(block: CodeBlock, ctx: RenderCtx): Promise<string> {
10
11
  const parts: string[] = [];
11
12
 
12
13
  const captionText = block.filename ?? block.caption;
@@ -14,8 +15,9 @@ export function renderCode(block: CodeBlock, _ctx: RenderCtx): string {
14
15
  parts.push(` <figcaption>${escapeHtml(captionText)}</figcaption>`);
15
16
  }
16
17
 
18
+ const highlighted = await highlightCode(block.code, block.lang, ctx.highlightTheme);
17
19
  parts.push(
18
- ` <pre><code class="lang-${escapeAttr(block.lang)}">${escapeHtml(block.code)}</code></pre>`,
20
+ ` <pre><code class="lang-${escapeAttr(block.lang)}">${highlighted}</code></pre>`,
19
21
  );
20
22
 
21
23
  return `<figure class="code">\n${parts.join("\n")}\n</figure>`;
@@ -7,7 +7,7 @@ import type { RenderCtx } from "../render.ts";
7
7
  import { renderBlock } from "../render.ts";
8
8
  import { escapeHtml } from "../escape.ts";
9
9
 
10
- export function renderSection(block: SectionBlock, ctx: RenderCtx): string {
10
+ export async function renderSection(block: SectionBlock, ctx: RenderCtx): Promise<string> {
11
11
  // Determine section number: explicit or auto-increment
12
12
  let num: string;
13
13
  if (block.num !== undefined && block.num !== "") {
@@ -34,6 +34,7 @@ export function renderSection(block: SectionBlock, ctx: RenderCtx): string {
34
34
  sectionCounter: ctx.sectionCounter,
35
35
  depth: ctx.depth + 1,
36
36
  path: `${ctx.path}.children`,
37
+ highlightTheme: ctx.highlightTheme,
37
38
  };
38
39
 
39
40
  let buffer: string[] = [];
@@ -45,7 +46,8 @@ export function renderSection(block: SectionBlock, ctx: RenderCtx): string {
45
46
  ...childCtx,
46
47
  path: `${ctx.path}.children[${i}]`,
47
48
  };
48
- const rendered = renderBlock(child, childBlockCtx);
49
+ // eslint-disable-next-line no-await-in-loop -- sequential render required; card buffer tracks contiguous non-section children
50
+ const rendered = await renderBlock(child, childBlockCtx);
49
51
  if (child.type === "section") {
50
52
  // Flush buffered non-section children into a card first
51
53
  if (buffer.length > 0) {
@@ -0,0 +1,206 @@
1
+ // Claret-dark shiki theme — converted from ports/bat/ClaretDark.tmTheme.
2
+ // src/render/blocks/themes/claret-dark.ts
3
+ //
4
+ // Source of truth: /claret.nvim/ports/bat/ClaretDark.tmTheme
5
+ // Every scope rule in the tmTheme is preserved verbatim; comma-separated
6
+ // scope strings are split into the string[] form shiki prefers.
7
+ //
8
+ // Global tokens (from tmTheme global settings):
9
+ // bg #180810 — editor background
10
+ // fg #DDD3C7 — default foreground
11
+ // selection #2B1F22
12
+ // gutter fg #71685E
13
+ //
14
+ // Derived palette reference (claret.nvim/lua/claret/palette.lua — dark):
15
+ // rose_1 #C75B7A keyword / statement / accent
16
+ // rose_2 #B04A68 property / data keys
17
+ // gold_1 #D4A76A function / number / constant / decorator
18
+ // sage_1 #8FA86E string
19
+ // slate_1 #8995A8 type / class / tag / escape / link
20
+ // slate_2 #6E7A90 tag attribute
21
+ // text #DDD3C7 variable / default fg
22
+ // text_2 #BDB3A7 operator (syntax.lua maps Operator → text_2; tmTheme uses #9E9288)
23
+ // NOTE: the tmTheme uses #9E9288 (text_3) for operator/punctuation — preserved.
24
+ // text_4 #71685E comment (fg)
25
+ // terra_1 #C44536 invalid / diff deleted
26
+
27
+ import type { ThemeRegistration } from "shiki";
28
+
29
+ export const claretDark: ThemeRegistration = {
30
+ name: "claret-dark",
31
+ type: "dark",
32
+ fg: "#DDD3C7",
33
+ bg: "#180810",
34
+ colors: {
35
+ "editor.foreground": "#DDD3C7",
36
+ "editor.background": "#180810",
37
+ "editor.selectionBackground": "#2B1F22",
38
+ "editor.lineHighlightBackground": "#2B1F22",
39
+ "editorLineNumber.foreground": "#71685E",
40
+ },
41
+ tokenColors: [
42
+ // Comment — #71685E italic
43
+ {
44
+ name: "Comment",
45
+ scope: ["comment", "punctuation.definition.comment"],
46
+ settings: { foreground: "#71685E", fontStyle: "italic" },
47
+ },
48
+ // Keyword — #C75B7A
49
+ {
50
+ name: "Keyword",
51
+ scope: ["keyword", "storage.type", "storage.modifier"],
52
+ settings: { foreground: "#C75B7A" },
53
+ },
54
+ // Function — #D4A76A
55
+ {
56
+ name: "Function",
57
+ scope: ["entity.name.function", "support.function"],
58
+ settings: { foreground: "#D4A76A" },
59
+ },
60
+ // String — #8FA86E
61
+ {
62
+ name: "String",
63
+ scope: ["string", "punctuation.definition.string"],
64
+ settings: { foreground: "#8FA86E" },
65
+ },
66
+ // Number — #D4A76A
67
+ {
68
+ name: "Number",
69
+ scope: ["constant.numeric"],
70
+ settings: { foreground: "#D4A76A" },
71
+ },
72
+ // Constant — #D4A76A
73
+ {
74
+ name: "Constant",
75
+ scope: ["constant", "constant.language", "variable.language"],
76
+ settings: { foreground: "#D4A76A" },
77
+ },
78
+ // Type — #8995A8
79
+ {
80
+ name: "Type",
81
+ scope: [
82
+ "entity.name.type",
83
+ "entity.name.class",
84
+ "support.type",
85
+ "support.class",
86
+ ],
87
+ settings: { foreground: "#8995A8" },
88
+ },
89
+ // Variable — #DDD3C7
90
+ {
91
+ name: "Variable",
92
+ scope: ["variable", "variable.parameter"],
93
+ settings: { foreground: "#DDD3C7" },
94
+ },
95
+ // Parameter — #DDD3C7 italic (overrides Variable for parameters)
96
+ {
97
+ name: "Parameter",
98
+ scope: ["variable.parameter"],
99
+ settings: { foreground: "#DDD3C7", fontStyle: "italic" },
100
+ },
101
+ // Property — #B04A68
102
+ {
103
+ name: "Property",
104
+ scope: ["variable.other.property", "variable.other.member"],
105
+ settings: { foreground: "#B04A68" },
106
+ },
107
+ // JSON/YAML/TOML Keys — #B04A68
108
+ {
109
+ name: "JSON/YAML/TOML Keys",
110
+ scope: [
111
+ "meta.mapping.key string",
112
+ "support.type.property-name.json",
113
+ "punctuation.support.type.property-name.json",
114
+ "support.type.property-name.toml",
115
+ "punctuation.support.type.property-name.toml",
116
+ "entity.name.tag.yaml",
117
+ "support.type.property-name.yaml",
118
+ ],
119
+ settings: { foreground: "#B04A68" },
120
+ },
121
+ // Operator — #9E9288
122
+ {
123
+ name: "Operator",
124
+ scope: ["keyword.operator"],
125
+ settings: { foreground: "#9E9288" },
126
+ },
127
+ // Punctuation — #9E9288
128
+ {
129
+ name: "Punctuation",
130
+ scope: ["punctuation"],
131
+ settings: { foreground: "#9E9288" },
132
+ },
133
+ // Decorator — #D4A76A italic
134
+ {
135
+ name: "Decorator",
136
+ scope: ["meta.decorator", "punctuation.decorator"],
137
+ settings: { foreground: "#D4A76A", fontStyle: "italic" },
138
+ },
139
+ // Tag — #8995A8
140
+ {
141
+ name: "Tag",
142
+ scope: ["entity.name.tag"],
143
+ settings: { foreground: "#8995A8" },
144
+ },
145
+ // Tag Attribute — #6E7A90
146
+ {
147
+ name: "Tag Attribute",
148
+ scope: ["entity.other.attribute-name"],
149
+ settings: { foreground: "#6E7A90" },
150
+ },
151
+ // Invalid — #C44536
152
+ {
153
+ name: "Invalid",
154
+ scope: ["invalid", "invalid.illegal"],
155
+ settings: { foreground: "#C44536" },
156
+ },
157
+ // Escape — #8995A8
158
+ {
159
+ name: "Escape",
160
+ scope: ["constant.character.escape"],
161
+ settings: { foreground: "#8995A8" },
162
+ },
163
+ // Markup Heading — #C75B7A bold
164
+ {
165
+ name: "Markup Heading",
166
+ scope: ["markup.heading"],
167
+ settings: { foreground: "#C75B7A", fontStyle: "bold" },
168
+ },
169
+ // Markup Bold — bold (no color override)
170
+ {
171
+ name: "Markup Bold",
172
+ scope: ["markup.bold"],
173
+ settings: { fontStyle: "bold" },
174
+ },
175
+ // Markup Italic — italic (no color override)
176
+ {
177
+ name: "Markup Italic",
178
+ scope: ["markup.italic"],
179
+ settings: { fontStyle: "italic" },
180
+ },
181
+ // Markup Link — #8995A8
182
+ {
183
+ name: "Markup Link",
184
+ scope: ["markup.underline.link", "string.other.link"],
185
+ settings: { foreground: "#8995A8" },
186
+ },
187
+ // Diff Added — #8FA86E
188
+ {
189
+ name: "Diff Added",
190
+ scope: ["markup.inserted"],
191
+ settings: { foreground: "#8FA86E" },
192
+ },
193
+ // Diff Deleted — #C44536
194
+ {
195
+ name: "Diff Deleted",
196
+ scope: ["markup.deleted"],
197
+ settings: { foreground: "#C44536" },
198
+ },
199
+ // Diff Changed — #D4A76A
200
+ {
201
+ name: "Diff Changed",
202
+ scope: ["markup.changed"],
203
+ settings: { foreground: "#D4A76A" },
204
+ },
205
+ ],
206
+ };
@@ -0,0 +1,227 @@
1
+ // Claret-light shiki theme — derived from claret.nvim light palette.
2
+ // src/render/blocks/themes/claret-light.ts
3
+ //
4
+ // Source of truth: /claret.nvim/lua/claret/palette.lua (light section)
5
+ // Scope-to-role mapping mirrors ClaretDark.tmTheme exactly; colors
6
+ // are substituted from the light palette using the same semantic roles.
7
+ //
8
+ // Light palette (palette.lua):
9
+ // bg #F5E6E2 editor background
10
+ // bg_mute #DDD0CC selection / line highlight
11
+ // text #2A1F1A default fg
12
+ // text_4 #928578 comment / gutter fg
13
+ // rose_1 #B80842 keyword / heading / statement
14
+ // rose_2 #920820 property / data keys
15
+ // gold_1 #946000 function / number / constant / decorator
16
+ // sage_1 #1B5500 string
17
+ // slate_1 #0E3088 type / class / tag / escape / link
18
+ // slate_2 #0A2575 tag attribute
19
+ // terra_1 #D42010 invalid / diff deleted
20
+ //
21
+ // Role mapping (matching dark tmTheme):
22
+ // Comment → text_4 #928578 italic
23
+ // Keyword → rose_1 #B80842
24
+ // Function → gold_1 #946000
25
+ // String → sage_1 #1B5500
26
+ // Number → gold_1 #946000
27
+ // Constant → gold_1 #946000
28
+ // Type → slate_1 #0E3088
29
+ // Variable → text #2A1F1A
30
+ // Parameter → text #2A1F1A italic
31
+ // Property → rose_2 #920820
32
+ // Keys → rose_2 #920820
33
+ // Operator → text_4 #928578 (text_3 equivalent; nearest warm muted in light palette)
34
+ // Punctuation → text_4 #928578
35
+ // Decorator → gold_1 #946000 italic
36
+ // Tag → slate_1 #0E3088
37
+ // Tag Attribute → slate_2 #0A2575
38
+ // Invalid → terra_1 #D42010
39
+ // Escape → slate_1 #0E3088
40
+ // Markup Heading→ rose_1 #B80842 bold
41
+ // Markup Bold → (no fg) bold
42
+ // Markup Italic → (no fg) italic
43
+ // Markup Link → slate_1 #0E3088
44
+ // Diff Added → sage_1 #1B5500
45
+ // Diff Deleted → terra_1 #D42010
46
+ // Diff Changed → gold_1 #946000
47
+
48
+ import type { ThemeRegistration } from "shiki";
49
+
50
+ export const claretLight: ThemeRegistration = {
51
+ name: "claret-light",
52
+ type: "light",
53
+ fg: "#2A1F1A",
54
+ bg: "#F5E6E2",
55
+ colors: {
56
+ "editor.foreground": "#2A1F1A",
57
+ "editor.background": "#F5E6E2",
58
+ "editor.selectionBackground": "#DDD0CC",
59
+ "editor.lineHighlightBackground": "#DDD0CC",
60
+ "editorLineNumber.foreground": "#928578",
61
+ },
62
+ tokenColors: [
63
+ // Comment — #928578 italic
64
+ {
65
+ name: "Comment",
66
+ scope: ["comment", "punctuation.definition.comment"],
67
+ settings: { foreground: "#928578", fontStyle: "italic" },
68
+ },
69
+ // Keyword — #B80842
70
+ {
71
+ name: "Keyword",
72
+ scope: ["keyword", "storage.type", "storage.modifier"],
73
+ settings: { foreground: "#B80842" },
74
+ },
75
+ // Function — #946000
76
+ {
77
+ name: "Function",
78
+ scope: ["entity.name.function", "support.function"],
79
+ settings: { foreground: "#946000" },
80
+ },
81
+ // String — #1B5500
82
+ {
83
+ name: "String",
84
+ scope: ["string", "punctuation.definition.string"],
85
+ settings: { foreground: "#1B5500" },
86
+ },
87
+ // Number — #946000
88
+ {
89
+ name: "Number",
90
+ scope: ["constant.numeric"],
91
+ settings: { foreground: "#946000" },
92
+ },
93
+ // Constant — #946000
94
+ {
95
+ name: "Constant",
96
+ scope: ["constant", "constant.language", "variable.language"],
97
+ settings: { foreground: "#946000" },
98
+ },
99
+ // Type — #0E3088
100
+ {
101
+ name: "Type",
102
+ scope: [
103
+ "entity.name.type",
104
+ "entity.name.class",
105
+ "support.type",
106
+ "support.class",
107
+ ],
108
+ settings: { foreground: "#0E3088" },
109
+ },
110
+ // Variable — #2A1F1A
111
+ {
112
+ name: "Variable",
113
+ scope: ["variable", "variable.parameter"],
114
+ settings: { foreground: "#2A1F1A" },
115
+ },
116
+ // Parameter — #2A1F1A italic (overrides Variable for parameters)
117
+ {
118
+ name: "Parameter",
119
+ scope: ["variable.parameter"],
120
+ settings: { foreground: "#2A1F1A", fontStyle: "italic" },
121
+ },
122
+ // Property — #920820
123
+ {
124
+ name: "Property",
125
+ scope: ["variable.other.property", "variable.other.member"],
126
+ settings: { foreground: "#920820" },
127
+ },
128
+ // JSON/YAML/TOML Keys — #920820
129
+ {
130
+ name: "JSON/YAML/TOML Keys",
131
+ scope: [
132
+ "meta.mapping.key string",
133
+ "support.type.property-name.json",
134
+ "punctuation.support.type.property-name.json",
135
+ "support.type.property-name.toml",
136
+ "punctuation.support.type.property-name.toml",
137
+ "entity.name.tag.yaml",
138
+ "support.type.property-name.yaml",
139
+ ],
140
+ settings: { foreground: "#920820" },
141
+ },
142
+ // Operator — #928578
143
+ {
144
+ name: "Operator",
145
+ scope: ["keyword.operator"],
146
+ settings: { foreground: "#928578" },
147
+ },
148
+ // Punctuation — #928578
149
+ {
150
+ name: "Punctuation",
151
+ scope: ["punctuation"],
152
+ settings: { foreground: "#928578" },
153
+ },
154
+ // Decorator — #946000 italic
155
+ {
156
+ name: "Decorator",
157
+ scope: ["meta.decorator", "punctuation.decorator"],
158
+ settings: { foreground: "#946000", fontStyle: "italic" },
159
+ },
160
+ // Tag — #0E3088
161
+ {
162
+ name: "Tag",
163
+ scope: ["entity.name.tag"],
164
+ settings: { foreground: "#0E3088" },
165
+ },
166
+ // Tag Attribute — #0A2575
167
+ {
168
+ name: "Tag Attribute",
169
+ scope: ["entity.other.attribute-name"],
170
+ settings: { foreground: "#0A2575" },
171
+ },
172
+ // Invalid — #D42010
173
+ {
174
+ name: "Invalid",
175
+ scope: ["invalid", "invalid.illegal"],
176
+ settings: { foreground: "#D42010" },
177
+ },
178
+ // Escape — #0E3088
179
+ {
180
+ name: "Escape",
181
+ scope: ["constant.character.escape"],
182
+ settings: { foreground: "#0E3088" },
183
+ },
184
+ // Markup Heading — #B80842 bold
185
+ {
186
+ name: "Markup Heading",
187
+ scope: ["markup.heading"],
188
+ settings: { foreground: "#B80842", fontStyle: "bold" },
189
+ },
190
+ // Markup Bold — bold (no color override)
191
+ {
192
+ name: "Markup Bold",
193
+ scope: ["markup.bold"],
194
+ settings: { fontStyle: "bold" },
195
+ },
196
+ // Markup Italic — italic (no color override)
197
+ {
198
+ name: "Markup Italic",
199
+ scope: ["markup.italic"],
200
+ settings: { fontStyle: "italic" },
201
+ },
202
+ // Markup Link — #0E3088
203
+ {
204
+ name: "Markup Link",
205
+ scope: ["markup.underline.link", "string.other.link"],
206
+ settings: { foreground: "#0E3088" },
207
+ },
208
+ // Diff Added — #1B5500
209
+ {
210
+ name: "Diff Added",
211
+ scope: ["markup.inserted"],
212
+ settings: { foreground: "#1B5500" },
213
+ },
214
+ // Diff Deleted — #D42010
215
+ {
216
+ name: "Diff Deleted",
217
+ scope: ["markup.deleted"],
218
+ settings: { foreground: "#D42010" },
219
+ },
220
+ // Diff Changed — #946000
221
+ {
222
+ name: "Diff Changed",
223
+ scope: ["markup.changed"],
224
+ settings: { foreground: "#946000" },
225
+ },
226
+ ],
227
+ };
@@ -2,7 +2,10 @@
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";
@@ -202,8 +205,19 @@ export async function stopRunning(stateDir: string): Promise<void> {
202
205
  }
203
206
  }
204
207
 
205
- export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo> {
206
- const { stateDir, port, portMax, idleTimeoutMs, hostname = "127.0.0.1", theme = defaultTheme() } = 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;
207
221
  const pidFilePath = join(stateDir, ".server.pid");
208
222
  const lockPath = join(stateDir, ".server-start.lock");
209
223
 
@@ -310,6 +324,173 @@ export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo>
310
324
  }
311
325
  }
312
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
+
313
494
  // ─── Test reset hook ──────────────────────────────────────────────────────────
314
495
  // This function is intended for test use only. It clears module-level singleton
315
496
  // state, stops any running server, and removes signal/exit listeners.
package/src/tools/ask.ts CHANGED
@@ -20,7 +20,7 @@ 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,
@@ -11,6 +11,7 @@ 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
13
  import { renderBlocks } from "../render/blocks/render.ts";
14
+ import { resolveHighlightTheme } from "../render/blocks/highlight.ts";
14
15
  import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
15
16
  import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
16
17
  import { atomicWrite, patchEmbeddedMetadata } from "../storage/write.ts";
@@ -27,7 +28,7 @@ import { withLock } from "../storage/lock.ts";
27
28
  import { renderProjectIndex, renderGlobalIndex } from "../storage/index-gen.ts";
28
29
  import { buildProjectSummaries } from "../storage/project-summaries.ts";
29
30
  import {
30
- ensureRunning as defaultEnsureRunning,
31
+ ensureServerRunning as defaultEnsureServerRunning,
31
32
  type RunningInfo,
32
33
  type LifecycleConfig,
33
34
  } from "../server/lifecycle.ts";
@@ -106,7 +107,7 @@ export function createPublishTool(
106
107
  const resolveConfig = overrides?.loadConfig ?? loadConfig;
107
108
  const now = overrides?.now ?? (() => new Date());
108
109
  const genId = overrides?.nanoid ?? defaultNanoid;
109
- const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureRunning;
110
+ const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureServerRunning;
110
111
 
111
112
  return tool({
112
113
  description: TOOL_DESCRIPTION,
@@ -193,7 +194,8 @@ export function createPublishTool(
193
194
 
194
195
  if (input.blocks !== undefined) {
195
196
  // Blocks path: render structured blocks → trusted HTML
196
- bodyHtml = renderBlocks(input.blocks);
197
+ const highlightTheme = resolveHighlightTheme(config.themePreset);
198
+ bodyHtml = await renderBlocks(input.blocks, { highlightTheme });
197
199
  } else {
198
200
  // HTML path: scrub agent-supplied HTML
199
201
  const scrubbed = scrub(input.html);
@@ -8,7 +8,7 @@ import type { RenderCtx, SectionCounter } from "../render/blocks/render.ts";
8
8
 
9
9
  function makeCtx(): RenderCtx {
10
10
  const counter: SectionCounter = { value: 1 };
11
- return { sectionCounter: counter, depth: 0, path: "blocks[0]" };
11
+ return { sectionCounter: counter, depth: 0, path: "blocks[0]", highlightTheme: "claret-dark" };
12
12
  }
13
13
 
14
14
  /** Escape a string for safe insertion inside a markdown fenced code block. */
@@ -18,7 +18,7 @@ function escapeForCodeFence(s: string): string {
18
18
  }
19
19
 
20
20
  /** Generate the full markdown reference from the catalog. Deterministic — same catalog → same output. */
21
- export function generateStyleguideMarkdown(): string {
21
+ export async function generateStyleguideMarkdown(): Promise<string> {
22
22
  const lines: string[] = [];
23
23
 
24
24
  lines.push("# Cesium publishing reference");
@@ -40,8 +40,26 @@ export function generateStyleguideMarkdown(): string {
40
40
  lines.push("## Block reference");
41
41
  lines.push("");
42
42
 
43
- for (const blockType of blockTypes) {
43
+ // Pre-render all examples in parallel (order preserved via index)
44
+ const renderedExamples = await Promise.all(
45
+ blockTypes.map(async (blockType) => {
46
+ const entry = blockCatalog[blockType];
47
+ if (entry.renderedExample !== undefined) {
48
+ return entry.renderedExample;
49
+ }
50
+ try {
51
+ return await renderBlock(entry.example, makeCtx());
52
+ } catch {
53
+ return "";
54
+ }
55
+ }),
56
+ );
57
+
58
+ for (let i = 0; i < blockTypes.length; i++) {
59
+ const blockType = blockTypes[i];
60
+ if (blockType === undefined) continue;
44
61
  const entry = blockCatalog[blockType];
62
+ const rendered = renderedExamples[i] ?? "";
45
63
 
46
64
  lines.push(`### \`${entry.type}\``);
47
65
  lines.push("");
@@ -62,15 +80,6 @@ export function generateStyleguideMarkdown(): string {
62
80
  lines.push("```");
63
81
  lines.push("");
64
82
 
65
- // Rendered HTML
66
- const rendered = entry.renderedExample ?? (() => {
67
- try {
68
- return renderBlock(entry.example, makeCtx());
69
- } catch {
70
- return "";
71
- }
72
- })();
73
-
74
83
  if (rendered !== "") {
75
84
  lines.push("Renders to:");
76
85
  lines.push("");
@@ -117,7 +126,7 @@ export function createStyleguideTool(_ctx: PluginInput): ReturnType<typeof tool>
117
126
  "Returns the cesium HTML design system reference page (CSS classes with example usage). Call this once at the start of writing a complex artifact to internalize the available components.",
118
127
  args: {},
119
128
  async execute(_args, _context) {
120
- return generateStyleguideMarkdown();
129
+ return await generateStyleguideMarkdown();
121
130
  },
122
131
  });
123
132
  }