@cfbender/cesium 0.6.1 → 0.7.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 +100 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/prompt/system-fragment.md +73 -8
- package/src/render/annotate-frozen.ts +90 -0
- package/src/render/blocks/render.ts +20 -0
- package/src/render/blocks/renderers/callout.ts +3 -2
- package/src/render/blocks/renderers/code.ts +17 -2
- package/src/render/blocks/renderers/compare-table.ts +3 -2
- package/src/render/blocks/renderers/diagram.ts +3 -2
- package/src/render/blocks/renderers/diff.ts +23 -9
- package/src/render/blocks/renderers/hero.ts +3 -2
- package/src/render/blocks/renderers/kv.ts +3 -2
- package/src/render/blocks/renderers/list.ts +5 -4
- package/src/render/blocks/renderers/pill-row.ts +3 -2
- package/src/render/blocks/renderers/prose.ts +8 -2
- package/src/render/blocks/renderers/raw-html.ts +8 -2
- package/src/render/blocks/renderers/risk-table.ts +3 -2
- package/src/render/blocks/renderers/section.ts +4 -2
- package/src/render/blocks/renderers/timeline.ts +3 -2
- package/src/render/blocks/renderers/tldr.ts +3 -2
- package/src/render/client-js.ts +804 -6
- package/src/render/critique.ts +5 -335
- package/src/render/theme.ts +431 -6
- package/src/render/validate.ts +353 -97
- package/src/render/wrap.ts +67 -9
- package/src/server/api.ts +162 -3
- package/src/storage/index-gen.ts +4 -2
- package/src/storage/mutate.ts +433 -27
- package/src/tools/annotate.ts +336 -0
- package/src/tools/ask.ts +2 -6
- package/src/tools/critique.ts +15 -45
- package/src/tools/publish.ts +16 -56
- package/src/tools/styleguide.ts +7 -1
- package/src/tools/wait.ts +77 -24
package/src/render/validate.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
// Validates cesium_publish and cesium_ask tool input before any write occurs.
|
|
2
2
|
|
|
3
|
-
import { parseFragment, defaultTreeAdapter as ta } from "parse5";
|
|
4
|
-
import type { DefaultTreeAdapterTypes } from "parse5";
|
|
5
3
|
import type { Block } from "./blocks/types.ts";
|
|
6
4
|
import { blockCatalog } from "./blocks/catalog.ts";
|
|
7
5
|
import { deepValidateBlock } from "./blocks/validate-block.ts";
|
|
8
6
|
|
|
9
|
-
type ChildNode = DefaultTreeAdapterTypes.ChildNode;
|
|
10
|
-
type Element = DefaultTreeAdapterTypes.Element;
|
|
11
|
-
|
|
12
7
|
export interface ValidationOk<T> {
|
|
13
8
|
ok: true;
|
|
14
9
|
value: T;
|
|
@@ -29,7 +24,8 @@ export type PublishKind =
|
|
|
29
24
|
| "audit"
|
|
30
25
|
| "rfc"
|
|
31
26
|
| "other"
|
|
32
|
-
| "ask"
|
|
27
|
+
| "ask"
|
|
28
|
+
| "annotate";
|
|
33
29
|
|
|
34
30
|
export const PUBLISH_KINDS: readonly PublishKind[] = [
|
|
35
31
|
"plan",
|
|
@@ -42,10 +38,23 @@ export const PUBLISH_KINDS: readonly PublishKind[] = [
|
|
|
42
38
|
"rfc",
|
|
43
39
|
"other",
|
|
44
40
|
"ask",
|
|
41
|
+
"annotate",
|
|
45
42
|
];
|
|
46
43
|
|
|
47
44
|
// ─── Interactive artifact types ────────────────────────────────────────────────
|
|
48
45
|
|
|
46
|
+
export type Comment = {
|
|
47
|
+
id: string; // server-assigned nanoid, opaque to client
|
|
48
|
+
anchor: string; // matches /^block-\d+(\.line-\d+)?$/
|
|
49
|
+
selectedText: string; // captured at submit time, ≤ 4096 chars
|
|
50
|
+
comment: string; // ≤ 16384 chars, non-empty after trim
|
|
51
|
+
createdAt: string; // ISO 8601
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type Verdict = "approve" | "request_changes" | "comment";
|
|
55
|
+
|
|
56
|
+
export type VerdictMode = "approve" | "approve-or-reject" | "full";
|
|
57
|
+
|
|
49
58
|
export type Option = {
|
|
50
59
|
id: string;
|
|
51
60
|
label: string;
|
|
@@ -114,7 +123,8 @@ export type AnswerValue =
|
|
|
114
123
|
| { type: "slider"; value: number }
|
|
115
124
|
| { type: "react"; decision: string; comment?: string };
|
|
116
125
|
|
|
117
|
-
export type
|
|
126
|
+
export type InteractiveAskData = {
|
|
127
|
+
kind: "ask";
|
|
118
128
|
status: "open" | "complete" | "expired" | "cancelled";
|
|
119
129
|
requireAll: boolean;
|
|
120
130
|
expiresAt: string;
|
|
@@ -123,6 +133,20 @@ export type InteractiveData = {
|
|
|
123
133
|
completedAt?: string;
|
|
124
134
|
};
|
|
125
135
|
|
|
136
|
+
export type InteractiveAnnotateData = {
|
|
137
|
+
kind: "annotate";
|
|
138
|
+
status: "open" | "complete" | "expired" | "cancelled";
|
|
139
|
+
expiresAt: string;
|
|
140
|
+
verdictMode: VerdictMode;
|
|
141
|
+
requireVerdict: boolean;
|
|
142
|
+
perLineFor: ("diff" | "code")[];
|
|
143
|
+
comments: Comment[];
|
|
144
|
+
verdict: { value: Verdict; decidedAt: string } | null;
|
|
145
|
+
completedAt?: string;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export type InteractiveData = InteractiveAskData | InteractiveAnnotateData;
|
|
149
|
+
|
|
126
150
|
// ─── Question validation ───────────────────────────────────────────────────────
|
|
127
151
|
|
|
128
152
|
function isNonEmptyString(v: unknown): v is string {
|
|
@@ -410,27 +434,309 @@ export function validateAskInput(input: unknown): AskValidationResult {
|
|
|
410
434
|
return { ok: true, value: result };
|
|
411
435
|
}
|
|
412
436
|
|
|
413
|
-
// ───
|
|
437
|
+
// ─── AnnotateInput validation ──────────────────────────────────────────────────
|
|
414
438
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
439
|
+
// annotate is blocks-only by design — no html/body escape valve.
|
|
440
|
+
export interface AnnotateInput {
|
|
441
|
+
title: string;
|
|
442
|
+
blocks: Block[];
|
|
443
|
+
verdictMode?: VerdictMode;
|
|
444
|
+
perLineFor?: ("diff" | "code")[];
|
|
445
|
+
requireVerdict?: boolean;
|
|
446
|
+
summary?: string;
|
|
447
|
+
tags?: string[];
|
|
448
|
+
expiresAt?: string;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const VERDICT_MODES: readonly VerdictMode[] = ["approve", "approve-or-reject", "full"];
|
|
452
|
+
|
|
453
|
+
function isVerdictMode(v: unknown): v is VerdictMode {
|
|
454
|
+
return typeof v === "string" && (VERDICT_MODES as readonly string[]).includes(v);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function validateAnnotateInput(input: unknown): ValidationResult<AnnotateInput> {
|
|
458
|
+
if (input === null || typeof input !== "object") {
|
|
459
|
+
return { ok: false, error: "input must be an object" };
|
|
460
|
+
}
|
|
461
|
+
const raw = input as Record<string, unknown>;
|
|
462
|
+
|
|
463
|
+
// title — required, non-empty, ≤ 200 chars
|
|
464
|
+
if (!("title" in raw) || typeof raw["title"] !== "string" || raw["title"].trim() === "") {
|
|
465
|
+
return { ok: false, error: "title is required and must be a non-empty string" };
|
|
466
|
+
}
|
|
467
|
+
if (raw["title"].length > 200) {
|
|
468
|
+
return { ok: false, error: "title must be 200 characters or fewer" };
|
|
469
|
+
}
|
|
470
|
+
const title = raw["title"];
|
|
471
|
+
|
|
472
|
+
// annotate is blocks-only — stray `body` is silently ignored, not an error.
|
|
473
|
+
|
|
474
|
+
// blocks — required, non-empty array
|
|
475
|
+
if (!("blocks" in raw) || !Array.isArray(raw["blocks"]) || raw["blocks"].length === 0) {
|
|
476
|
+
return { ok: false, error: "blocks must be a non-empty array" };
|
|
477
|
+
}
|
|
478
|
+
const blocksResult = validateBlocksArray(raw["blocks"]);
|
|
479
|
+
if (!blocksResult.ok) {
|
|
480
|
+
const errorMessages = blocksResult.errors.map((e) => `${e.path}: ${e.message}`).join("; ");
|
|
481
|
+
return { ok: false, error: `blocks validation failed — ${errorMessages}` };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// verdictMode — optional, must be valid enum value
|
|
485
|
+
if ("verdictMode" in raw && raw["verdictMode"] !== undefined) {
|
|
486
|
+
if (!isVerdictMode(raw["verdictMode"])) {
|
|
487
|
+
return {
|
|
488
|
+
ok: false,
|
|
489
|
+
error: `verdictMode must be one of: ${VERDICT_MODES.join(", ")}`,
|
|
490
|
+
};
|
|
424
491
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// perLineFor — optional array, only "diff" and "code", no duplicates
|
|
495
|
+
if ("perLineFor" in raw && raw["perLineFor"] !== undefined) {
|
|
496
|
+
if (!Array.isArray(raw["perLineFor"])) {
|
|
497
|
+
return { ok: false, error: "perLineFor must be an array" };
|
|
498
|
+
}
|
|
499
|
+
const seen = new Set<string>();
|
|
500
|
+
for (const item of raw["perLineFor"] as unknown[]) {
|
|
501
|
+
if (item !== "diff" && item !== "code") {
|
|
502
|
+
return {
|
|
503
|
+
ok: false,
|
|
504
|
+
error: `perLineFor items must be "diff" or "code", got "${String(item)}"`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (seen.has(item)) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
error: `perLineFor must not contain duplicates, got duplicate "${item}"`,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
seen.add(item);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// requireVerdict — optional boolean
|
|
518
|
+
if ("requireVerdict" in raw && raw["requireVerdict"] !== undefined) {
|
|
519
|
+
if (typeof raw["requireVerdict"] !== "boolean") {
|
|
520
|
+
return { ok: false, error: "requireVerdict must be a boolean" };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// summary — optional string
|
|
525
|
+
if ("summary" in raw && raw["summary"] !== undefined) {
|
|
526
|
+
if (typeof raw["summary"] !== "string") {
|
|
527
|
+
return { ok: false, error: "summary must be a string" };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// tags — optional array of strings
|
|
532
|
+
if ("tags" in raw && raw["tags"] !== undefined) {
|
|
533
|
+
if (!Array.isArray(raw["tags"])) {
|
|
534
|
+
return { ok: false, error: "tags must be an array of strings" };
|
|
535
|
+
}
|
|
536
|
+
for (const tag of raw["tags"]) {
|
|
537
|
+
if (typeof tag !== "string") {
|
|
538
|
+
return { ok: false, error: "tags must be an array of strings" };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// expiresAt — optional, must be valid ISO date string
|
|
544
|
+
if ("expiresAt" in raw && raw["expiresAt"] !== undefined) {
|
|
545
|
+
if (typeof raw["expiresAt"] !== "string") {
|
|
546
|
+
return { ok: false, error: "expiresAt must be a string" };
|
|
547
|
+
}
|
|
548
|
+
const d = new Date(raw["expiresAt"]);
|
|
549
|
+
if (isNaN(d.getTime())) {
|
|
550
|
+
return { ok: false, error: "expiresAt must be a valid ISO date string" };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const result: AnnotateInput = { title, blocks: blocksResult.blocks };
|
|
555
|
+
if (isVerdictMode(raw["verdictMode"])) result.verdictMode = raw["verdictMode"];
|
|
556
|
+
if (Array.isArray(raw["perLineFor"]))
|
|
557
|
+
result.perLineFor = raw["perLineFor"] as ("diff" | "code")[];
|
|
558
|
+
if (typeof raw["requireVerdict"] === "boolean") result.requireVerdict = raw["requireVerdict"];
|
|
559
|
+
if (typeof raw["summary"] === "string") result.summary = raw["summary"];
|
|
560
|
+
if (Array.isArray(raw["tags"])) result.tags = raw["tags"] as string[];
|
|
561
|
+
if (typeof raw["expiresAt"] === "string") result.expiresAt = raw["expiresAt"];
|
|
562
|
+
|
|
563
|
+
return { ok: true, value: result };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ─── Comment value validation ──────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
const ANCHOR_RE = /^block-\d+(\.line-\d+)?$/;
|
|
569
|
+
|
|
570
|
+
export function validateCommentValue(
|
|
571
|
+
input: unknown,
|
|
572
|
+
): ValidationResult<{ anchor: string; selectedText: string; comment: string }> {
|
|
573
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
574
|
+
return { ok: false, error: "input must be an object" };
|
|
575
|
+
}
|
|
576
|
+
const raw = input as Record<string, unknown>;
|
|
577
|
+
|
|
578
|
+
// anchor
|
|
579
|
+
if (typeof raw["anchor"] !== "string") {
|
|
580
|
+
return { ok: false, error: "anchor must be a string" };
|
|
581
|
+
}
|
|
582
|
+
if (!ANCHOR_RE.test(raw["anchor"])) {
|
|
583
|
+
return {
|
|
584
|
+
ok: false,
|
|
585
|
+
error: `anchor must match /^block-\\d+(\\.line-\\d+)?$/, got "${raw["anchor"]}"`,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const anchor = raw["anchor"];
|
|
589
|
+
|
|
590
|
+
// selectedText — required, can be empty, ≤ 4096 chars
|
|
591
|
+
if (typeof raw["selectedText"] !== "string") {
|
|
592
|
+
return { ok: false, error: "selectedText must be a string" };
|
|
593
|
+
}
|
|
594
|
+
if (raw["selectedText"].length > 4096) {
|
|
595
|
+
return { ok: false, error: "selectedText must be 4096 characters or fewer" };
|
|
596
|
+
}
|
|
597
|
+
const selectedText = raw["selectedText"];
|
|
598
|
+
|
|
599
|
+
// comment — required, non-empty after trim, ≤ 16384 chars
|
|
600
|
+
if (typeof raw["comment"] !== "string") {
|
|
601
|
+
return { ok: false, error: "comment must be a string" };
|
|
602
|
+
}
|
|
603
|
+
if (raw["comment"].trim() === "") {
|
|
604
|
+
return { ok: false, error: "comment must be non-empty" };
|
|
605
|
+
}
|
|
606
|
+
if (raw["comment"].length > 16384) {
|
|
607
|
+
return { ok: false, error: "comment must be 16384 characters or fewer" };
|
|
608
|
+
}
|
|
609
|
+
const comment = raw["comment"];
|
|
610
|
+
|
|
611
|
+
return { ok: true, value: { anchor, selectedText, comment } };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ─── Verdict value validation ──────────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
const VERDICTS_BY_MODE: Record<VerdictMode, readonly Verdict[]> = {
|
|
617
|
+
approve: ["approve"],
|
|
618
|
+
"approve-or-reject": ["approve", "request_changes"],
|
|
619
|
+
full: ["approve", "request_changes", "comment"],
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
export function validateVerdictValue(input: unknown, mode: VerdictMode): ValidationResult<Verdict> {
|
|
623
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
624
|
+
return { ok: false, error: "input must be an object" };
|
|
625
|
+
}
|
|
626
|
+
const raw = input as Record<string, unknown>;
|
|
627
|
+
|
|
628
|
+
if (typeof raw["verdict"] !== "string") {
|
|
629
|
+
return { ok: false, error: "verdict must be a string" };
|
|
630
|
+
}
|
|
631
|
+
const v = raw["verdict"];
|
|
632
|
+
const allowed = VERDICTS_BY_MODE[mode];
|
|
633
|
+
if (!(allowed as readonly string[]).includes(v)) {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
error: `verdict "${v}" is not allowed in "${mode}" mode; allowed: ${allowed.join(", ")}`,
|
|
433
637
|
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return { ok: true, value: v as Verdict };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ─── coerceInteractiveData ─────────────────────────────────────────────────────
|
|
644
|
+
|
|
645
|
+
function isValidStatus(v: unknown): v is InteractiveData["status"] {
|
|
646
|
+
return v === "open" || v === "complete" || v === "expired" || v === "cancelled";
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function coerceAsAskData(raw: Record<string, unknown>): InteractiveAskData | null {
|
|
650
|
+
if (!isValidStatus(raw["status"])) return null;
|
|
651
|
+
if (!Array.isArray(raw["questions"])) return null;
|
|
652
|
+
if (typeof raw["requireAll"] !== "boolean") return null;
|
|
653
|
+
if (typeof raw["expiresAt"] !== "string") return null;
|
|
654
|
+
const answers =
|
|
655
|
+
raw["answers"] !== null && typeof raw["answers"] === "object" && !Array.isArray(raw["answers"])
|
|
656
|
+
? (raw["answers"] as Record<string, { value: AnswerValue; answeredAt: string }>)
|
|
657
|
+
: null;
|
|
658
|
+
if (answers === null) return null;
|
|
659
|
+
|
|
660
|
+
const result: InteractiveAskData = {
|
|
661
|
+
kind: "ask",
|
|
662
|
+
status: raw["status"],
|
|
663
|
+
requireAll: raw["requireAll"],
|
|
664
|
+
expiresAt: raw["expiresAt"],
|
|
665
|
+
questions: raw["questions"] as Question[],
|
|
666
|
+
answers,
|
|
667
|
+
};
|
|
668
|
+
if (typeof raw["completedAt"] === "string") result.completedAt = raw["completedAt"];
|
|
669
|
+
return result;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function coerceAsAnnotateData(raw: Record<string, unknown>): InteractiveAnnotateData | null {
|
|
673
|
+
if (!isValidStatus(raw["status"])) return null;
|
|
674
|
+
if (typeof raw["expiresAt"] !== "string") return null;
|
|
675
|
+
if (!isVerdictMode(raw["verdictMode"])) return null;
|
|
676
|
+
if (typeof raw["requireVerdict"] !== "boolean") return null;
|
|
677
|
+
if (!Array.isArray(raw["perLineFor"])) return null;
|
|
678
|
+
if (!Array.isArray(raw["comments"])) return null;
|
|
679
|
+
// verdict can be null or an object
|
|
680
|
+
const rawVerdict = raw["verdict"];
|
|
681
|
+
const verdict =
|
|
682
|
+
rawVerdict === null
|
|
683
|
+
? null
|
|
684
|
+
: rawVerdict !== null &&
|
|
685
|
+
typeof rawVerdict === "object" &&
|
|
686
|
+
!Array.isArray(rawVerdict) &&
|
|
687
|
+
typeof (rawVerdict as Record<string, unknown>)["value"] === "string" &&
|
|
688
|
+
typeof (rawVerdict as Record<string, unknown>)["decidedAt"] === "string"
|
|
689
|
+
? (rawVerdict as { value: Verdict; decidedAt: string })
|
|
690
|
+
: undefined;
|
|
691
|
+
if (verdict === undefined) return null;
|
|
692
|
+
|
|
693
|
+
const result: InteractiveAnnotateData = {
|
|
694
|
+
kind: "annotate",
|
|
695
|
+
status: raw["status"],
|
|
696
|
+
expiresAt: raw["expiresAt"],
|
|
697
|
+
verdictMode: raw["verdictMode"],
|
|
698
|
+
requireVerdict: raw["requireVerdict"],
|
|
699
|
+
perLineFor: raw["perLineFor"] as ("diff" | "code")[],
|
|
700
|
+
comments: raw["comments"] as Comment[],
|
|
701
|
+
verdict,
|
|
702
|
+
};
|
|
703
|
+
if (typeof raw["completedAt"] === "string") result.completedAt = raw["completedAt"];
|
|
704
|
+
return result;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Tolerant reader for embedded cesium-meta.interactive JSON.
|
|
709
|
+
*
|
|
710
|
+
* - Returns null for non-object input or unrecognized shapes.
|
|
711
|
+
* - Legacy ask artifacts (without a `kind` field) are coerced to InteractiveAskData
|
|
712
|
+
* with `kind: "ask"` injected.
|
|
713
|
+
*/
|
|
714
|
+
export function coerceInteractiveData(raw: unknown): InteractiveData | null {
|
|
715
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
716
|
+
const obj = raw as Record<string, unknown>;
|
|
717
|
+
|
|
718
|
+
if (obj["kind"] === "annotate") {
|
|
719
|
+
return coerceAsAnnotateData(obj);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (obj["kind"] === "ask" || obj["kind"] === undefined || !("kind" in obj)) {
|
|
723
|
+
return coerceAsAskData(obj);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Unknown kind
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ─── PublishInput — blocks-only ──────────────────────────────────────────────
|
|
731
|
+
|
|
732
|
+
export interface PublishInput {
|
|
733
|
+
title: string;
|
|
734
|
+
kind: PublishKind;
|
|
735
|
+
blocks: Block[];
|
|
736
|
+
summary?: string;
|
|
737
|
+
tags?: string[];
|
|
738
|
+
supersedes?: string;
|
|
739
|
+
}
|
|
434
740
|
|
|
435
741
|
function isPublishKind(val: unknown): val is PublishKind {
|
|
436
742
|
return typeof val === "string" && (PUBLISH_KINDS as readonly string[]).includes(val);
|
|
@@ -645,7 +951,7 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
|
|
|
645
951
|
return errors;
|
|
646
952
|
}
|
|
647
953
|
|
|
648
|
-
function validateBlocksArray(raw: unknown): BlockValidationResult {
|
|
954
|
+
export function validateBlocksArray(raw: unknown): BlockValidationResult {
|
|
649
955
|
if (!Array.isArray(raw)) {
|
|
650
956
|
return { ok: false, errors: [{ path: "blocks", message: "blocks must be an array" }] };
|
|
651
957
|
}
|
|
@@ -725,15 +1031,18 @@ export function validatePublishInput(input: unknown): ValidationResult<PublishIn
|
|
|
725
1031
|
}
|
|
726
1032
|
const kind = raw["kind"];
|
|
727
1033
|
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1034
|
+
// blocks — required, non-empty
|
|
1035
|
+
if (
|
|
1036
|
+
!("blocks" in raw) ||
|
|
1037
|
+
raw["blocks"] === undefined ||
|
|
1038
|
+
!Array.isArray(raw["blocks"]) ||
|
|
1039
|
+
raw["blocks"].length === 0
|
|
1040
|
+
) {
|
|
1041
|
+
return {
|
|
1042
|
+
ok: false,
|
|
1043
|
+
error:
|
|
1044
|
+
"cesium_publish requires a non-empty `blocks` array. Call `cesium_styleguide` for the block catalog.",
|
|
1045
|
+
};
|
|
737
1046
|
}
|
|
738
1047
|
|
|
739
1048
|
// summary (optional)
|
|
@@ -765,69 +1074,16 @@ export function validatePublishInput(input: unknown): ValidationResult<PublishIn
|
|
|
765
1074
|
}
|
|
766
1075
|
}
|
|
767
1076
|
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
...(Array.isArray(raw["tags"]) ? { tags: raw["tags"] as string[] } : {}),
|
|
773
|
-
...(typeof raw["supersedes"] === "string" ? { supersedes: raw["supersedes"] } : {}),
|
|
774
|
-
};
|
|
775
|
-
|
|
776
|
-
if (hasHtml) {
|
|
777
|
-
// html branch
|
|
778
|
-
if (typeof raw["html"] !== "string" || raw["html"].trim() === "") {
|
|
779
|
-
return { ok: false, error: "html is required and must be a non-empty string" };
|
|
780
|
-
}
|
|
781
|
-
return {
|
|
782
|
-
ok: true,
|
|
783
|
-
value: { ...commonFields, html: raw["html"] },
|
|
784
|
-
};
|
|
785
|
-
} else {
|
|
786
|
-
// blocks branch
|
|
787
|
-
const blocksResult = validateBlocksArray(raw["blocks"]);
|
|
788
|
-
if (!blocksResult.ok) {
|
|
789
|
-
const errorMessages = blocksResult.errors.map((e) => `${e.path}: ${e.message}`).join("; ");
|
|
790
|
-
return { ok: false, error: `blocks validation failed — ${errorMessages}` };
|
|
791
|
-
}
|
|
792
|
-
return {
|
|
793
|
-
ok: true,
|
|
794
|
-
value: { ...commonFields, blocks: blocksResult.blocks },
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
800
|
-
|
|
801
|
-
function walkNodes(nodes: ChildNode[], visitor: (node: ChildNode) => void): void {
|
|
802
|
-
for (const node of nodes) {
|
|
803
|
-
visitor(node);
|
|
804
|
-
if (ta.isElementNode(node)) {
|
|
805
|
-
walkNodes(ta.getChildNodes(node as Element) as ChildNode[], visitor);
|
|
806
|
-
}
|
|
1077
|
+
const blocksResult = validateBlocksArray(raw["blocks"]);
|
|
1078
|
+
if (!blocksResult.ok) {
|
|
1079
|
+
const errorMessages = blocksResult.errors.map((e) => `${e.path}: ${e.message}`).join("; ");
|
|
1080
|
+
return { ok: false, error: `blocks validation failed — ${errorMessages}` };
|
|
807
1081
|
}
|
|
808
|
-
}
|
|
809
1082
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
const children = ta.getChildNodes(fragment) as ChildNode[];
|
|
815
|
-
let hasHeading = false;
|
|
1083
|
+
const value: PublishInput = { title, kind, blocks: blocksResult.blocks };
|
|
1084
|
+
if (typeof raw["summary"] === "string") value.summary = raw["summary"];
|
|
1085
|
+
if (Array.isArray(raw["tags"])) value.tags = raw["tags"] as string[];
|
|
1086
|
+
if (typeof raw["supersedes"] === "string") value.supersedes = raw["supersedes"];
|
|
816
1087
|
|
|
817
|
-
|
|
818
|
-
if (ta.isElementNode(node)) {
|
|
819
|
-
const el = node as Element;
|
|
820
|
-
const tag = ta.getTagName(el);
|
|
821
|
-
if (HEADING_TAGS.has(tag)) hasHeading = true;
|
|
822
|
-
}
|
|
823
|
-
});
|
|
824
|
-
|
|
825
|
-
if (!hasHeading) {
|
|
826
|
-
warnings.push("no headings found — consider adding an <h1> or <h2>");
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
return warnings;
|
|
830
|
-
} catch {
|
|
831
|
-
return [];
|
|
832
|
-
}
|
|
1088
|
+
return { ok: true, value };
|
|
833
1089
|
}
|
package/src/render/wrap.ts
CHANGED
|
@@ -5,7 +5,13 @@ import { buildThemeCss } from "../storage/theme-write.ts";
|
|
|
5
5
|
import { renderControl, renderAnswered } from "./controls.ts";
|
|
6
6
|
import { getClientJs } from "./client-js.ts";
|
|
7
7
|
import { faviconLinkTag } from "./favicon.ts";
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
InteractiveData,
|
|
10
|
+
InteractiveAskData,
|
|
11
|
+
InteractiveAnnotateData,
|
|
12
|
+
Question,
|
|
13
|
+
VerdictMode,
|
|
14
|
+
} from "./validate.ts";
|
|
9
15
|
|
|
10
16
|
export interface ArtifactMeta {
|
|
11
17
|
id: string;
|
|
@@ -25,7 +31,6 @@ export interface ArtifactMeta {
|
|
|
25
31
|
supersedes: string | null;
|
|
26
32
|
supersededBy: string | null;
|
|
27
33
|
contentSha256: string;
|
|
28
|
-
inputMode: "html" | "blocks";
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
export interface WrapOptions {
|
|
@@ -80,7 +85,7 @@ const BACK_LINK_STYLE = "color: var(--muted); text-decoration: none;";
|
|
|
80
85
|
|
|
81
86
|
// ─── Interactive rendering ─────────────────────────────────────────────────────
|
|
82
87
|
|
|
83
|
-
function renderQuestionSection(q: Question, interactive:
|
|
88
|
+
function renderQuestionSection(q: Question, interactive: InteractiveAskData): string {
|
|
84
89
|
const answered = interactive.answers[q.id];
|
|
85
90
|
|
|
86
91
|
if (answered !== undefined) {
|
|
@@ -90,11 +95,62 @@ function renderQuestionSection(q: Question, interactive: InteractiveData): strin
|
|
|
90
95
|
return renderControl(q);
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
function renderVerdictButtons(verdictMode: VerdictMode, isOpen: boolean): string {
|
|
99
|
+
// When open, render buttons WITHOUT disabled — client JS gates them based on
|
|
100
|
+
// comment count and session status. When not open, mark all disabled.
|
|
101
|
+
const disabled = isOpen ? "" : ' disabled aria-disabled="true"';
|
|
102
|
+
const buttons: string[] = [];
|
|
103
|
+
|
|
104
|
+
buttons.push(
|
|
105
|
+
`<button type="button" class="cs-verdict-btn cs-verdict-approve" data-verdict="approve"${disabled}>Approve</button>`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (verdictMode === "approve-or-reject" || verdictMode === "full") {
|
|
109
|
+
buttons.push(
|
|
110
|
+
`<button type="button" class="cs-verdict-btn cs-verdict-request_changes" data-verdict="request_changes"${disabled}>Request changes</button>`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (verdictMode === "full") {
|
|
115
|
+
buttons.push(
|
|
116
|
+
`<button type="button" class="cs-verdict-btn cs-verdict-comment" data-verdict="comment"${disabled}>Comment</button>`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return buttons.join("\n ");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderAnnotateScaffold(interactive: InteractiveAnnotateData): string {
|
|
124
|
+
const isOpen = interactive.status === "open";
|
|
125
|
+
const verdictButtons = renderVerdictButtons(interactive.verdictMode, isOpen);
|
|
126
|
+
|
|
127
|
+
return `<section class="cs-annotate-scaffold" data-cesium-annotate-scaffold data-cesium-verdict-mode="${interactive.verdictMode}" data-cesium-status="${interactive.status}">
|
|
128
|
+
<template id="cs-annotate-comment-popup">
|
|
129
|
+
<div class="cs-comment-popup" role="dialog" aria-label="Add a comment">
|
|
130
|
+
<textarea class="cs-comment-input" placeholder="Add a comment\u2026"></textarea>
|
|
131
|
+
<div class="cs-comment-actions">
|
|
132
|
+
<button type="button" class="cs-comment-save" disabled>Save</button>
|
|
133
|
+
<button type="button" class="cs-comment-cancel">Cancel</button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
137
|
+
<aside class="cs-comment-rail" data-cesium-comment-rail aria-label="Review comments"></aside>
|
|
138
|
+
<footer class="cs-verdict-footer">
|
|
139
|
+
<span class="cs-comment-count" data-cesium-comment-count>0 comments</span>
|
|
140
|
+
${verdictButtons}
|
|
141
|
+
</footer>
|
|
142
|
+
</section>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
93
145
|
function renderInteractive(interactive: InteractiveData): string {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
if (interactive.kind === "ask") {
|
|
147
|
+
const sections = interactive.questions
|
|
148
|
+
.map((q) => renderQuestionSection(q, interactive))
|
|
149
|
+
.join("\n");
|
|
150
|
+
return `\n<section class="cs-questions">\n${sections}\n</section>`;
|
|
151
|
+
}
|
|
152
|
+
// interactive.kind === "annotate"
|
|
153
|
+
return `\n${renderAnnotateScaffold(interactive)}`;
|
|
98
154
|
}
|
|
99
155
|
|
|
100
156
|
function renderBackNav(meta: ArtifactMeta): string {
|
|
@@ -148,8 +204,10 @@ export function wrapDocument(opts: WrapOptions): string {
|
|
|
148
204
|
// cascade order — so theme upgrades retroactively apply to served artifacts
|
|
149
205
|
// while standalone copies retain their generation-time look.
|
|
150
206
|
const themeCss = buildThemeCss(theme);
|
|
151
|
-
// Embed interactive into the cesium-meta JSON block when present
|
|
152
|
-
|
|
207
|
+
// Embed interactive into the cesium-meta JSON block when present.
|
|
208
|
+
// inputMode is frozen at "blocks" on-disk for stability; the field is kept
|
|
209
|
+
// on emitted metadata so older readers/tools that look for it still see a value.
|
|
210
|
+
const metaPayload: Record<string, unknown> = { ...meta, inputMode: "blocks" };
|
|
153
211
|
if (interactive !== undefined) {
|
|
154
212
|
metaPayload["interactive"] = interactive;
|
|
155
213
|
}
|