@cfbender/cesium 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +97 -3
  2. package/README.md +8 -8
  3. package/package.json +19 -17
  4. package/src/cli/commands/ls.ts +62 -65
  5. package/src/cli/commands/open.ts +47 -62
  6. package/src/cli/commands/prune.ts +59 -71
  7. package/src/cli/commands/restart.ts +100 -12
  8. package/src/cli/commands/serve.ts +119 -116
  9. package/src/cli/commands/stop.ts +51 -84
  10. package/src/cli/commands/theme.ts +54 -92
  11. package/src/cli/index.ts +17 -70
  12. package/src/index.ts +4 -1
  13. package/src/prompt/field-reference.ts +2 -2
  14. package/src/prompt/system-fragment.md +46 -16
  15. package/src/render/blocks/catalog.ts +2 -0
  16. package/src/render/blocks/diff/myers.ts +221 -0
  17. package/src/render/blocks/diff/parse-unified.ts +101 -0
  18. package/src/render/blocks/highlight.ts +8 -11
  19. package/src/render/blocks/markdown.ts +28 -7
  20. package/src/render/blocks/render.ts +3 -0
  21. package/src/render/blocks/renderers/code.ts +1 -3
  22. package/src/render/blocks/renderers/compare-table.ts +3 -4
  23. package/src/render/blocks/renderers/diagram.ts +2 -5
  24. package/src/render/blocks/renderers/diff.ts +378 -0
  25. package/src/render/blocks/renderers/prose.ts +1 -2
  26. package/src/render/blocks/renderers/timeline.ts +2 -1
  27. package/src/render/blocks/themes/claret-dark.ts +1 -6
  28. package/src/render/blocks/themes/claret-light.ts +1 -6
  29. package/src/render/blocks/types.ts +13 -1
  30. package/src/render/blocks/validate-block.ts +19 -9
  31. package/src/render/theme.ts +149 -0
  32. package/src/render/validate.ts +53 -9
  33. package/src/server/api.ts +112 -124
  34. package/src/server/favicon.ts +8 -16
  35. package/src/server/http.ts +101 -106
  36. package/src/server/lifecycle.ts +12 -6
  37. package/src/storage/assets.ts +8 -10
  38. package/src/storage/index-gen.ts +2 -3
  39. package/src/storage/theme-write.ts +17 -3
  40. package/src/tools/publish.ts +1 -3
  41. package/src/tools/styleguide.ts +3 -7
  42. package/src/tools/wait.ts +1 -0
@@ -13,6 +13,9 @@ export interface ThemePalette {
13
13
  olive: string;
14
14
  codeBg: string;
15
15
  codeFg: string;
16
+ diffAdd: string; // line-tint and connector color for additions
17
+ diffRemove: string; // line-tint and connector color for deletions
18
+ diffChange: string; // connector color for replacements
16
19
  }
17
20
 
18
21
  export interface ThemeFonts {
@@ -56,6 +59,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
56
59
  olive: "#8FA86E",
57
60
  codeBg: "#2B1F22",
58
61
  codeFg: "#DDD3C7",
62
+ diffAdd: "#6D9E60",
63
+ diffRemove: "#C75B5B",
64
+ diffChange: "#D4A85A",
59
65
  },
60
66
  // claret-light: deep-rose-on-warm-cream — derived from claret.nvim light palette.
61
67
  // (this is the old "claret" palette, now renamed)
@@ -72,6 +78,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
72
78
  olive: "#5A6B40",
73
79
  codeBg: "#180810",
74
80
  codeFg: "#DDD3C7",
81
+ diffAdd: "#5A6B40",
82
+ diffRemove: "#9E3838",
83
+ diffChange: "#B07A2A",
75
84
  },
76
85
  // claret: alias for claret-dark (backward compat)
77
86
  claret: {
@@ -87,6 +96,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
87
96
  olive: "#8FA86E",
88
97
  codeBg: "#2B1F22",
89
98
  codeFg: "#DDD3C7",
99
+ diffAdd: "#6D9E60",
100
+ diffRemove: "#C75B5B",
101
+ diffChange: "#D4A85A",
90
102
  },
91
103
  // Warm: ivory/clay/oat — the html-effectiveness reference palette.
92
104
  warm: {
@@ -102,6 +114,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
102
114
  olive: "#788C5D",
103
115
  codeBg: "#141413",
104
116
  codeFg: "#E8E6DE",
117
+ diffAdd: "#788C5D",
118
+ diffRemove: "#C0392B",
119
+ diffChange: "#D97757",
105
120
  },
106
121
  // Cool: desaturated blue-grey — technical, trustworthy.
107
122
  cool: {
@@ -117,6 +132,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
117
132
  olive: "#4E8A6A",
118
133
  codeBg: "#1B2333",
119
134
  codeFg: "#D8E0ED",
135
+ diffAdd: "#4E8A6A",
136
+ diffRemove: "#B6443A",
137
+ diffChange: "#3A7BB8",
120
138
  },
121
139
  // Mono: black/white/grey — editorial, high-contrast.
122
140
  mono: {
@@ -132,6 +150,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
132
150
  olive: "#5A7A5A",
133
151
  codeBg: "#111111",
134
152
  codeFg: "#EBEBEB",
153
+ diffAdd: "#5A7A5A",
154
+ diffRemove: "#A03A2B",
155
+ diffChange: "#666666",
135
156
  },
136
157
  // Paper: sepia/cream — soft, book-like, warm and aged.
137
158
  paper: {
@@ -147,6 +168,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
147
168
  olive: "#607848",
148
169
  codeBg: "#2A2218",
149
170
  codeFg: "#E8DEC8",
171
+ diffAdd: "#607848",
172
+ diffRemove: "#A0392B",
173
+ diffChange: "#B05A2A",
150
174
  },
151
175
  };
152
176
 
@@ -198,6 +222,9 @@ export function themeToCssVars(theme: ThemeTokens): string {
198
222
  --olive: ${colors.olive};
199
223
  --code-bg: ${colors.codeBg};
200
224
  --code-fg: ${colors.codeFg};
225
+ --diff-add: ${colors.diffAdd};
226
+ --diff-remove: ${colors.diffRemove};
227
+ --diff-change: ${colors.diffChange};
201
228
  --serif: ${fonts.serif};
202
229
  --sans: ${fonts.sans};
203
230
  --mono: ${fonts.mono};
@@ -291,6 +318,13 @@ h1, h2, h3, h4, h5, h6 {
291
318
  border-radius: 12px;
292
319
  padding: 18px 22px;
293
320
  margin-bottom: 1.5em;
321
+ /* contain wide children (tables, long URLs, code) inside the card.
322
+ * min-width:0 lets the card shrink in grid/flex contexts (.cards-grid)
323
+ * so it actually obeys its track instead of growing to its widest child.
324
+ * overflow-x:auto then scrolls any content that's STILL too wide
325
+ * (e.g. a many-column table) rather than bursting the card border. */
326
+ min-width: 0;
327
+ overflow-x: auto;
294
328
  }
295
329
 
296
330
  /* tldr */
@@ -302,6 +336,8 @@ h1, h2, h3, h4, h5, h6 {
302
336
  margin-bottom: 1.5em;
303
337
  font-size: 1.05rem;
304
338
  color: var(--ink-soft);
339
+ min-width: 0;
340
+ overflow-x: auto;
305
341
  }
306
342
 
307
343
  /* callout */
@@ -313,6 +349,8 @@ h1, h2, h3, h4, h5, h6 {
313
349
  background: var(--surface-2);
314
350
  color: var(--ink-soft);
315
351
  font-size: 0.95rem;
352
+ min-width: 0;
353
+ overflow-x: auto;
316
354
  }
317
355
  .callout.note { border-color: var(--olive); background: color-mix(in srgb, var(--olive) 10%, var(--surface)); }
318
356
  .callout.warn { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
@@ -546,6 +584,11 @@ figure.code figcaption {
546
584
  padding: 10px 14px;
547
585
  text-align: left;
548
586
  vertical-align: top;
587
+ /* let long URLs / identifiers / paths wrap inside the cell instead of
588
+ * pushing the table beyond its container. Many-column tables that are
589
+ * still wider than the card fall through to the card's overflow-x. */
590
+ overflow-wrap: anywhere;
591
+ word-break: break-word;
549
592
  }
550
593
  .compare-table th {
551
594
  background: var(--surface-2);
@@ -567,6 +610,8 @@ figure.code figcaption {
567
610
  padding: 10px 14px;
568
611
  text-align: left;
569
612
  vertical-align: top;
613
+ overflow-wrap: anywhere;
614
+ word-break: break-word;
570
615
  }
571
616
  .risk-table th {
572
617
  background: var(--surface-2);
@@ -936,6 +981,110 @@ textarea.cs-text { font-family: var(--mono); }
936
981
  font-weight: 600;
937
982
  z-index: 10;
938
983
  }
984
+
985
+ /* diff block */
986
+ .diff-block {
987
+ margin: var(--space-6, 1.5rem) 0;
988
+ border: 1.5px solid var(--rule);
989
+ border-radius: 12px;
990
+ overflow: hidden;
991
+ background: var(--code-bg);
992
+ font-family: var(--mono);
993
+ font-size: 13px;
994
+ color: var(--code-fg);
995
+ }
996
+ .diff-block.fallback pre {
997
+ margin: 0;
998
+ padding: 12px 14px;
999
+ white-space: pre;
1000
+ overflow-x: auto;
1001
+ }
1002
+ .diff-header {
1003
+ display: flex;
1004
+ justify-content: space-between;
1005
+ align-items: center;
1006
+ padding: 8px 14px;
1007
+ border-bottom: 1px solid color-mix(in oklab, var(--rule), transparent 40%);
1008
+ background: color-mix(in oklab, var(--code-bg), var(--code-fg) 5%);
1009
+ font-size: 12px;
1010
+ color: color-mix(in oklab, var(--code-fg), transparent 30%);
1011
+ }
1012
+ .diff-filename { font-weight: 500; }
1013
+ .diff-stat { font-variant-numeric: tabular-nums; display: inline-flex; gap: 8px; }
1014
+ .diff-stat .add { color: var(--diff-add); }
1015
+ .diff-stat .rem { color: var(--diff-remove); }
1016
+
1017
+ .diff-grid {
1018
+ display: grid;
1019
+ grid-template-columns: 1fr 60px 1fr;
1020
+ align-items: start;
1021
+ }
1022
+ .diff-side {
1023
+ list-style: none;
1024
+ margin: 0;
1025
+ padding: 8px 0;
1026
+ overflow-x: auto;
1027
+ min-width: 0;
1028
+ }
1029
+ .diff-line {
1030
+ display: grid;
1031
+ grid-template-columns: 3.25em 1fr;
1032
+ gap: 0;
1033
+ height: 22px;
1034
+ line-height: 22px;
1035
+ white-space: pre;
1036
+ }
1037
+ .diff-line .num {
1038
+ text-align: right;
1039
+ padding-right: 12px;
1040
+ color: color-mix(in oklab, var(--code-fg), transparent 60%);
1041
+ user-select: none;
1042
+ font-variant-numeric: tabular-nums;
1043
+ }
1044
+ .diff-line .content {
1045
+ padding-right: 14px;
1046
+ overflow: hidden;
1047
+ text-overflow: ellipsis;
1048
+ }
1049
+ .diff-line.add { background: color-mix(in oklab, transparent, var(--diff-add) 14%); }
1050
+ .diff-line.remove { background: color-mix(in oklab, transparent, var(--diff-remove) 14%); }
1051
+ .diff-line.hunk-sep {
1052
+ background: color-mix(in oklab, var(--code-bg), var(--code-fg) 8%);
1053
+ color: color-mix(in oklab, var(--code-fg), transparent 50%);
1054
+ font-style: italic;
1055
+ font-size: 11px;
1056
+ }
1057
+
1058
+ .diff-connector {
1059
+ position: relative;
1060
+ align-self: stretch;
1061
+ padding: 8px 0;
1062
+ background: color-mix(in oklab, var(--code-bg), var(--code-fg) 2%);
1063
+ border-left: 1px solid color-mix(in oklab, var(--rule), transparent 60%);
1064
+ border-right: 1px solid color-mix(in oklab, var(--rule), transparent 60%);
1065
+ }
1066
+ .diff-connector svg {
1067
+ display: block;
1068
+ width: 100%;
1069
+ }
1070
+ .diff-conn { stroke-width: 1; }
1071
+ .diff-conn.add { fill: var(--diff-add); stroke: var(--diff-add); fill-opacity: 0.22; }
1072
+ .diff-conn.remove { fill: var(--diff-remove); stroke: var(--diff-remove); fill-opacity: 0.22; }
1073
+ .diff-conn.change { fill: var(--diff-change); stroke: var(--diff-change); fill-opacity: 0.18; }
1074
+
1075
+ .diff-block figcaption {
1076
+ padding: 8px 14px;
1077
+ border-top: 1px solid color-mix(in oklab, var(--rule), transparent 40%);
1078
+ font-size: 12px;
1079
+ font-family: var(--sans);
1080
+ color: color-mix(in oklab, var(--code-fg), transparent 30%);
1081
+ }
1082
+
1083
+ @media (max-width: 720px) {
1084
+ .diff-grid { grid-template-columns: 1fr; }
1085
+ .diff-connector { display: none; }
1086
+ .diff-side.before { border-bottom: 1px solid color-mix(in oklab, var(--rule), transparent 60%); }
1087
+ }
939
1088
  `;
940
1089
  }
941
1090
 
@@ -439,7 +439,9 @@ function isPublishKind(val: unknown): val is PublishKind {
439
439
  // ─── Block validation ─────────────────────────────────────────────────────────
440
440
 
441
441
  type BlockValidationError = { path: string; message: string };
442
- type BlockValidationResult = { ok: true; blocks: Block[] } | { ok: false; errors: BlockValidationError[] };
442
+ type BlockValidationResult =
443
+ | { ok: true; blocks: Block[] }
444
+ | { ok: false; errors: BlockValidationError[] };
443
445
 
444
446
  function validateBlock(raw: unknown, path: string, depth: number): BlockValidationError[] {
445
447
  const errors: BlockValidationError[] = [];
@@ -484,7 +486,10 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
484
486
  errors.push({ path, message: "section block requires children array" });
485
487
  } else {
486
488
  if (depth > 3) {
487
- errors.push({ path, message: `section nesting depth exceeds maximum of 3 (current depth: ${depth})` });
489
+ errors.push({
490
+ path,
491
+ message: `section nesting depth exceeds maximum of 3 (current depth: ${depth})`,
492
+ });
488
493
  } else {
489
494
  const children = b["children"] as unknown[];
490
495
  for (let i = 0; i < children.length; i++) {
@@ -518,7 +523,10 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
518
523
  }
519
524
  case "code": {
520
525
  if (typeof b["lang"] !== "string" || (b["lang"] as string).trim() === "") {
521
- errors.push({ path, message: 'code block requires a non-empty lang (use "text" if unknown)' });
526
+ errors.push({
527
+ path,
528
+ message: 'code block requires a non-empty lang (use "text" if unknown)',
529
+ });
522
530
  }
523
531
  if (typeof b["code"] !== "string") {
524
532
  errors.push({ path, message: "code block requires code field" });
@@ -580,7 +588,10 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
580
588
  const hasSvg = typeof b["svg"] === "string";
581
589
  const hasHtml = typeof b["html"] === "string";
582
590
  if (hasSvg && hasHtml) {
583
- errors.push({ path, message: "diagram block must have exactly one of svg or html, not both" });
591
+ errors.push({
592
+ path,
593
+ message: "diagram block must have exactly one of svg or html, not both",
594
+ });
584
595
  } else if (!hasSvg && !hasHtml) {
585
596
  errors.push({ path, message: "diagram block requires exactly one of svg or html" });
586
597
  }
@@ -592,6 +603,35 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
592
603
  }
593
604
  break;
594
605
  }
606
+ case "diff": {
607
+ const hasPatch = "patch" in b && b["patch"] !== undefined;
608
+ const hasBefore = "before" in b && b["before"] !== undefined;
609
+ const hasAfter = "after" in b && b["after"] !== undefined;
610
+
611
+ if (hasPatch && (hasBefore || hasAfter)) {
612
+ errors.push({
613
+ path,
614
+ message: "provide exactly one of patch or before/after, not both",
615
+ });
616
+ } else if (!hasPatch && !hasBefore && !hasAfter) {
617
+ errors.push({
618
+ path,
619
+ message: "diff block requires either patch or before+after",
620
+ });
621
+ } else if (hasPatch) {
622
+ if (typeof b["patch"] !== "string" || (b["patch"] as string).trim() === "") {
623
+ errors.push({ path, message: "diff block patch must be a non-empty string" });
624
+ }
625
+ } else {
626
+ // before/after arm
627
+ if (hasBefore && !hasAfter) {
628
+ errors.push({ path, message: "diff block before/after arm requires both fields" });
629
+ } else if (!hasBefore && hasAfter) {
630
+ errors.push({ path, message: "diff block before/after arm requires both fields" });
631
+ }
632
+ }
633
+ break;
634
+ }
595
635
  }
596
636
 
597
637
  // Deep field validation against catalog schema — catches unknown fields (with "did you mean"
@@ -611,7 +651,10 @@ function validateBlocksArray(raw: unknown): BlockValidationResult {
611
651
  }
612
652
 
613
653
  if (raw.length > 1000) {
614
- return { ok: false, errors: [{ path: "blocks", message: "blocks array exceeds maximum of 1000 blocks" }] };
654
+ return {
655
+ ok: false,
656
+ errors: [{ path: "blocks", message: "blocks array exceeds maximum of 1000 blocks" }],
657
+ };
615
658
  }
616
659
 
617
660
  const allErrors: BlockValidationError[] = [];
@@ -627,7 +670,10 @@ function validateBlocksArray(raw: unknown): BlockValidationResult {
627
670
  if (blockType === "hero") {
628
671
  heroCount++;
629
672
  if (i !== 0) {
630
- allErrors.push({ path: `blocks[${i}]`, message: "hero block must be the first block if present" });
673
+ allErrors.push({
674
+ path: `blocks[${i}]`,
675
+ message: "hero block must be the first block if present",
676
+ });
631
677
  }
632
678
  }
633
679
  if (blockType === "tldr") {
@@ -740,9 +786,7 @@ export function validatePublishInput(input: unknown): ValidationResult<PublishIn
740
786
  // blocks branch
741
787
  const blocksResult = validateBlocksArray(raw["blocks"]);
742
788
  if (!blocksResult.ok) {
743
- const errorMessages = blocksResult.errors
744
- .map((e) => `${e.path}: ${e.message}`)
745
- .join("; ");
789
+ const errorMessages = blocksResult.errors.map((e) => `${e.path}: ${e.message}`).join("; ");
746
790
  return { ok: false, error: `blocks validation failed — ${errorMessages}` };
747
791
  }
748
792
  return {
package/src/server/api.ts CHANGED
@@ -1,12 +1,15 @@
1
- // API route handler for interactive artifact submissions and state queries.
1
+ // API routes for interactive artifact submissions and state queries, exposed
2
+ // as a Hono sub-app. Mounted by lifecycle.ts via `handle.app.route("/", apiApp)`.
2
3
  //
3
4
  // Routes:
4
5
  // POST /api/sessions/:projectSlug/:filename/answers/:questionId
5
6
  // GET /api/sessions/:projectSlug/:filename/state
6
7
  //
7
- // Wire into startServer via handle.addHandler() before the static file fallback.
8
+ // Any other /api/* path returns a JSON 404 (rather than falling through to the
9
+ // static file handler, which would return the HTML 404 page).
8
10
 
9
11
  import { join, resolve, relative } from "node:path";
12
+ import { Hono } from "hono";
10
13
  import { submitAnswer, getState } from "../storage/mutate.ts";
11
14
  import type { AnswerValue } from "../render/validate.ts";
12
15
 
@@ -19,148 +22,133 @@ export interface ApiHandlerOptions {
19
22
  const FILENAME_RE = /^[^/\\]+\.html$/;
20
23
  const DANGEROUS_RE = /[/\\]|\.\./;
21
24
 
22
- function jsonResponse(body: unknown, status: number): Response {
23
- return new Response(JSON.stringify(body), {
24
- status,
25
- headers: {
26
- "Content-Type": "application/json; charset=utf-8",
27
- "Cache-Control": "no-store",
28
- },
29
- });
25
+ interface ResolvedArtifact {
26
+ /** Absolute path to the artifact file. */
27
+ artifactPath: string;
28
+ }
29
+
30
+ /**
31
+ * Validate the slug/filename pair and resolve the artifact's absolute path,
32
+ * enforcing containment under <stateDir>/projects/<slug>/artifacts/. Returns
33
+ * a Hono `Response` on validation failure, or the resolved path on success.
34
+ */
35
+ function resolveArtifact(
36
+ stateDir: string,
37
+ projectSlug: string,
38
+ filename: string,
39
+ ): ResolvedArtifact | Response {
40
+ if (DANGEROUS_RE.test(projectSlug) || DANGEROUS_RE.test(filename)) {
41
+ return Response.json({ ok: false, error: "invalid path component" }, { status: 400 });
42
+ }
43
+ if (!FILENAME_RE.test(filename)) {
44
+ return Response.json({ ok: false, error: "filename must end with .html" }, { status: 400 });
45
+ }
46
+
47
+ const artifactsDir = join(stateDir, "projects", projectSlug, "artifacts");
48
+ const artifactPath = join(artifactsDir, filename);
49
+ const resolvedArtifactsDir = resolve(artifactsDir);
50
+ const resolvedArtifact = resolve(artifactPath);
51
+ const rel = relative(resolvedArtifactsDir, resolvedArtifact);
52
+ if (rel.startsWith("..") || rel.includes("/")) {
53
+ return Response.json({ ok: false, error: "invalid path" }, { status: 400 });
54
+ }
55
+ return { artifactPath: resolvedArtifact };
30
56
  }
31
57
 
32
- export function createApiHandler(
33
- options: ApiHandlerOptions,
34
- ): (req: Request) => Promise<Response | undefined> {
58
+ export function createApiApp(options: ApiHandlerOptions): Hono {
35
59
  const { stateDir } = options;
60
+ const app = new Hono();
36
61
 
37
- return async (req: Request): Promise<Response | undefined> => {
38
- const url = new URL(req.url);
39
- const { pathname } = url;
62
+ // All API responses are dynamic never let intermediaries cache them.
63
+ app.use("/api/*", async (c, next) => {
64
+ await next();
65
+ c.header("Cache-Control", "no-store");
66
+ });
40
67
 
41
- // Only handle /api/ routes
42
- if (!pathname.startsWith("/api/")) {
43
- return undefined;
44
- }
68
+ // POST /api/sessions/:projectSlug/:filename/answers/:questionId
69
+ app.post("/api/sessions/:projectSlug/:filename/answers/:questionId", async (c) => {
70
+ const { projectSlug, filename, questionId } = c.req.param();
45
71
 
46
- // ─── Route matching ────────────────────────────────────────────────────
47
- // POST /api/sessions/:projectSlug/:filename/answers/:questionId
48
- const answerMatch = /^\/api\/sessions\/([^/]+)\/([^/]+)\/answers\/([^/]+)$/.exec(pathname);
49
- // GET /api/sessions/:projectSlug/:filename/state
50
- const stateMatch = /^\/api\/sessions\/([^/]+)\/([^/]+)\/state$/.exec(pathname);
72
+ const resolved = resolveArtifact(stateDir, projectSlug, filename);
73
+ if (resolved instanceof Response) return resolved;
51
74
 
52
- if (answerMatch === null && stateMatch === null) {
53
- // Unrecognized /api/ path
54
- return jsonResponse({ ok: false, error: "not found" }, 404);
75
+ let body: unknown;
76
+ try {
77
+ body = await c.req.json();
78
+ } catch {
79
+ return c.json({ ok: false, error: "invalid JSON body" }, 400);
55
80
  }
56
81
 
57
- const match = (answerMatch ?? stateMatch)!;
58
- const projectSlug = match[1]!;
59
- const filename = match[2]!;
60
-
61
- // ─── Input validation ──────────────────────────────────────────────────
62
- if (DANGEROUS_RE.test(projectSlug) || DANGEROUS_RE.test(filename)) {
63
- return jsonResponse({ ok: false, error: "invalid path component" }, 400);
82
+ if (
83
+ body === null ||
84
+ typeof body !== "object" ||
85
+ Array.isArray(body) ||
86
+ !("value" in (body as Record<string, unknown>))
87
+ ) {
88
+ return c.json({ ok: false, error: 'body must contain a "value" field' }, 400);
64
89
  }
65
90
 
66
- if (!FILENAME_RE.test(filename)) {
67
- return jsonResponse({ ok: false, error: "filename must end with .html" }, 400);
68
- }
91
+ const value = (body as Record<string, unknown>)["value"] as AnswerValue;
69
92
 
70
- // ─── Path traversal defense ────────────────────────────────────────────
71
- const artifactsDir = join(stateDir, "projects", projectSlug, "artifacts");
72
- const artifactPath = join(artifactsDir, filename);
93
+ const outcome = await submitAnswer({
94
+ artifactPath: resolved.artifactPath,
95
+ questionId,
96
+ value,
97
+ });
73
98
 
74
- const resolvedArtifactsDir = resolve(artifactsDir);
75
- const resolvedArtifact = resolve(artifactPath);
76
- const rel = relative(resolvedArtifactsDir, resolvedArtifact);
77
- if (rel.startsWith("..") || rel.includes("/")) {
78
- return jsonResponse({ ok: false, error: "invalid path" }, 400);
99
+ if (outcome.ok) {
100
+ return c.json(
101
+ {
102
+ ok: true,
103
+ status: outcome.status,
104
+ remaining: outcome.remaining,
105
+ replacementHtml: outcome.replacementHtml,
106
+ },
107
+ 200,
108
+ );
79
109
  }
80
110
 
81
- // ─── Route dispatch ────────────────────────────────────────────────────
82
-
83
- if (answerMatch !== null) {
84
- // POST /api/sessions/:projectSlug/:filename/answers/:questionId
85
- if (req.method !== "POST") {
86
- return jsonResponse({ ok: false, error: "method not allowed" }, 404);
87
- }
88
-
89
- const questionId = answerMatch[3]!;
90
-
91
- // Parse body
92
- let body: unknown;
93
- try {
94
- body = await req.json();
95
- } catch {
96
- return jsonResponse({ ok: false, error: "invalid JSON body" }, 400);
97
- }
98
-
99
- if (
100
- body === null ||
101
- typeof body !== "object" ||
102
- Array.isArray(body) ||
103
- !("value" in (body as Record<string, unknown>))
104
- ) {
105
- return jsonResponse({ ok: false, error: 'body must contain a "value" field' }, 400);
106
- }
107
-
108
- const value = (body as Record<string, unknown>)["value"] as AnswerValue;
109
-
110
- const outcome = await submitAnswer({ artifactPath: resolvedArtifact, questionId, value });
111
-
112
- if (outcome.ok) {
113
- return jsonResponse(
114
- {
115
- ok: true,
116
- status: outcome.status,
117
- remaining: outcome.remaining,
118
- replacementHtml: outcome.replacementHtml,
119
- },
120
- 200,
121
- );
122
- }
123
-
124
- switch (outcome.reason) {
125
- case "not-found":
126
- case "not-interactive":
127
- case "unknown-question":
128
- return jsonResponse({ ok: false, reason: outcome.reason }, 404);
129
- case "session-ended":
130
- return jsonResponse({ ok: false, status: outcome.status }, 410);
131
- case "expired":
132
- return jsonResponse({ ok: false, status: "expired" }, 410);
133
- case "invalid-value":
134
- return jsonResponse({ ok: false, message: outcome.message }, 422);
135
- }
136
-
137
- // Fallback (should not reach)
138
- return jsonResponse({ ok: false, error: "internal error" }, 500);
111
+ switch (outcome.reason) {
112
+ case "not-found":
113
+ case "not-interactive":
114
+ case "unknown-question":
115
+ return c.json({ ok: false, reason: outcome.reason }, 404);
116
+ case "session-ended":
117
+ return c.json({ ok: false, status: outcome.status }, 410);
118
+ case "expired":
119
+ return c.json({ ok: false, status: "expired" }, 410);
120
+ case "invalid-value":
121
+ return c.json({ ok: false, message: outcome.message }, 422);
139
122
  }
140
123
 
141
- if (stateMatch !== null) {
142
- // GET /api/sessions/:projectSlug/:filename/state
143
- if (req.method !== "GET") {
144
- return jsonResponse({ ok: false, error: "method not allowed" }, 404);
145
- }
124
+ return c.json({ ok: false, error: "internal error" }, 500);
125
+ });
146
126
 
147
- const outcome = await getState(resolvedArtifact);
127
+ // GET /api/sessions/:projectSlug/:filename/state
128
+ app.get("/api/sessions/:projectSlug/:filename/state", async (c) => {
129
+ const { projectSlug, filename } = c.req.param();
148
130
 
149
- if (!outcome.ok) {
150
- return jsonResponse({ ok: false, reason: outcome.reason }, 404);
151
- }
131
+ const resolved = resolveArtifact(stateDir, projectSlug, filename);
132
+ if (resolved instanceof Response) return resolved;
152
133
 
153
- return jsonResponse(
154
- {
155
- status: outcome.status,
156
- answers: outcome.answers,
157
- remaining: outcome.remaining,
158
- },
159
- 200,
160
- );
134
+ const outcome = await getState(resolved.artifactPath);
135
+ if (!outcome.ok) {
136
+ return c.json({ ok: false, reason: outcome.reason }, 404);
161
137
  }
162
138
 
163
- // Should not reach here
164
- return jsonResponse({ ok: false, error: "not found" }, 404);
165
- };
139
+ return c.json(
140
+ {
141
+ status: outcome.status,
142
+ answers: outcome.answers,
143
+ remaining: outcome.remaining,
144
+ },
145
+ 200,
146
+ );
147
+ });
148
+
149
+ // Catch-all under /api/* — keeps unmatched API paths as JSON 404 instead of
150
+ // falling through to the static file handler.
151
+ app.all("/api/*", (c) => c.json({ ok: false, error: "not found" }, 404));
152
+
153
+ return app;
166
154
  }
@@ -7,22 +7,14 @@
7
7
  // (written by writeFaviconSvg on every publish). This shim covers the .ico
8
8
  // fallback so users don't see a 404 in DevTools.
9
9
 
10
+ import { Hono } from "hono";
10
11
  import { FAVICON_SVG } from "../render/favicon.ts";
11
12
 
12
- const SVG_RESPONSE_HEADERS: Record<string, string> = {
13
- "Content-Type": "image/svg+xml; charset=utf-8",
14
- "Cache-Control": "public, max-age=86400",
15
- };
16
-
17
- export function createFaviconHandler(): (req: Request) => Promise<Response | undefined> {
18
- return async (req: Request): Promise<Response | undefined> => {
19
- const url = new URL(req.url);
20
- if (url.pathname !== "/favicon.ico") {
21
- return undefined;
22
- }
23
- if (req.method !== "GET" && req.method !== "HEAD") {
24
- return undefined;
25
- }
26
- return new Response(FAVICON_SVG, { status: 200, headers: SVG_RESPONSE_HEADERS });
27
- };
13
+ export function createFaviconApp(): Hono {
14
+ const app = new Hono();
15
+ app.on(["GET", "HEAD"], "/favicon.ico", (c) => {
16
+ c.header("Cache-Control", "public, max-age=86400");
17
+ return c.body(FAVICON_SVG, 200, { "Content-Type": "image/svg+xml; charset=utf-8" });
18
+ });
19
+ return app;
28
20
  }