@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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Minimal inline fallback CSS — ~8 lines, ≤500 bytes minified.
|
|
2
|
+
// Goal: standalone .html files opened via file:// look "plain but readable,"
|
|
3
|
+
// not broken. Full styling comes from the served /theme.css.
|
|
4
|
+
|
|
5
|
+
/** Returns a compact CSS block covering typography, color tokens, and basic
|
|
6
|
+
* component borders. Used as the inline <style> fallback in every artifact.
|
|
7
|
+
* Budget: ≤500 bytes minified. */
|
|
8
|
+
export function fallbackCss(): string {
|
|
9
|
+
return (
|
|
10
|
+
":root{font-family:system-ui,sans-serif;line-height:1.6;}" +
|
|
11
|
+
"body{max-width:900px;margin:0 auto;padding:24px;}" +
|
|
12
|
+
"@media(prefers-color-scheme:dark){body{background:#180810;color:#ddd;}}" +
|
|
13
|
+
"pre,code{font-family:ui-monospace,monospace;font-size:.875em;}" +
|
|
14
|
+
".card,.tldr{border:1.5px solid #ccc;border-radius:8px;padding:14px 18px;}" +
|
|
15
|
+
".callout{border:1.5px solid #ccc;border-radius:6px;padding:12px 16px;}" +
|
|
16
|
+
"table{border-collapse:collapse;width:100%;}th,td{border:1px solid #ccc;padding:8px;}"
|
|
17
|
+
);
|
|
18
|
+
}
|
package/src/render/theme.ts
CHANGED
|
@@ -336,6 +336,34 @@ h1, h2, h3, h4, h5, h6 {
|
|
|
336
336
|
.code .cm { color: var(--muted); font-style: italic; }
|
|
337
337
|
.code .fn { color: #d4a85a; }
|
|
338
338
|
|
|
339
|
+
/* figure.code — block-renderer code blocks (figure > figcaption + pre>code) */
|
|
340
|
+
figure.code {
|
|
341
|
+
margin: 0 0 1.25em 0;
|
|
342
|
+
}
|
|
343
|
+
figure.code pre {
|
|
344
|
+
background: var(--code-bg);
|
|
345
|
+
color: var(--code-fg);
|
|
346
|
+
font-family: var(--mono);
|
|
347
|
+
font-size: 0.875rem;
|
|
348
|
+
line-height: 1.6;
|
|
349
|
+
border-radius: 8px;
|
|
350
|
+
padding: 16px 20px;
|
|
351
|
+
overflow-x: auto;
|
|
352
|
+
margin: 0;
|
|
353
|
+
white-space: pre;
|
|
354
|
+
}
|
|
355
|
+
figure.code pre code {
|
|
356
|
+
font-family: inherit;
|
|
357
|
+
font-size: inherit;
|
|
358
|
+
background: none;
|
|
359
|
+
padding: 0;
|
|
360
|
+
color: inherit;
|
|
361
|
+
}
|
|
362
|
+
figure.code pre .kw { color: var(--accent); }
|
|
363
|
+
figure.code pre .str { color: var(--olive); }
|
|
364
|
+
figure.code pre .cm { color: var(--muted); font-style: italic; }
|
|
365
|
+
figure.code pre .fn { color: #d4a85a; }
|
|
366
|
+
|
|
339
367
|
/* timeline */
|
|
340
368
|
.timeline { list-style: none; padding: 0; position: relative; }
|
|
341
369
|
.timeline::before {
|
|
@@ -363,6 +391,26 @@ h1, h2, h3, h4, h5, h6 {
|
|
|
363
391
|
background: var(--oat);
|
|
364
392
|
border: 2px solid var(--accent);
|
|
365
393
|
}
|
|
394
|
+
/* timeline item inner spans */
|
|
395
|
+
.timeline-item { display: flex; flex-direction: column; gap: 0.15em; }
|
|
396
|
+
.timeline-label {
|
|
397
|
+
font-family: var(--mono);
|
|
398
|
+
font-size: 0.8rem;
|
|
399
|
+
font-weight: 700;
|
|
400
|
+
letter-spacing: 0.06em;
|
|
401
|
+
text-transform: uppercase;
|
|
402
|
+
color: var(--accent);
|
|
403
|
+
}
|
|
404
|
+
.timeline-date {
|
|
405
|
+
font-family: var(--mono);
|
|
406
|
+
font-size: 0.75rem;
|
|
407
|
+
color: var(--muted);
|
|
408
|
+
margin-left: 0.5em;
|
|
409
|
+
font-weight: 400;
|
|
410
|
+
letter-spacing: 0;
|
|
411
|
+
text-transform: none;
|
|
412
|
+
}
|
|
413
|
+
.timeline-text { color: var(--ink-soft); font-size: 0.95rem; }
|
|
366
414
|
|
|
367
415
|
/* diagram */
|
|
368
416
|
.diagram {
|
|
@@ -379,6 +427,112 @@ h1, h2, h3, h4, h5, h6 {
|
|
|
379
427
|
margin-top: 10px;
|
|
380
428
|
font-family: var(--sans);
|
|
381
429
|
}
|
|
430
|
+
/* svg text/stroke/fill overrides — defense-in-depth for dark themes */
|
|
431
|
+
.diagram svg text,
|
|
432
|
+
figure.diagram svg text { fill: currentColor; }
|
|
433
|
+
.diagram svg [stroke="#888"],
|
|
434
|
+
.diagram svg [stroke="#999"],
|
|
435
|
+
.diagram svg [stroke="#666"] { stroke: currentColor; opacity: 0.55; }
|
|
436
|
+
.diagram svg [fill="#222"],
|
|
437
|
+
.diagram svg [fill="#000"],
|
|
438
|
+
.diagram svg [fill="black"] { fill: currentColor; }
|
|
439
|
+
|
|
440
|
+
/* kv — key-value definition list (hero meta, standalone kv block) */
|
|
441
|
+
dl.kv {
|
|
442
|
+
display: grid;
|
|
443
|
+
grid-template-columns: max-content 1fr;
|
|
444
|
+
gap: 0.4rem 1.5rem;
|
|
445
|
+
margin: 1.25rem 0;
|
|
446
|
+
align-items: baseline;
|
|
447
|
+
}
|
|
448
|
+
dl.kv dt {
|
|
449
|
+
font-family: var(--mono);
|
|
450
|
+
font-size: 0.75rem;
|
|
451
|
+
font-weight: 600;
|
|
452
|
+
letter-spacing: 0.08em;
|
|
453
|
+
text-transform: uppercase;
|
|
454
|
+
color: var(--muted);
|
|
455
|
+
}
|
|
456
|
+
dl.kv dd { margin: 0; color: var(--ink-soft); }
|
|
457
|
+
|
|
458
|
+
/* lede — hero subtitle paragraph */
|
|
459
|
+
.lede {
|
|
460
|
+
font-size: 1.15rem;
|
|
461
|
+
color: var(--ink-soft);
|
|
462
|
+
margin-bottom: 1em;
|
|
463
|
+
line-height: 1.5;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* divider — plain hr + labeled variant */
|
|
467
|
+
hr {
|
|
468
|
+
border: none;
|
|
469
|
+
border-top: 1.5px solid var(--rule);
|
|
470
|
+
margin: 2em 0;
|
|
471
|
+
}
|
|
472
|
+
hr[data-label] {
|
|
473
|
+
display: flex;
|
|
474
|
+
align-items: center;
|
|
475
|
+
gap: 0.75em;
|
|
476
|
+
border: none;
|
|
477
|
+
margin: 2em 0;
|
|
478
|
+
color: var(--muted);
|
|
479
|
+
font-family: var(--mono);
|
|
480
|
+
font-size: 0.75rem;
|
|
481
|
+
letter-spacing: 0.08em;
|
|
482
|
+
text-transform: uppercase;
|
|
483
|
+
}
|
|
484
|
+
hr[data-label]::before {
|
|
485
|
+
content: "";
|
|
486
|
+
flex: 1;
|
|
487
|
+
border-top: 1.5px solid var(--rule);
|
|
488
|
+
display: block;
|
|
489
|
+
}
|
|
490
|
+
hr[data-label]::after {
|
|
491
|
+
content: attr(data-label);
|
|
492
|
+
flex-shrink: 0;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/* figure.code — caption above the code block */
|
|
496
|
+
figure.code figcaption {
|
|
497
|
+
font-family: var(--mono);
|
|
498
|
+
font-size: 0.75rem;
|
|
499
|
+
color: var(--muted);
|
|
500
|
+
margin-bottom: 8px;
|
|
501
|
+
letter-spacing: 0.04em;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/* pill-row — horizontal chip container */
|
|
505
|
+
.pill-row {
|
|
506
|
+
display: flex;
|
|
507
|
+
flex-wrap: wrap;
|
|
508
|
+
gap: 0.5em;
|
|
509
|
+
margin-bottom: 1.25em;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* check-list — checklist style (ul style="check") */
|
|
513
|
+
.check-list {
|
|
514
|
+
list-style: none;
|
|
515
|
+
padding: 0;
|
|
516
|
+
margin-bottom: 1em;
|
|
517
|
+
}
|
|
518
|
+
.check-list .check {
|
|
519
|
+
display: flex;
|
|
520
|
+
align-items: baseline;
|
|
521
|
+
gap: 0.5em;
|
|
522
|
+
margin-bottom: 0.4em;
|
|
523
|
+
padding-left: 0.25em;
|
|
524
|
+
}
|
|
525
|
+
.check-list .check::before {
|
|
526
|
+
content: "✓";
|
|
527
|
+
font-family: var(--mono);
|
|
528
|
+
font-size: 0.8em;
|
|
529
|
+
font-weight: 700;
|
|
530
|
+
color: var(--olive);
|
|
531
|
+
flex-shrink: 0;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/* card stack — sibling cards within a section */
|
|
535
|
+
.card + .card { margin-top: 1em; }
|
|
382
536
|
|
|
383
537
|
/* compare-table */
|
|
384
538
|
.compare-table {
|
package/src/render/validate.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { parseFragment, defaultTreeAdapter as ta } from "parse5";
|
|
4
4
|
import type { DefaultTreeAdapterTypes } from "parse5";
|
|
5
|
+
import type { Block } from "./blocks/types.ts";
|
|
6
|
+
import { blockCatalog } from "./blocks/catalog.ts";
|
|
7
|
+
import { deepValidateBlock } from "./blocks/validate-block.ts";
|
|
5
8
|
|
|
6
9
|
type ChildNode = DefaultTreeAdapterTypes.ChildNode;
|
|
7
10
|
type Element = DefaultTreeAdapterTypes.Element;
|
|
@@ -407,19 +410,251 @@ export function validateAskInput(input: unknown): AskValidationResult {
|
|
|
407
410
|
return { ok: true, value: result };
|
|
408
411
|
}
|
|
409
412
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
413
|
+
// ─── PublishInput — supports html XOR blocks ──────────────────────────────────
|
|
414
|
+
|
|
415
|
+
export type PublishInput =
|
|
416
|
+
| {
|
|
417
|
+
title: string;
|
|
418
|
+
kind: PublishKind;
|
|
419
|
+
html: string;
|
|
420
|
+
blocks?: never;
|
|
421
|
+
summary?: string;
|
|
422
|
+
tags?: string[];
|
|
423
|
+
supersedes?: string;
|
|
424
|
+
}
|
|
425
|
+
| {
|
|
426
|
+
title: string;
|
|
427
|
+
kind: PublishKind;
|
|
428
|
+
blocks: Block[];
|
|
429
|
+
html?: never;
|
|
430
|
+
summary?: string;
|
|
431
|
+
tags?: string[];
|
|
432
|
+
supersedes?: string;
|
|
433
|
+
};
|
|
418
434
|
|
|
419
435
|
function isPublishKind(val: unknown): val is PublishKind {
|
|
420
436
|
return typeof val === "string" && (PUBLISH_KINDS as readonly string[]).includes(val);
|
|
421
437
|
}
|
|
422
438
|
|
|
439
|
+
// ─── Block validation ─────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
type BlockValidationError = { path: string; message: string };
|
|
442
|
+
type BlockValidationResult = { ok: true; blocks: Block[] } | { ok: false; errors: BlockValidationError[] };
|
|
443
|
+
|
|
444
|
+
function validateBlock(raw: unknown, path: string, depth: number): BlockValidationError[] {
|
|
445
|
+
const errors: BlockValidationError[] = [];
|
|
446
|
+
|
|
447
|
+
if (raw === null || typeof raw !== "object") {
|
|
448
|
+
errors.push({ path, message: "block must be an object" });
|
|
449
|
+
return errors;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const b = raw as Record<string, unknown>;
|
|
453
|
+
const type = b["type"];
|
|
454
|
+
|
|
455
|
+
if (typeof type !== "string") {
|
|
456
|
+
errors.push({ path, message: "block.type must be a string" });
|
|
457
|
+
return errors;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!(type in blockCatalog)) {
|
|
461
|
+
errors.push({ path, message: `unknown block type: "${type}"` });
|
|
462
|
+
return errors;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Per-type required field checks
|
|
466
|
+
switch (type) {
|
|
467
|
+
case "hero": {
|
|
468
|
+
if (typeof b["title"] !== "string" || (b["title"] as string).trim() === "") {
|
|
469
|
+
errors.push({ path, message: "hero block requires a non-empty title" });
|
|
470
|
+
}
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
case "tldr": {
|
|
474
|
+
if (typeof b["markdown"] !== "string") {
|
|
475
|
+
errors.push({ path, message: "tldr block requires markdown field" });
|
|
476
|
+
}
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
case "section": {
|
|
480
|
+
if (typeof b["title"] !== "string" || (b["title"] as string).trim() === "") {
|
|
481
|
+
errors.push({ path, message: "section block requires a non-empty title" });
|
|
482
|
+
}
|
|
483
|
+
if (!Array.isArray(b["children"])) {
|
|
484
|
+
errors.push({ path, message: "section block requires children array" });
|
|
485
|
+
} else {
|
|
486
|
+
if (depth > 3) {
|
|
487
|
+
errors.push({ path, message: `section nesting depth exceeds maximum of 3 (current depth: ${depth})` });
|
|
488
|
+
} else {
|
|
489
|
+
const children = b["children"] as unknown[];
|
|
490
|
+
for (let i = 0; i < children.length; i++) {
|
|
491
|
+
const childErrors = validateBlock(children[i], `${path}.children[${i}]`, depth + 1);
|
|
492
|
+
for (const e of childErrors) errors.push(e);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
case "prose": {
|
|
499
|
+
if (typeof b["markdown"] !== "string") {
|
|
500
|
+
errors.push({ path, message: "prose block requires markdown field" });
|
|
501
|
+
}
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
case "list": {
|
|
505
|
+
if (!Array.isArray(b["items"])) {
|
|
506
|
+
errors.push({ path, message: "list block requires items array" });
|
|
507
|
+
}
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
case "callout": {
|
|
511
|
+
if (b["variant"] !== "note" && b["variant"] !== "warn" && b["variant"] !== "risk") {
|
|
512
|
+
errors.push({ path, message: 'callout block requires variant: "note", "warn", or "risk"' });
|
|
513
|
+
}
|
|
514
|
+
if (typeof b["markdown"] !== "string") {
|
|
515
|
+
errors.push({ path, message: "callout block requires markdown field" });
|
|
516
|
+
}
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
case "code": {
|
|
520
|
+
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)' });
|
|
522
|
+
}
|
|
523
|
+
if (typeof b["code"] !== "string") {
|
|
524
|
+
errors.push({ path, message: "code block requires code field" });
|
|
525
|
+
}
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
case "timeline": {
|
|
529
|
+
if (!Array.isArray(b["items"])) {
|
|
530
|
+
errors.push({ path, message: "timeline block requires items array" });
|
|
531
|
+
}
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
case "compare_table": {
|
|
535
|
+
if (!Array.isArray(b["headers"]) || (b["headers"] as unknown[]).length === 0) {
|
|
536
|
+
errors.push({ path, message: "compare_table block requires non-empty headers array" });
|
|
537
|
+
} else {
|
|
538
|
+
const headers = b["headers"] as unknown[];
|
|
539
|
+
const headerCount = headers.length;
|
|
540
|
+
if (Array.isArray(b["rows"])) {
|
|
541
|
+
const rows = b["rows"] as unknown[];
|
|
542
|
+
for (let i = 0; i < rows.length; i++) {
|
|
543
|
+
const row = rows[i];
|
|
544
|
+
if (!Array.isArray(row) || (row as unknown[]).length !== headerCount) {
|
|
545
|
+
errors.push({
|
|
546
|
+
path: `${path}.rows[${i}]`,
|
|
547
|
+
message: `compare_table row has ${Array.isArray(row) ? (row as unknown[]).length : "?"} cells but headers has ${headerCount}`,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
errors.push({ path, message: "compare_table block requires rows array" });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
case "risk_table": {
|
|
558
|
+
if (!Array.isArray(b["rows"])) {
|
|
559
|
+
errors.push({ path, message: "risk_table block requires rows array" });
|
|
560
|
+
}
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
case "kv": {
|
|
564
|
+
if (!Array.isArray(b["rows"])) {
|
|
565
|
+
errors.push({ path, message: "kv block requires rows array" });
|
|
566
|
+
}
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
case "pill_row": {
|
|
570
|
+
if (!Array.isArray(b["items"])) {
|
|
571
|
+
errors.push({ path, message: "pill_row block requires items array" });
|
|
572
|
+
}
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
case "divider": {
|
|
576
|
+
// No required fields beyond type
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
case "diagram": {
|
|
580
|
+
const hasSvg = typeof b["svg"] === "string";
|
|
581
|
+
const hasHtml = typeof b["html"] === "string";
|
|
582
|
+
if (hasSvg && hasHtml) {
|
|
583
|
+
errors.push({ path, message: "diagram block must have exactly one of svg or html, not both" });
|
|
584
|
+
} else if (!hasSvg && !hasHtml) {
|
|
585
|
+
errors.push({ path, message: "diagram block requires exactly one of svg or html" });
|
|
586
|
+
}
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
case "raw_html": {
|
|
590
|
+
if (typeof b["html"] !== "string" || (b["html"] as string).trim() === "") {
|
|
591
|
+
errors.push({ path, message: "raw_html block requires non-empty html field" });
|
|
592
|
+
}
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Deep field validation against catalog schema — catches unknown fields (with "did you mean"
|
|
598
|
+
// suggestions) and bad enum values. Only runs when per-type required checks passed to avoid
|
|
599
|
+
// duplicating error messages.
|
|
600
|
+
if (errors.length === 0) {
|
|
601
|
+
const deepErrors = deepValidateBlock(b, path);
|
|
602
|
+
for (const e of deepErrors) errors.push(e);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return errors;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function validateBlocksArray(raw: unknown): BlockValidationResult {
|
|
609
|
+
if (!Array.isArray(raw)) {
|
|
610
|
+
return { ok: false, errors: [{ path: "blocks", message: "blocks must be an array" }] };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (raw.length > 1000) {
|
|
614
|
+
return { ok: false, errors: [{ path: "blocks", message: "blocks array exceeds maximum of 1000 blocks" }] };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const allErrors: BlockValidationError[] = [];
|
|
618
|
+
|
|
619
|
+
// Structural checks: at most one hero (must be first), at most one tldr
|
|
620
|
+
let heroCount = 0;
|
|
621
|
+
let tldrCount = 0;
|
|
622
|
+
|
|
623
|
+
for (let i = 0; i < raw.length; i++) {
|
|
624
|
+
const block = raw[i] as Record<string, unknown> | null | undefined;
|
|
625
|
+
const blockType = block !== null && typeof block === "object" ? block["type"] : undefined;
|
|
626
|
+
|
|
627
|
+
if (blockType === "hero") {
|
|
628
|
+
heroCount++;
|
|
629
|
+
if (i !== 0) {
|
|
630
|
+
allErrors.push({ path: `blocks[${i}]`, message: "hero block must be the first block if present" });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (blockType === "tldr") {
|
|
634
|
+
tldrCount++;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (heroCount > 1) {
|
|
639
|
+
allErrors.push({ path: "blocks", message: "at most one hero block is allowed per document" });
|
|
640
|
+
}
|
|
641
|
+
if (tldrCount > 1) {
|
|
642
|
+
allErrors.push({ path: "blocks", message: "at most one tldr block is allowed per document" });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Per-block validation (depth 1 at root)
|
|
646
|
+
for (let i = 0; i < raw.length; i++) {
|
|
647
|
+
const blockErrors = validateBlock(raw[i], `blocks[${i}]`, 1);
|
|
648
|
+
for (const e of blockErrors) allErrors.push(e);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (allErrors.length > 0) {
|
|
652
|
+
return { ok: false, errors: allErrors };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return { ok: true, blocks: raw as Block[] };
|
|
656
|
+
}
|
|
657
|
+
|
|
423
658
|
export function validatePublishInput(input: unknown): ValidationResult<PublishInput> {
|
|
424
659
|
if (input === null || typeof input !== "object") {
|
|
425
660
|
return { ok: false, error: "input must be an object" };
|
|
@@ -444,11 +679,16 @@ export function validatePublishInput(input: unknown): ValidationResult<PublishIn
|
|
|
444
679
|
}
|
|
445
680
|
const kind = raw["kind"];
|
|
446
681
|
|
|
447
|
-
// html
|
|
448
|
-
|
|
449
|
-
|
|
682
|
+
// XOR: exactly one of html or blocks
|
|
683
|
+
const hasHtml = "html" in raw && raw["html"] !== undefined;
|
|
684
|
+
const hasBlocks = "blocks" in raw && raw["blocks"] !== undefined;
|
|
685
|
+
|
|
686
|
+
if (hasHtml && hasBlocks) {
|
|
687
|
+
return { ok: false, error: "provide exactly one of html or blocks, not both" };
|
|
688
|
+
}
|
|
689
|
+
if (!hasHtml && !hasBlocks) {
|
|
690
|
+
return { ok: false, error: "provide exactly one of html or blocks" };
|
|
450
691
|
}
|
|
451
|
-
const html = raw["html"];
|
|
452
692
|
|
|
453
693
|
// summary (optional)
|
|
454
694
|
if ("summary" in raw && raw["summary"] !== undefined) {
|
|
@@ -479,12 +719,37 @@ export function validatePublishInput(input: unknown): ValidationResult<PublishIn
|
|
|
479
719
|
}
|
|
480
720
|
}
|
|
481
721
|
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
722
|
+
const commonFields = {
|
|
723
|
+
title,
|
|
724
|
+
kind,
|
|
725
|
+
...(typeof raw["summary"] === "string" ? { summary: raw["summary"] } : {}),
|
|
726
|
+
...(Array.isArray(raw["tags"]) ? { tags: raw["tags"] as string[] } : {}),
|
|
727
|
+
...(typeof raw["supersedes"] === "string" ? { supersedes: raw["supersedes"] } : {}),
|
|
728
|
+
};
|
|
486
729
|
|
|
487
|
-
|
|
730
|
+
if (hasHtml) {
|
|
731
|
+
// html branch
|
|
732
|
+
if (typeof raw["html"] !== "string" || raw["html"].trim() === "") {
|
|
733
|
+
return { ok: false, error: "html is required and must be a non-empty string" };
|
|
734
|
+
}
|
|
735
|
+
return {
|
|
736
|
+
ok: true,
|
|
737
|
+
value: { ...commonFields, html: raw["html"] },
|
|
738
|
+
};
|
|
739
|
+
} else {
|
|
740
|
+
// blocks branch
|
|
741
|
+
const blocksResult = validateBlocksArray(raw["blocks"]);
|
|
742
|
+
if (!blocksResult.ok) {
|
|
743
|
+
const errorMessages = blocksResult.errors
|
|
744
|
+
.map((e) => `${e.path}: ${e.message}`)
|
|
745
|
+
.join("; ");
|
|
746
|
+
return { ok: false, error: `blocks validation failed — ${errorMessages}` };
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
ok: true,
|
|
750
|
+
value: { ...commonFields, blocks: blocksResult.blocks },
|
|
751
|
+
};
|
|
752
|
+
}
|
|
488
753
|
}
|
|
489
754
|
|
|
490
755
|
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
package/src/render/wrap.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Assembles the full <!doctype html> document from a body fragment + metadata.
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { type ThemeTokens } from "./theme.ts";
|
|
4
|
+
import { fallbackCss } from "./fallback.ts";
|
|
4
5
|
import { renderControl, renderAnswered } from "./controls.ts";
|
|
5
6
|
import { getClientJs } from "./client-js.ts";
|
|
6
7
|
import { faviconLinkTag } from "./favicon.ts";
|
|
@@ -24,6 +25,7 @@ export interface ArtifactMeta {
|
|
|
24
25
|
supersedes: string | null;
|
|
25
26
|
supersededBy: string | null;
|
|
26
27
|
contentSha256: string;
|
|
28
|
+
inputMode: "html" | "blocks";
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export interface WrapOptions {
|
|
@@ -129,7 +131,7 @@ function renderFooter(meta: ArtifactMeta): string {
|
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
export function wrapDocument(opts: WrapOptions): string {
|
|
132
|
-
const { body, meta,
|
|
134
|
+
const { body, meta, warnings = [], interactive } = opts;
|
|
133
135
|
// Default href: artifact context (three levels deep from stateDir)
|
|
134
136
|
const href =
|
|
135
137
|
opts.themeCssHref === undefined
|
|
@@ -140,8 +142,7 @@ export function wrapDocument(opts: WrapOptions): string {
|
|
|
140
142
|
// Suppress <link> when null is explicitly passed
|
|
141
143
|
const suppressLink = opts.themeCssHref === null;
|
|
142
144
|
|
|
143
|
-
const
|
|
144
|
-
const tokens = themeTokensCss(theme);
|
|
145
|
+
const fallback = fallbackCss();
|
|
145
146
|
// Embed interactive into the cesium-meta JSON block when present
|
|
146
147
|
const metaPayload: Record<string, unknown> = { ...meta };
|
|
147
148
|
if (interactive !== undefined) {
|
|
@@ -171,9 +172,8 @@ export function wrapDocument(opts: WrapOptions): string {
|
|
|
171
172
|
<meta charset="utf-8">
|
|
172
173
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
173
174
|
<title>${titleEsc} · cesium</title>
|
|
174
|
-
<style
|
|
175
|
-
|
|
176
|
-
${tokens}</style>${linkTag}${faviconTag}
|
|
175
|
+
<style>/* fallback — standalone-readable; full styles served from /theme.css */
|
|
176
|
+
${fallback}</style>${linkTag}${faviconTag}
|
|
177
177
|
<script type="application/json" id="cesium-meta">${metaJson}</script>
|
|
178
178
|
</head>
|
|
179
179
|
<body>
|