@cfbender/cesium 0.3.6 → 0.5.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 (43) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +28 -14
  3. package/assets/styleguide.html +149 -0
  4. package/package.json +1 -1
  5. package/src/cli/commands/serve.ts +3 -0
  6. package/src/index.ts +4 -1
  7. package/src/prompt/field-reference.ts +94 -0
  8. package/src/prompt/system-fragment.md +56 -65
  9. package/src/render/blocks/catalog.ts +39 -0
  10. package/src/render/blocks/escape.ts +27 -0
  11. package/src/render/blocks/index.ts +6 -0
  12. package/src/render/blocks/markdown.ts +217 -0
  13. package/src/render/blocks/render.ts +96 -0
  14. package/src/render/blocks/renderers/callout.ts +38 -0
  15. package/src/render/blocks/renderers/code.ts +44 -0
  16. package/src/render/blocks/renderers/compare-table.ts +56 -0
  17. package/src/render/blocks/renderers/diagram.ts +48 -0
  18. package/src/render/blocks/renderers/divider.ts +31 -0
  19. package/src/render/blocks/renderers/hero.ts +66 -0
  20. package/src/render/blocks/renderers/kv.ts +45 -0
  21. package/src/render/blocks/renderers/list.ts +51 -0
  22. package/src/render/blocks/renderers/pill-row.ts +45 -0
  23. package/src/render/blocks/renderers/prose.ts +29 -0
  24. package/src/render/blocks/renderers/raw-html.ts +32 -0
  25. package/src/render/blocks/renderers/risk-table.ts +76 -0
  26. package/src/render/blocks/renderers/section.ts +95 -0
  27. package/src/render/blocks/renderers/timeline.ts +58 -0
  28. package/src/render/blocks/renderers/tldr.ts +30 -0
  29. package/src/render/blocks/types.ts +127 -0
  30. package/src/render/blocks/validate-block.ts +202 -0
  31. package/src/render/critique.ts +410 -10
  32. package/src/render/fallback.ts +18 -0
  33. package/src/render/theme.ts +235 -0
  34. package/src/render/validate.ts +282 -17
  35. package/src/render/wrap.ts +7 -7
  36. package/src/server/lifecycle.ts +7 -1
  37. package/src/storage/assets.ts +66 -0
  38. package/src/storage/index-cache.ts +1 -0
  39. package/src/storage/index-gen.ts +13 -14
  40. package/src/tools/ask.ts +5 -3
  41. package/src/tools/critique.ts +41 -6
  42. package/src/tools/publish.ts +39 -12
  43. package/src/tools/styleguide.ts +109 -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
+ }
@@ -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 {
@@ -456,6 +610,87 @@ h1, h2, h3, h4, h5, h6 {
456
610
  color: var(--muted);
457
611
  text-transform: lowercase;
458
612
  }
613
+ .pill.accent {
614
+ background: color-mix(in srgb, var(--accent) 18%, var(--surface));
615
+ color: var(--accent);
616
+ font-weight: 600;
617
+ }
618
+
619
+ /* ranked list — numbered cards for ordered recommendations / findings */
620
+ .ranked-list {
621
+ display: flex;
622
+ flex-direction: column;
623
+ gap: 1em;
624
+ margin: 0 0 1.5em;
625
+ padding: 0;
626
+ list-style: none;
627
+ }
628
+ .ranked-item {
629
+ background: var(--surface);
630
+ border: 1.5px solid var(--rule);
631
+ border-radius: 12px;
632
+ padding: 22px 26px;
633
+ display: grid;
634
+ grid-template-columns: 64px 1fr;
635
+ gap: 6px 24px;
636
+ align-items: start;
637
+ }
638
+ .ranked-item .rank-num {
639
+ font-family: var(--serif);
640
+ font-size: 2.4rem;
641
+ font-weight: 500;
642
+ color: var(--oat);
643
+ line-height: 1;
644
+ letter-spacing: -0.02em;
645
+ padding-top: 2px;
646
+ }
647
+ .ranked-item .rank-title {
648
+ font-family: var(--serif);
649
+ font-size: 1.2rem;
650
+ font-weight: 600;
651
+ color: var(--ink);
652
+ margin: 0 0 6px;
653
+ line-height: 1.3;
654
+ }
655
+ .ranked-item .rank-meta {
656
+ display: flex;
657
+ align-items: center;
658
+ gap: 8px;
659
+ flex-wrap: wrap;
660
+ margin-bottom: 14px;
661
+ }
662
+ .ranked-item .rank-body > p {
663
+ color: var(--ink-soft);
664
+ font-size: 0.95rem;
665
+ line-height: 1.65;
666
+ margin: 0 0 0.85em;
667
+ }
668
+ .ranked-item .rank-body > p:last-child { margin-bottom: 0; }
669
+ .ranked-item .rank-body > ul,
670
+ .ranked-item .rank-body > ol {
671
+ color: var(--ink-soft);
672
+ font-size: 0.95rem;
673
+ line-height: 1.65;
674
+ margin: 0 0 0.85em;
675
+ padding-left: 1.2em;
676
+ }
677
+ .ranked-item .rank-aside {
678
+ color: var(--muted);
679
+ font-size: 0.9rem;
680
+ line-height: 1.6;
681
+ padding-left: 14px;
682
+ border-left: 2px solid var(--rule);
683
+ margin: 0;
684
+ }
685
+ .ranked-item .rank-aside-label {
686
+ font-family: var(--mono);
687
+ font-size: 0.7rem;
688
+ font-weight: 600;
689
+ letter-spacing: 0.1em;
690
+ text-transform: uppercase;
691
+ color: var(--accent);
692
+ margin-right: 6px;
693
+ }
459
694
 
460
695
  /* byline */
461
696
  .byline {
@@ -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
- export interface PublishInput {
411
- title: string;
412
- kind: PublishKind;
413
- html: string;
414
- summary?: string;
415
- tags?: string[];
416
- supersedes?: string;
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
- 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" };
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 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"];
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
- return { ok: true, value: result };
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"]);
@@ -1,6 +1,7 @@
1
1
  // Assembles the full <!doctype html> document from a body fragment + metadata.
2
2
 
3
- import { frameworkRulesCss, themeTokensCss, type ThemeTokens } from "./theme.ts";
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, theme, warnings = [], interactive } = opts;
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 rules = frameworkRulesCss();
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>${rules}
175
- /* fallback theme tokens — used when theme.css is missing or unreachable */
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>
@@ -7,6 +7,8 @@ import { startServer, type ServerHandle } from "./http.ts";
7
7
  import { acquireLock } from "../storage/lock.ts";
8
8
  import { createApiHandler } from "./api.ts";
9
9
  import { createFaviconHandler } from "./favicon.ts";
10
+ import { ensureThemeCss } from "../storage/assets.ts";
11
+ import { defaultTheme, type ThemeTokens } from "../render/theme.ts";
10
12
 
11
13
  export interface LifecycleConfig {
12
14
  stateDir: string;
@@ -14,6 +16,7 @@ export interface LifecycleConfig {
14
16
  portMax: number; // upper bound (inclusive)
15
17
  idleTimeoutMs: number;
16
18
  hostname?: string; // default "127.0.0.1"
19
+ theme?: ThemeTokens; // default: defaultTheme()
17
20
  }
18
21
 
19
22
  export interface RunningInfo {
@@ -200,7 +203,7 @@ export async function stopRunning(stateDir: string): Promise<void> {
200
203
  }
201
204
 
202
205
  export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo> {
203
- const { stateDir, port, portMax, idleTimeoutMs, hostname = "127.0.0.1" } = cfg;
206
+ const { stateDir, port, portMax, idleTimeoutMs, hostname = "127.0.0.1", theme = defaultTheme() } = cfg;
204
207
  const pidFilePath = join(stateDir, ".server.pid");
205
208
  const lockPath = join(stateDir, ".server-start.lock");
206
209
 
@@ -259,6 +262,9 @@ export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo>
259
262
  const handle = await tryBindPort(port);
260
263
  const boundPort = handle.port;
261
264
 
265
+ // Materialize theme.css before serving — self-heals on plugin upgrade
266
+ await ensureThemeCss(stateDir, theme);
267
+
262
268
  // Wire API handler before static file fallback
263
269
  handle.addHandler(createApiHandler({ stateDir }));
264
270
  // /favicon.ico shim — browsers auto-request this even when the page