@cfbender/cesium 0.3.5
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/ARCHITECTURE.md +304 -0
- package/CHANGELOG.md +335 -0
- package/LICENSE +21 -0
- package/README.md +479 -0
- package/agents/cesium.md +39 -0
- package/assets/styleguide.html +857 -0
- package/package.json +61 -0
- package/src/cli/commands/ls.ts +186 -0
- package/src/cli/commands/open.ts +208 -0
- package/src/cli/commands/prune.ts +348 -0
- package/src/cli/commands/restart.ts +38 -0
- package/src/cli/commands/serve.ts +214 -0
- package/src/cli/commands/stop.ts +130 -0
- package/src/cli/commands/theme.ts +333 -0
- package/src/cli/index.ts +78 -0
- package/src/config.ts +94 -0
- package/src/index.ts +35 -0
- package/src/prompt/system-fragment.md +97 -0
- package/src/render/client-js.ts +316 -0
- package/src/render/controls.ts +302 -0
- package/src/render/critique.ts +360 -0
- package/src/render/extract.ts +83 -0
- package/src/render/scrub.ts +141 -0
- package/src/render/theme.ts +712 -0
- package/src/render/validate.ts +524 -0
- package/src/render/wrap.ts +165 -0
- package/src/server/api.ts +166 -0
- package/src/server/http.ts +195 -0
- package/src/server/lifecycle.ts +331 -0
- package/src/server/stop.ts +124 -0
- package/src/storage/index-cache.ts +71 -0
- package/src/storage/index-gen.ts +447 -0
- package/src/storage/lock.ts +108 -0
- package/src/storage/mutate.ts +396 -0
- package/src/storage/paths.ts +159 -0
- package/src/storage/project-summaries.ts +19 -0
- package/src/storage/theme-write.ts +19 -0
- package/src/storage/write.ts +75 -0
- package/src/tools/ask.ts +353 -0
- package/src/tools/critique.ts +66 -0
- package/src/tools/publish.ts +404 -0
- package/src/tools/stop.ts +53 -0
- package/src/tools/styleguide.ts +23 -0
- package/src/tools/wait.ts +192 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// Validates cesium_publish and cesium_ask tool input before any write occurs.
|
|
2
|
+
|
|
3
|
+
import { parseFragment, defaultTreeAdapter as ta } from "parse5";
|
|
4
|
+
import type { DefaultTreeAdapterTypes } from "parse5";
|
|
5
|
+
|
|
6
|
+
type ChildNode = DefaultTreeAdapterTypes.ChildNode;
|
|
7
|
+
type Element = DefaultTreeAdapterTypes.Element;
|
|
8
|
+
|
|
9
|
+
export interface ValidationOk<T> {
|
|
10
|
+
ok: true;
|
|
11
|
+
value: T;
|
|
12
|
+
}
|
|
13
|
+
export interface ValidationErr {
|
|
14
|
+
ok: false;
|
|
15
|
+
error: string;
|
|
16
|
+
}
|
|
17
|
+
export type ValidationResult<T> = ValidationOk<T> | ValidationErr;
|
|
18
|
+
|
|
19
|
+
export type PublishKind =
|
|
20
|
+
| "plan"
|
|
21
|
+
| "review"
|
|
22
|
+
| "comparison"
|
|
23
|
+
| "report"
|
|
24
|
+
| "explainer"
|
|
25
|
+
| "design"
|
|
26
|
+
| "audit"
|
|
27
|
+
| "rfc"
|
|
28
|
+
| "other"
|
|
29
|
+
| "ask";
|
|
30
|
+
|
|
31
|
+
export const PUBLISH_KINDS: readonly PublishKind[] = [
|
|
32
|
+
"plan",
|
|
33
|
+
"review",
|
|
34
|
+
"comparison",
|
|
35
|
+
"report",
|
|
36
|
+
"explainer",
|
|
37
|
+
"design",
|
|
38
|
+
"audit",
|
|
39
|
+
"rfc",
|
|
40
|
+
"other",
|
|
41
|
+
"ask",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// ─── Interactive artifact types ────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export type Option = {
|
|
47
|
+
id: string;
|
|
48
|
+
label: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type Question =
|
|
53
|
+
| {
|
|
54
|
+
type: "pick_one";
|
|
55
|
+
id: string;
|
|
56
|
+
question: string;
|
|
57
|
+
options: Option[];
|
|
58
|
+
recommended?: string;
|
|
59
|
+
context?: string;
|
|
60
|
+
}
|
|
61
|
+
| {
|
|
62
|
+
type: "pick_many";
|
|
63
|
+
id: string;
|
|
64
|
+
question: string;
|
|
65
|
+
options: Option[];
|
|
66
|
+
min?: number;
|
|
67
|
+
max?: number;
|
|
68
|
+
context?: string;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
type: "confirm";
|
|
72
|
+
id: string;
|
|
73
|
+
question: string;
|
|
74
|
+
yesLabel?: string;
|
|
75
|
+
noLabel?: string;
|
|
76
|
+
context?: string;
|
|
77
|
+
}
|
|
78
|
+
| {
|
|
79
|
+
type: "ask_text";
|
|
80
|
+
id: string;
|
|
81
|
+
question: string;
|
|
82
|
+
multiline?: boolean;
|
|
83
|
+
placeholder?: string;
|
|
84
|
+
optional?: boolean;
|
|
85
|
+
context?: string;
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
type: "slider";
|
|
89
|
+
id: string;
|
|
90
|
+
question: string;
|
|
91
|
+
min: number;
|
|
92
|
+
max: number;
|
|
93
|
+
step?: number;
|
|
94
|
+
defaultValue?: number;
|
|
95
|
+
context?: string;
|
|
96
|
+
}
|
|
97
|
+
| {
|
|
98
|
+
type: "react";
|
|
99
|
+
id: string;
|
|
100
|
+
question: string;
|
|
101
|
+
mode?: "approve" | "thumbs";
|
|
102
|
+
allowComment?: boolean;
|
|
103
|
+
context?: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type AnswerValue =
|
|
107
|
+
| { type: "pick_one"; selected: string }
|
|
108
|
+
| { type: "pick_many"; selected: string[] }
|
|
109
|
+
| { type: "confirm"; choice: "yes" | "no" }
|
|
110
|
+
| { type: "ask_text"; text: string }
|
|
111
|
+
| { type: "slider"; value: number }
|
|
112
|
+
| { type: "react"; decision: string; comment?: string };
|
|
113
|
+
|
|
114
|
+
export type InteractiveData = {
|
|
115
|
+
status: "open" | "complete" | "expired" | "cancelled";
|
|
116
|
+
requireAll: boolean;
|
|
117
|
+
expiresAt: string;
|
|
118
|
+
questions: Question[];
|
|
119
|
+
answers: Record<string, { value: AnswerValue; answeredAt: string }>;
|
|
120
|
+
completedAt?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ─── Question validation ───────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function isNonEmptyString(v: unknown): v is string {
|
|
126
|
+
return typeof v === "string" && v.trim() !== "";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isOption(v: unknown): v is Option {
|
|
130
|
+
if (v === null || typeof v !== "object") return false;
|
|
131
|
+
const o = v as Record<string, unknown>;
|
|
132
|
+
return isNonEmptyString(o["id"]) && isNonEmptyString(o["label"]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
type QuestionValidationResult = { ok: true; question: Question } | { ok: false; error: string };
|
|
136
|
+
|
|
137
|
+
export function validateQuestion(q: unknown): QuestionValidationResult {
|
|
138
|
+
if (q === null || typeof q !== "object") {
|
|
139
|
+
return { ok: false, error: "question must be an object" };
|
|
140
|
+
}
|
|
141
|
+
const raw = q as Record<string, unknown>;
|
|
142
|
+
|
|
143
|
+
if (!isNonEmptyString(raw["id"])) {
|
|
144
|
+
return { ok: false, error: "question.id must be a non-empty string" };
|
|
145
|
+
}
|
|
146
|
+
const id = raw["id"];
|
|
147
|
+
|
|
148
|
+
if (!isNonEmptyString(raw["question"])) {
|
|
149
|
+
return { ok: false, error: "question.question must be a non-empty string" };
|
|
150
|
+
}
|
|
151
|
+
const questionText = raw["question"];
|
|
152
|
+
|
|
153
|
+
const context = typeof raw["context"] === "string" ? raw["context"] : undefined;
|
|
154
|
+
|
|
155
|
+
switch (raw["type"]) {
|
|
156
|
+
case "pick_one": {
|
|
157
|
+
if (!Array.isArray(raw["options"]) || raw["options"].length === 0) {
|
|
158
|
+
return { ok: false, error: `pick_one question "${id}" must have at least one option` };
|
|
159
|
+
}
|
|
160
|
+
for (const opt of raw["options"] as unknown[]) {
|
|
161
|
+
if (!isOption(opt)) {
|
|
162
|
+
return { ok: false, error: `pick_one question "${id}" has invalid option` };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const result: Question = {
|
|
166
|
+
type: "pick_one",
|
|
167
|
+
id,
|
|
168
|
+
question: questionText,
|
|
169
|
+
options: raw["options"] as Option[],
|
|
170
|
+
};
|
|
171
|
+
if (typeof raw["recommended"] === "string") result.recommended = raw["recommended"];
|
|
172
|
+
if (context !== undefined) result.context = context;
|
|
173
|
+
return { ok: true, question: result };
|
|
174
|
+
}
|
|
175
|
+
case "pick_many": {
|
|
176
|
+
if (!Array.isArray(raw["options"]) || raw["options"].length === 0) {
|
|
177
|
+
return { ok: false, error: `pick_many question "${id}" must have at least one option` };
|
|
178
|
+
}
|
|
179
|
+
for (const opt of raw["options"] as unknown[]) {
|
|
180
|
+
if (!isOption(opt)) {
|
|
181
|
+
return { ok: false, error: `pick_many question "${id}" has invalid option` };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const min = typeof raw["min"] === "number" ? raw["min"] : undefined;
|
|
185
|
+
const max = typeof raw["max"] === "number" ? raw["max"] : undefined;
|
|
186
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
187
|
+
return { ok: false, error: `pick_many question "${id}" has min > max` };
|
|
188
|
+
}
|
|
189
|
+
const result: Question = {
|
|
190
|
+
type: "pick_many",
|
|
191
|
+
id,
|
|
192
|
+
question: questionText,
|
|
193
|
+
options: raw["options"] as Option[],
|
|
194
|
+
};
|
|
195
|
+
if (min !== undefined) result.min = min;
|
|
196
|
+
if (max !== undefined) result.max = max;
|
|
197
|
+
if (context !== undefined) result.context = context;
|
|
198
|
+
return { ok: true, question: result };
|
|
199
|
+
}
|
|
200
|
+
case "confirm": {
|
|
201
|
+
const result: Question = { type: "confirm", id, question: questionText };
|
|
202
|
+
if (typeof raw["yesLabel"] === "string") result.yesLabel = raw["yesLabel"];
|
|
203
|
+
if (typeof raw["noLabel"] === "string") result.noLabel = raw["noLabel"];
|
|
204
|
+
if (context !== undefined) result.context = context;
|
|
205
|
+
return { ok: true, question: result };
|
|
206
|
+
}
|
|
207
|
+
case "ask_text": {
|
|
208
|
+
const result: Question = { type: "ask_text", id, question: questionText };
|
|
209
|
+
if (typeof raw["multiline"] === "boolean") result.multiline = raw["multiline"];
|
|
210
|
+
if (typeof raw["placeholder"] === "string") result.placeholder = raw["placeholder"];
|
|
211
|
+
if ("optional" in raw && raw["optional"] !== undefined) {
|
|
212
|
+
if (typeof raw["optional"] !== "boolean") {
|
|
213
|
+
return { ok: false, error: `ask_text question "${id}" optional must be a boolean` };
|
|
214
|
+
}
|
|
215
|
+
result.optional = raw["optional"];
|
|
216
|
+
}
|
|
217
|
+
if (context !== undefined) result.context = context;
|
|
218
|
+
return { ok: true, question: result };
|
|
219
|
+
}
|
|
220
|
+
case "slider": {
|
|
221
|
+
if (typeof raw["min"] !== "number" || typeof raw["max"] !== "number") {
|
|
222
|
+
return { ok: false, error: `slider question "${id}" must have numeric min and max` };
|
|
223
|
+
}
|
|
224
|
+
if (raw["min"] >= raw["max"]) {
|
|
225
|
+
return { ok: false, error: `slider question "${id}" must have min < max` };
|
|
226
|
+
}
|
|
227
|
+
const result: Question = {
|
|
228
|
+
type: "slider",
|
|
229
|
+
id,
|
|
230
|
+
question: questionText,
|
|
231
|
+
min: raw["min"],
|
|
232
|
+
max: raw["max"],
|
|
233
|
+
};
|
|
234
|
+
if (typeof raw["step"] === "number") result.step = raw["step"];
|
|
235
|
+
if (typeof raw["defaultValue"] === "number") result.defaultValue = raw["defaultValue"];
|
|
236
|
+
if (context !== undefined) result.context = context;
|
|
237
|
+
return { ok: true, question: result };
|
|
238
|
+
}
|
|
239
|
+
case "react": {
|
|
240
|
+
const result: Question = { type: "react", id, question: questionText };
|
|
241
|
+
if (raw["mode"] === "approve" || raw["mode"] === "thumbs") result.mode = raw["mode"];
|
|
242
|
+
if (typeof raw["allowComment"] === "boolean") result.allowComment = raw["allowComment"];
|
|
243
|
+
if (context !== undefined) result.context = context;
|
|
244
|
+
return { ok: true, question: result };
|
|
245
|
+
}
|
|
246
|
+
default:
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
error: `question has invalid type: ${String(raw["type"])}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Answer value validation ───────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
type AnswerValueResult = { ok: true; value: AnswerValue } | { ok: false; error: string };
|
|
257
|
+
|
|
258
|
+
export function validateAnswerValue(qType: Question["type"], value: unknown): AnswerValueResult {
|
|
259
|
+
if (value === null || typeof value !== "object") {
|
|
260
|
+
return { ok: false, error: "answer value must be an object" };
|
|
261
|
+
}
|
|
262
|
+
const raw = value as Record<string, unknown>;
|
|
263
|
+
|
|
264
|
+
if (raw["type"] !== qType) {
|
|
265
|
+
return {
|
|
266
|
+
ok: false,
|
|
267
|
+
error: `answer type mismatch: expected "${qType}", got "${String(raw["type"])}"`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
switch (qType) {
|
|
272
|
+
case "pick_one": {
|
|
273
|
+
if (typeof raw["selected"] !== "string") {
|
|
274
|
+
return { ok: false, error: "pick_one answer must have a string selected field" };
|
|
275
|
+
}
|
|
276
|
+
return { ok: true, value: { type: "pick_one", selected: raw["selected"] } };
|
|
277
|
+
}
|
|
278
|
+
case "pick_many": {
|
|
279
|
+
if (!Array.isArray(raw["selected"]) || !raw["selected"].every((s) => typeof s === "string")) {
|
|
280
|
+
return { ok: false, error: "pick_many answer must have a string[] selected field" };
|
|
281
|
+
}
|
|
282
|
+
return { ok: true, value: { type: "pick_many", selected: raw["selected"] as string[] } };
|
|
283
|
+
}
|
|
284
|
+
case "confirm": {
|
|
285
|
+
if (raw["choice"] !== "yes" && raw["choice"] !== "no") {
|
|
286
|
+
return { ok: false, error: 'confirm answer must have choice "yes" or "no"' };
|
|
287
|
+
}
|
|
288
|
+
return { ok: true, value: { type: "confirm", choice: raw["choice"] } };
|
|
289
|
+
}
|
|
290
|
+
case "ask_text": {
|
|
291
|
+
if (typeof raw["text"] !== "string") {
|
|
292
|
+
return { ok: false, error: "ask_text answer must have a string text field" };
|
|
293
|
+
}
|
|
294
|
+
return { ok: true, value: { type: "ask_text", text: raw["text"] } };
|
|
295
|
+
}
|
|
296
|
+
case "slider": {
|
|
297
|
+
if (typeof raw["value"] !== "number") {
|
|
298
|
+
return { ok: false, error: "slider answer must have a numeric value field" };
|
|
299
|
+
}
|
|
300
|
+
return { ok: true, value: { type: "slider", value: raw["value"] } };
|
|
301
|
+
}
|
|
302
|
+
case "react": {
|
|
303
|
+
if (typeof raw["decision"] !== "string") {
|
|
304
|
+
return { ok: false, error: "react answer must have a string decision field" };
|
|
305
|
+
}
|
|
306
|
+
const comment = typeof raw["comment"] === "string" ? raw["comment"] : undefined;
|
|
307
|
+
const result: AnswerValue = { type: "react", decision: raw["decision"] };
|
|
308
|
+
if (comment !== undefined) result.comment = comment;
|
|
309
|
+
return { ok: true, value: result };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─── AskInput validation ───────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
export interface AskInput {
|
|
317
|
+
title: string;
|
|
318
|
+
body: string;
|
|
319
|
+
questions: Question[];
|
|
320
|
+
summary?: string;
|
|
321
|
+
tags?: string[];
|
|
322
|
+
expiresAt?: string;
|
|
323
|
+
requireAll?: boolean;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
type AskValidationResult = { ok: true; value: AskInput } | { ok: false; error: string };
|
|
327
|
+
|
|
328
|
+
export function validateAskInput(input: unknown): AskValidationResult {
|
|
329
|
+
if (input === null || typeof input !== "object") {
|
|
330
|
+
return { ok: false, error: "input must be an object" };
|
|
331
|
+
}
|
|
332
|
+
const raw = input as Record<string, unknown>;
|
|
333
|
+
|
|
334
|
+
// title
|
|
335
|
+
if (!("title" in raw) || typeof raw["title"] !== "string" || raw["title"].trim() === "") {
|
|
336
|
+
return { ok: false, error: "title is required and must be a non-empty string" };
|
|
337
|
+
}
|
|
338
|
+
const title = raw["title"];
|
|
339
|
+
|
|
340
|
+
// body
|
|
341
|
+
if (!("body" in raw) || typeof raw["body"] !== "string") {
|
|
342
|
+
return { ok: false, error: "body is required and must be a string" };
|
|
343
|
+
}
|
|
344
|
+
const body = raw["body"];
|
|
345
|
+
|
|
346
|
+
// questions
|
|
347
|
+
if (!("questions" in raw) || !Array.isArray(raw["questions"]) || raw["questions"].length === 0) {
|
|
348
|
+
return { ok: false, error: "questions must be a non-empty array" };
|
|
349
|
+
}
|
|
350
|
+
const questions: Question[] = [];
|
|
351
|
+
const seenIds = new Set<string>();
|
|
352
|
+
for (const q of raw["questions"] as unknown[]) {
|
|
353
|
+
const result = validateQuestion(q);
|
|
354
|
+
if (!result.ok) {
|
|
355
|
+
return { ok: false, error: result.error };
|
|
356
|
+
}
|
|
357
|
+
if (seenIds.has(result.question.id)) {
|
|
358
|
+
return { ok: false, error: `duplicate question id: "${result.question.id}"` };
|
|
359
|
+
}
|
|
360
|
+
seenIds.add(result.question.id);
|
|
361
|
+
questions.push(result.question);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// summary (optional)
|
|
365
|
+
if ("summary" in raw && raw["summary"] !== undefined) {
|
|
366
|
+
if (typeof raw["summary"] !== "string") {
|
|
367
|
+
return { ok: false, error: "summary must be a string" };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// tags (optional)
|
|
372
|
+
if ("tags" in raw && raw["tags"] !== undefined) {
|
|
373
|
+
if (!Array.isArray(raw["tags"])) {
|
|
374
|
+
return { ok: false, error: "tags must be an array of strings" };
|
|
375
|
+
}
|
|
376
|
+
for (const tag of raw["tags"]) {
|
|
377
|
+
if (typeof tag !== "string") {
|
|
378
|
+
return { ok: false, error: "tags must be an array of strings" };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// expiresAt (optional, must be ISO string)
|
|
384
|
+
if ("expiresAt" in raw && raw["expiresAt"] !== undefined) {
|
|
385
|
+
if (typeof raw["expiresAt"] !== "string") {
|
|
386
|
+
return { ok: false, error: "expiresAt must be a string" };
|
|
387
|
+
}
|
|
388
|
+
const d = new Date(raw["expiresAt"]);
|
|
389
|
+
if (isNaN(d.getTime())) {
|
|
390
|
+
return { ok: false, error: "expiresAt must be a valid ISO date string" };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// requireAll (optional)
|
|
395
|
+
if ("requireAll" in raw && raw["requireAll"] !== undefined) {
|
|
396
|
+
if (typeof raw["requireAll"] !== "boolean") {
|
|
397
|
+
return { ok: false, error: "requireAll must be a boolean" };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const result: AskInput = { title, body, questions };
|
|
402
|
+
if (typeof raw["summary"] === "string") result.summary = raw["summary"];
|
|
403
|
+
if (Array.isArray(raw["tags"])) result.tags = raw["tags"] as string[];
|
|
404
|
+
if (typeof raw["expiresAt"] === "string") result.expiresAt = raw["expiresAt"];
|
|
405
|
+
if (typeof raw["requireAll"] === "boolean") result.requireAll = raw["requireAll"];
|
|
406
|
+
|
|
407
|
+
return { ok: true, value: result };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export interface PublishInput {
|
|
411
|
+
title: string;
|
|
412
|
+
kind: PublishKind;
|
|
413
|
+
html: string;
|
|
414
|
+
summary?: string;
|
|
415
|
+
tags?: string[];
|
|
416
|
+
supersedes?: string;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function isPublishKind(val: unknown): val is PublishKind {
|
|
420
|
+
return typeof val === "string" && (PUBLISH_KINDS as readonly string[]).includes(val);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function validatePublishInput(input: unknown): ValidationResult<PublishInput> {
|
|
424
|
+
if (input === null || typeof input !== "object") {
|
|
425
|
+
return { ok: false, error: "input must be an object" };
|
|
426
|
+
}
|
|
427
|
+
const raw = input as Record<string, unknown>;
|
|
428
|
+
|
|
429
|
+
// title
|
|
430
|
+
if (!("title" in raw) || typeof raw["title"] !== "string" || raw["title"].trim() === "") {
|
|
431
|
+
return { ok: false, error: "title is required and must be a non-empty string" };
|
|
432
|
+
}
|
|
433
|
+
if (raw["title"].length > 200) {
|
|
434
|
+
return { ok: false, error: "title must be 200 characters or fewer" };
|
|
435
|
+
}
|
|
436
|
+
const title = raw["title"];
|
|
437
|
+
|
|
438
|
+
// kind
|
|
439
|
+
if (!("kind" in raw) || !isPublishKind(raw["kind"])) {
|
|
440
|
+
return {
|
|
441
|
+
ok: false,
|
|
442
|
+
error: `kind must be one of: ${PUBLISH_KINDS.join(", ")}`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const kind = raw["kind"];
|
|
446
|
+
|
|
447
|
+
// html
|
|
448
|
+
if (!("html" in raw) || typeof raw["html"] !== "string" || raw["html"].trim() === "") {
|
|
449
|
+
return { ok: false, error: "html is required and must be a non-empty string" };
|
|
450
|
+
}
|
|
451
|
+
const html = raw["html"];
|
|
452
|
+
|
|
453
|
+
// summary (optional)
|
|
454
|
+
if ("summary" in raw && raw["summary"] !== undefined) {
|
|
455
|
+
if (typeof raw["summary"] !== "string") {
|
|
456
|
+
return { ok: false, error: "summary must be a string" };
|
|
457
|
+
}
|
|
458
|
+
if (raw["summary"].length > 500) {
|
|
459
|
+
return { ok: false, error: "summary must be 500 characters or fewer" };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// tags (optional)
|
|
464
|
+
if ("tags" in raw && raw["tags"] !== undefined) {
|
|
465
|
+
if (!Array.isArray(raw["tags"])) {
|
|
466
|
+
return { ok: false, error: "tags must be an array of strings" };
|
|
467
|
+
}
|
|
468
|
+
for (const tag of raw["tags"]) {
|
|
469
|
+
if (typeof tag !== "string") {
|
|
470
|
+
return { ok: false, error: "tags must be an array of strings" };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// supersedes (optional)
|
|
476
|
+
if ("supersedes" in raw && raw["supersedes"] !== undefined) {
|
|
477
|
+
if (typeof raw["supersedes"] !== "string") {
|
|
478
|
+
return { ok: false, error: "supersedes must be a string" };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const result: PublishInput = { title, kind, html };
|
|
483
|
+
if (typeof raw["summary"] === "string") result.summary = raw["summary"];
|
|
484
|
+
if (Array.isArray(raw["tags"])) result.tags = raw["tags"] as string[];
|
|
485
|
+
if (typeof raw["supersedes"] === "string") result.supersedes = raw["supersedes"];
|
|
486
|
+
|
|
487
|
+
return { ok: true, value: result };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
491
|
+
|
|
492
|
+
function walkNodes(nodes: ChildNode[], visitor: (node: ChildNode) => void): void {
|
|
493
|
+
for (const node of nodes) {
|
|
494
|
+
visitor(node);
|
|
495
|
+
if (ta.isElementNode(node)) {
|
|
496
|
+
walkNodes(ta.getChildNodes(node as Element) as ChildNode[], visitor);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function htmlBodyWarnings(htmlBody: string): string[] {
|
|
502
|
+
try {
|
|
503
|
+
const warnings: string[] = [];
|
|
504
|
+
const fragment = parseFragment(htmlBody);
|
|
505
|
+
const children = ta.getChildNodes(fragment) as ChildNode[];
|
|
506
|
+
let hasHeading = false;
|
|
507
|
+
|
|
508
|
+
walkNodes(children, (node) => {
|
|
509
|
+
if (ta.isElementNode(node)) {
|
|
510
|
+
const el = node as Element;
|
|
511
|
+
const tag = ta.getTagName(el);
|
|
512
|
+
if (HEADING_TAGS.has(tag)) hasHeading = true;
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
if (!hasHeading) {
|
|
517
|
+
warnings.push("no headings found — consider adding an <h1> or <h2>");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return warnings;
|
|
521
|
+
} catch {
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Assembles the full <!doctype html> document from a body fragment + metadata.
|
|
2
|
+
|
|
3
|
+
import { frameworkRulesCss, themeTokensCss, type ThemeTokens } from "./theme.ts";
|
|
4
|
+
import { renderControl, renderAnswered } from "./controls.ts";
|
|
5
|
+
import { getClientJs } from "./client-js.ts";
|
|
6
|
+
import type { InteractiveData, Question } from "./validate.ts";
|
|
7
|
+
|
|
8
|
+
export interface ArtifactMeta {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
kind: string;
|
|
12
|
+
summary: string | null;
|
|
13
|
+
tags: string[];
|
|
14
|
+
createdAt: string;
|
|
15
|
+
model: string | null;
|
|
16
|
+
sessionId: string | null;
|
|
17
|
+
projectSlug: string;
|
|
18
|
+
projectName: string;
|
|
19
|
+
cwd: string;
|
|
20
|
+
worktree: string | null;
|
|
21
|
+
gitBranch: string | null;
|
|
22
|
+
gitCommit: string | null;
|
|
23
|
+
supersedes: string | null;
|
|
24
|
+
supersededBy: string | null;
|
|
25
|
+
contentSha256: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WrapOptions {
|
|
29
|
+
body: string;
|
|
30
|
+
meta: ArtifactMeta;
|
|
31
|
+
theme: ThemeTokens;
|
|
32
|
+
warnings?: string[];
|
|
33
|
+
/** Interactive artifact data. When present, renders question controls below the body. */
|
|
34
|
+
interactive?: InteractiveData;
|
|
35
|
+
/** Relative href for the dynamic theme <link> tag.
|
|
36
|
+
* Default: "../../../theme.css" (artifact context).
|
|
37
|
+
* Pass "" or omit to use the default.
|
|
38
|
+
* Pass null to suppress the <link> entirely (standalone/test mode). */
|
|
39
|
+
themeCssHref?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function escapeHtml(str: string): string {
|
|
43
|
+
return str
|
|
44
|
+
.replace(/&/g, "&")
|
|
45
|
+
.replace(/</g, "<")
|
|
46
|
+
.replace(/>/g, ">")
|
|
47
|
+
.replace(/"/g, """);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safeJsonForScript(obj: unknown): string {
|
|
51
|
+
return JSON.stringify(obj, null, 2).replace(/<\/script>/gi, "<\\/script>");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderWarnings(warnings: string[]): string {
|
|
55
|
+
if (warnings.length === 0) return "";
|
|
56
|
+
return warnings.map((w) => `<div class="callout warn">${escapeHtml(w)}</div>`).join("\n") + "\n";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const BACK_NAV_STYLE =
|
|
60
|
+
"font-family: var(--mono); font-size: 12px; letter-spacing: 0.04em; " +
|
|
61
|
+
"margin-bottom: 24px; color: var(--muted);";
|
|
62
|
+
const BACK_LINK_STYLE = "color: var(--muted); text-decoration: none;";
|
|
63
|
+
|
|
64
|
+
// ─── Interactive rendering ─────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function renderQuestionSection(q: Question, interactive: InteractiveData): string {
|
|
67
|
+
const answered = interactive.answers[q.id];
|
|
68
|
+
|
|
69
|
+
if (answered !== undefined) {
|
|
70
|
+
return renderAnswered(q, answered.value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return renderControl(q);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderInteractive(interactive: InteractiveData): string {
|
|
77
|
+
const sections = interactive.questions
|
|
78
|
+
.map((q) => renderQuestionSection(q, interactive))
|
|
79
|
+
.join("\n");
|
|
80
|
+
return `\n<section class="cs-questions">\n${sections}\n</section>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderBackNav(meta: ArtifactMeta): string {
|
|
84
|
+
// Two relative links: project index lives one directory up from the artifact;
|
|
85
|
+
// global index is three levels up (../../../index.html). Both work whether
|
|
86
|
+
// the file is opened over http (cesium server) or via file://.
|
|
87
|
+
const projectLabel = escapeHtml(meta.projectName);
|
|
88
|
+
return `<nav class="cesium-back" aria-label="cesium navigation" style="${BACK_NAV_STYLE}">
|
|
89
|
+
<a href="../index.html" style="${BACK_LINK_STYLE}">← ${projectLabel}</a>
|
|
90
|
+
<span style="margin: 0 8px; opacity: 0.5;">·</span>
|
|
91
|
+
<a href="../../../index.html" style="${BACK_LINK_STYLE}">all projects</a>
|
|
92
|
+
</nav>
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderFooter(meta: ArtifactMeta): string {
|
|
97
|
+
const parts: string[] = [
|
|
98
|
+
`<span>id: <code>${escapeHtml(meta.id)}</code></span>`,
|
|
99
|
+
`<span>kind: ${escapeHtml(meta.kind)}</span>`,
|
|
100
|
+
`<span>created: ${escapeHtml(meta.createdAt)}</span>`,
|
|
101
|
+
];
|
|
102
|
+
if (meta.model) parts.push(`<span>model: ${escapeHtml(meta.model)}</span>`);
|
|
103
|
+
if (meta.supersedes) {
|
|
104
|
+
parts.push(
|
|
105
|
+
`<span>revises: <a href="#supersedes-${escapeHtml(meta.supersedes)}">${escapeHtml(meta.supersedes)}</a></span>`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (meta.supersededBy) {
|
|
109
|
+
parts.push(
|
|
110
|
+
`<span>superseded by: <a href="#supersedes-${escapeHtml(meta.supersededBy)}">${escapeHtml(meta.supersededBy)}</a></span>`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return `<footer class="byline">\n${parts.map((p) => ` ${p}`).join("\n")}\n</footer>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function wrapDocument(opts: WrapOptions): string {
|
|
117
|
+
const { body, meta, theme, warnings = [], interactive } = opts;
|
|
118
|
+
// Default href: artifact context (three levels deep from stateDir)
|
|
119
|
+
const href =
|
|
120
|
+
opts.themeCssHref === undefined
|
|
121
|
+
? "../../../theme.css"
|
|
122
|
+
: opts.themeCssHref === ""
|
|
123
|
+
? "../../../theme.css"
|
|
124
|
+
: opts.themeCssHref;
|
|
125
|
+
// Suppress <link> when null is explicitly passed
|
|
126
|
+
const suppressLink = opts.themeCssHref === null;
|
|
127
|
+
|
|
128
|
+
const rules = frameworkRulesCss();
|
|
129
|
+
const tokens = themeTokensCss(theme);
|
|
130
|
+
// Embed interactive into the cesium-meta JSON block when present
|
|
131
|
+
const metaPayload: Record<string, unknown> = { ...meta };
|
|
132
|
+
if (interactive !== undefined) {
|
|
133
|
+
metaPayload["interactive"] = interactive;
|
|
134
|
+
}
|
|
135
|
+
const metaJson = safeJsonForScript(metaPayload);
|
|
136
|
+
const titleEsc = escapeHtml(meta.title);
|
|
137
|
+
const backNav = renderBackNav(meta);
|
|
138
|
+
const warningHtml = renderWarnings(warnings);
|
|
139
|
+
const footer = renderFooter(meta);
|
|
140
|
+
const interactiveHtml = interactive !== undefined ? renderInteractive(interactive) : "";
|
|
141
|
+
// Inject client JS only when the session is open (status === "open")
|
|
142
|
+
const clientScriptTag =
|
|
143
|
+
interactive !== undefined && interactive.status === "open"
|
|
144
|
+
? `\n<script data-cesium-client>${getClientJs()}</script>`
|
|
145
|
+
: "";
|
|
146
|
+
|
|
147
|
+
const linkTag = suppressLink ? "" : `\n <link rel="stylesheet" href="${href}">`;
|
|
148
|
+
|
|
149
|
+
return `<!doctype html>
|
|
150
|
+
<html lang="en">
|
|
151
|
+
<head>
|
|
152
|
+
<meta charset="utf-8">
|
|
153
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
154
|
+
<title>${titleEsc} · cesium</title>
|
|
155
|
+
<style>${rules}
|
|
156
|
+
/* fallback theme tokens — used when theme.css is missing or unreachable */
|
|
157
|
+
${tokens}</style>${linkTag}
|
|
158
|
+
<script type="application/json" id="cesium-meta">${metaJson}</script>
|
|
159
|
+
</head>
|
|
160
|
+
<body>
|
|
161
|
+
${backNav}${warningHtml}${body}${interactiveHtml}${clientScriptTag}
|
|
162
|
+
${footer}
|
|
163
|
+
</body>
|
|
164
|
+
</html>`;
|
|
165
|
+
}
|