@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 +41 -0
- package/package.json +3 -2
- package/src/cli/commands/serve.ts +15 -2
- package/src/render/blocks/highlight.ts +188 -0
- package/src/render/blocks/render.ts +13 -5
- package/src/render/blocks/renderers/code.ts +5 -3
- package/src/render/blocks/renderers/section.ts +4 -2
- package/src/render/blocks/themes/claret-dark.ts +206 -0
- package/src/render/blocks/themes/claret-light.ts +227 -0
- package/src/server/lifecycle.ts +184 -3
- package/src/tools/ask.ts +2 -2
- package/src/tools/publish.ts +5 -3
- package/src/tools/styleguide.ts +22 -13
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.
|
|
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 {
|
|
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
|
|
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(
|
|
82
|
-
|
|
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
|
-
|
|
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,
|
|
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)}">${
|
|
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
|
-
|
|
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
|
+
};
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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 ??
|
|
61
|
+
const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureServerRunning;
|
|
62
62
|
|
|
63
63
|
return tool({
|
|
64
64
|
description: TOOL_DESCRIPTION,
|
package/src/tools/publish.ts
CHANGED
|
@@ -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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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);
|
package/src/tools/styleguide.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|