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