@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.
- package/CHANGELOG.md +97 -3
- package/README.md +8 -8
- package/package.json +19 -17
- package/src/cli/commands/ls.ts +62 -65
- package/src/cli/commands/open.ts +47 -62
- package/src/cli/commands/prune.ts +59 -71
- package/src/cli/commands/restart.ts +100 -12
- package/src/cli/commands/serve.ts +119 -116
- package/src/cli/commands/stop.ts +51 -84
- package/src/cli/commands/theme.ts +54 -92
- package/src/cli/index.ts +17 -70
- package/src/index.ts +4 -1
- package/src/prompt/field-reference.ts +2 -2
- package/src/prompt/system-fragment.md +46 -16
- package/src/render/blocks/catalog.ts +2 -0
- package/src/render/blocks/diff/myers.ts +221 -0
- package/src/render/blocks/diff/parse-unified.ts +101 -0
- package/src/render/blocks/highlight.ts +8 -11
- package/src/render/blocks/markdown.ts +28 -7
- package/src/render/blocks/render.ts +3 -0
- package/src/render/blocks/renderers/code.ts +1 -3
- package/src/render/blocks/renderers/compare-table.ts +3 -4
- package/src/render/blocks/renderers/diagram.ts +2 -5
- package/src/render/blocks/renderers/diff.ts +378 -0
- package/src/render/blocks/renderers/prose.ts +1 -2
- package/src/render/blocks/renderers/timeline.ts +2 -1
- package/src/render/blocks/themes/claret-dark.ts +1 -6
- package/src/render/blocks/themes/claret-light.ts +1 -6
- package/src/render/blocks/types.ts +13 -1
- package/src/render/blocks/validate-block.ts +19 -9
- package/src/render/theme.ts +149 -0
- package/src/render/validate.ts +53 -9
- package/src/server/api.ts +112 -124
- package/src/server/favicon.ts +8 -16
- package/src/server/http.ts +101 -106
- package/src/server/lifecycle.ts +12 -6
- package/src/storage/assets.ts +8 -10
- package/src/storage/index-gen.ts +2 -3
- package/src/storage/theme-write.ts +17 -3
- package/src/tools/publish.ts +1 -3
- package/src/tools/styleguide.ts +3 -7
- package/src/tools/wait.ts +1 -0
package/src/render/theme.ts
CHANGED
|
@@ -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
|
|
package/src/render/validate.ts
CHANGED
|
@@ -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 =
|
|
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({
|
|
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({
|
|
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({
|
|
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 {
|
|
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({
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
const outcome = await submitAnswer({
|
|
94
|
+
artifactPath: resolved.artifactPath,
|
|
95
|
+
questionId,
|
|
96
|
+
value,
|
|
97
|
+
});
|
|
73
98
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
131
|
+
const resolved = resolveArtifact(stateDir, projectSlug, filename);
|
|
132
|
+
if (resolved instanceof Response) return resolved;
|
|
152
133
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
}
|
package/src/server/favicon.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return
|
|
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
|
}
|