@cfbender/cesium 0.6.2 → 0.7.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/index.ts +2 -0
  5. package/src/prompt/system-fragment.md +68 -8
  6. package/src/render/annotate-frozen.ts +90 -0
  7. package/src/render/blocks/render.ts +20 -0
  8. package/src/render/blocks/renderers/callout.ts +3 -2
  9. package/src/render/blocks/renderers/code.ts +17 -2
  10. package/src/render/blocks/renderers/compare-table.ts +3 -2
  11. package/src/render/blocks/renderers/diagram.ts +3 -2
  12. package/src/render/blocks/renderers/diff.ts +23 -9
  13. package/src/render/blocks/renderers/hero.ts +3 -2
  14. package/src/render/blocks/renderers/kv.ts +3 -2
  15. package/src/render/blocks/renderers/list.ts +5 -4
  16. package/src/render/blocks/renderers/pill-row.ts +3 -2
  17. package/src/render/blocks/renderers/prose.ts +8 -2
  18. package/src/render/blocks/renderers/raw-html.ts +8 -2
  19. package/src/render/blocks/renderers/risk-table.ts +3 -2
  20. package/src/render/blocks/renderers/section.ts +4 -2
  21. package/src/render/blocks/renderers/timeline.ts +3 -2
  22. package/src/render/blocks/renderers/tldr.ts +3 -2
  23. package/src/render/client-js.ts +803 -6
  24. package/src/render/critique.ts +5 -335
  25. package/src/render/theme.ts +455 -6
  26. package/src/render/validate.ts +353 -97
  27. package/src/render/wrap.ts +67 -9
  28. package/src/server/api.ts +162 -3
  29. package/src/storage/index-gen.ts +4 -2
  30. package/src/storage/mutate.ts +433 -27
  31. package/src/tools/annotate.ts +336 -0
  32. package/src/tools/ask.ts +2 -6
  33. package/src/tools/critique.ts +15 -45
  34. package/src/tools/publish.ts +16 -56
  35. package/src/tools/styleguide.ts +7 -1
  36. package/src/tools/wait.ts +77 -24
@@ -5,6 +5,8 @@
5
5
  //
6
6
  // getState: reads the artifact and returns its current status/answers without
7
7
  // acquiring a lock (read-only, no mutation).
8
+ //
9
+ // addComment / removeComment / setVerdict: annotate-specific mutators.
8
10
 
9
11
  import { readFile } from "node:fs/promises";
10
12
  import { parseFragment, serialize, defaultTreeAdapter as ta } from "parse5";
@@ -12,8 +14,23 @@ import type { DefaultTreeAdapterTypes } from "parse5";
12
14
  import { atomicWrite } from "./write.ts";
13
15
  import { withLock } from "./lock.ts";
14
16
  import { renderAnswered } from "../render/controls.ts";
15
- import { validateAnswerValue } from "../render/validate.ts";
16
- import type { Question, AnswerValue, InteractiveData } from "../render/validate.ts";
17
+ import { renderFrozenRail, renderVerdictPill } from "../render/annotate-frozen.ts";
18
+ import {
19
+ validateAnswerValue,
20
+ validateCommentValue,
21
+ validateVerdictValue,
22
+ coerceInteractiveData,
23
+ } from "../render/validate.ts";
24
+ import type {
25
+ Question,
26
+ AnswerValue,
27
+ Comment,
28
+ Verdict,
29
+ VerdictMode,
30
+ InteractiveData,
31
+ InteractiveAskData,
32
+ InteractiveAnnotateData,
33
+ } from "../render/validate.ts";
17
34
 
18
35
  type ChildNode = DefaultTreeAdapterTypes.ChildNode;
19
36
  type Element = DefaultTreeAdapterTypes.Element;
@@ -35,13 +52,63 @@ export type SubmitAnswerOutcome =
35
52
  | { ok: false; reason: "unknown-question"; questionId: string }
36
53
  | { ok: false; reason: "invalid-value"; message: string };
37
54
 
55
+ export type AddCommentInput = {
56
+ artifactPath: string;
57
+ anchor: string;
58
+ selectedText: string;
59
+ comment: string;
60
+ };
61
+
62
+ export type AddCommentOutcome =
63
+ | { ok: true; comment: Comment }
64
+ | { ok: false; reason: "not-found" }
65
+ | { ok: false; reason: "not-interactive" }
66
+ | { ok: false; reason: "session-ended"; status: "complete" | "expired" | "cancelled" }
67
+ | { ok: false; reason: "expired" }
68
+ | { ok: false; reason: "invalid-value"; message: string };
69
+
70
+ export type RemoveCommentInput = {
71
+ artifactPath: string;
72
+ commentId: string;
73
+ };
74
+
75
+ export type RemoveCommentOutcome =
76
+ | { ok: true }
77
+ | { ok: false; reason: "not-found" }
78
+ | { ok: false; reason: "not-interactive" }
79
+ | { ok: false; reason: "comment-not-found" }
80
+ | { ok: false; reason: "session-ended"; status: "complete" | "expired" | "cancelled" }
81
+ | { ok: false; reason: "expired" };
82
+
83
+ export type SetVerdictInput = {
84
+ artifactPath: string;
85
+ verdict: Verdict;
86
+ };
87
+
88
+ export type SetVerdictOutcome =
89
+ | { ok: true; status: "complete"; verdict: { value: Verdict; decidedAt: string } }
90
+ | { ok: false; reason: "not-found" }
91
+ | { ok: false; reason: "not-interactive" }
92
+ | { ok: false; reason: "session-ended"; status: "complete" | "expired" | "cancelled" }
93
+ | { ok: false; reason: "expired" }
94
+ | { ok: false; reason: "invalid-value"; message: string };
95
+
38
96
  export type StateOutcome =
39
97
  | {
40
98
  ok: true;
41
- status: InteractiveData["status"];
99
+ kind: "ask";
100
+ status: InteractiveAskData["status"];
42
101
  answers: Record<string, AnswerValue>;
43
102
  remaining: string[];
44
103
  }
104
+ | {
105
+ ok: true;
106
+ kind: "annotate";
107
+ status: InteractiveAnnotateData["status"];
108
+ comments: Comment[];
109
+ verdict: InteractiveAnnotateData["verdict"];
110
+ verdictMode: VerdictMode;
111
+ }
45
112
  | { ok: false; reason: "not-found" | "not-interactive" };
46
113
 
47
114
  // ─── Embedded metadata regex (mirrors storage/write.ts) ──────────────────────
@@ -63,18 +130,6 @@ function parseEmbeddedMeta(html: string): Record<string, unknown> | null {
63
130
  }
64
131
  }
65
132
 
66
- function isInteractiveData(v: unknown): v is InteractiveData {
67
- if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
68
- const raw = v as Record<string, unknown>;
69
- return (
70
- (raw["status"] === "open" ||
71
- raw["status"] === "complete" ||
72
- raw["status"] === "expired" ||
73
- raw["status"] === "cancelled") &&
74
- Array.isArray(raw["questions"])
75
- );
76
- }
77
-
78
133
  // ─── Cross-validation beyond structural check ─────────────────────────────────
79
134
 
80
135
  const EPSILON = 1e-9;
@@ -272,11 +327,17 @@ export async function submitAnswer(input: SubmitAnswerInput): Promise<SubmitAnsw
272
327
 
273
328
  // 2. Parse cesium-meta
274
329
  const meta = parseEmbeddedMeta(html);
275
- if (meta === null || !isInteractiveData(meta["interactive"])) {
330
+ const coerced = coerceInteractiveData(meta === null ? null : meta["interactive"]);
331
+ if (coerced === null) {
276
332
  return { ok: false, reason: "not-interactive" };
277
333
  }
278
334
 
279
- const interactive = meta["interactive"] as InteractiveData;
335
+ // submitAnswer only handles ask artifacts; annotate uses addComment/setVerdict
336
+ if (coerced.kind !== "ask") {
337
+ return { ok: false, reason: "not-interactive" };
338
+ }
339
+
340
+ const interactive: InteractiveAskData = coerced;
280
341
 
281
342
  // 3. Check session status
282
343
  if (interactive.status === "complete" || interactive.status === "cancelled") {
@@ -376,21 +437,366 @@ export async function getState(artifactPath: string): Promise<StateOutcome> {
376
437
  }
377
438
 
378
439
  const meta = parseEmbeddedMeta(html);
379
- if (meta === null || !isInteractiveData(meta["interactive"])) {
440
+ const coerced = coerceInteractiveData(meta === null ? null : meta["interactive"]);
441
+ if (coerced === null) {
380
442
  return { ok: false, reason: "not-interactive" };
381
443
  }
382
444
 
383
- const interactive = meta["interactive"] as InteractiveData;
445
+ if (coerced.kind === "ask") {
446
+ const interactive: InteractiveAskData = coerced;
447
+
448
+ // Extract answer values (drop the answeredAt wrapper)
449
+ const answers: Record<string, AnswerValue> = {};
450
+ for (const [id, entry] of Object.entries(interactive.answers)) {
451
+ answers[id] = entry.value;
452
+ }
453
+
454
+ const remaining = interactive.questions
455
+ .map((q) => q.id)
456
+ .filter((id) => interactive.answers[id] === undefined);
457
+
458
+ return { ok: true, kind: "ask", status: interactive.status, answers, remaining };
459
+ }
460
+
461
+ // kind === "annotate"
462
+ const interactive: InteractiveAnnotateData = coerced;
463
+ return {
464
+ ok: true,
465
+ kind: "annotate",
466
+ status: interactive.status,
467
+ comments: interactive.comments,
468
+ verdict: interactive.verdict,
469
+ verdictMode: interactive.verdictMode,
470
+ };
471
+ }
472
+
473
+ // ─── nanoid ──────────────────────────────────────────────────────────────────
474
+
475
+ function defaultNanoid(): string {
476
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
477
+ const bytes = new Uint8Array(6);
478
+ crypto.getRandomValues(bytes);
479
+ let result = "";
480
+ for (const byte of bytes) {
481
+ result += alphabet[byte % alphabet.length];
482
+ }
483
+ return result;
484
+ }
485
+
486
+ // ─── Shared annotate-session guard ───────────────────────────────────────────
487
+ //
488
+ // Reads, parses, and validates an annotate artifact under an already-held lock.
489
+ // Returns either the coerced data + raw html, or an early-return outcome.
490
+
491
+ type AnnotateReadResult =
492
+ | { ok: true; html: string; interactive: InteractiveAnnotateData }
493
+ | { ok: false; outcome: AddCommentOutcome | RemoveCommentOutcome | SetVerdictOutcome };
494
+
495
+ async function readAnnotateArtifact(artifactPath: string): Promise<AnnotateReadResult> {
496
+ let html: string;
497
+ try {
498
+ html = await readFile(artifactPath, "utf8");
499
+ } catch (err) {
500
+ const e = err as NodeJS.ErrnoException;
501
+ if (e.code === "ENOENT") return { ok: false, outcome: { ok: false, reason: "not-found" } };
502
+ throw err;
503
+ }
504
+
505
+ const meta = parseEmbeddedMeta(html);
506
+ const coerced = coerceInteractiveData(meta === null ? null : meta["interactive"]);
507
+ if (coerced === null || coerced.kind !== "annotate") {
508
+ return { ok: false, outcome: { ok: false, reason: "not-interactive" } };
509
+ }
510
+
511
+ const interactive = coerced;
384
512
 
385
- // Extract answer values (drop the answeredAt wrapper)
386
- const answers: Record<string, AnswerValue> = {};
387
- for (const [id, entry] of Object.entries(interactive.answers)) {
388
- answers[id] = entry.value;
513
+ // Session-ended check
514
+ if (interactive.status === "complete" || interactive.status === "cancelled") {
515
+ return {
516
+ ok: false,
517
+ outcome: { ok: false, reason: "session-ended", status: interactive.status },
518
+ };
519
+ }
520
+ if (interactive.status === "expired") {
521
+ return {
522
+ ok: false,
523
+ outcome: { ok: false, reason: "session-ended", status: "expired" },
524
+ };
389
525
  }
390
526
 
391
- const remaining = interactive.questions
392
- .map((q) => q.id)
393
- .filter((id) => interactive.answers[id] === undefined);
527
+ return { ok: true, html, interactive };
528
+ }
529
+
530
+ // ─── addComment ──────────────────────────────────────────────────────────────
531
+
532
+ export async function addComment(input: AddCommentInput): Promise<AddCommentOutcome> {
533
+ const { artifactPath, anchor, selectedText, comment } = input;
534
+ const lockPath = `${artifactPath}.lock`;
535
+
536
+ return withLock({ lockPath }, async () => {
537
+ const read = await readAnnotateArtifact(artifactPath);
538
+ if (!read.ok) return read.outcome as AddCommentOutcome;
539
+
540
+ let { html } = read;
541
+ const { interactive } = read;
542
+
543
+ // Check expiresAt
544
+ if (interactive.status === "open" && Date.parse(interactive.expiresAt) < Date.now()) {
545
+ interactive.status = "expired";
546
+ html = patchMetaInHtml(html, interactive);
547
+ await atomicWrite(artifactPath, html);
548
+ return { ok: false, reason: "expired" };
549
+ }
550
+
551
+ // Validate comment value
552
+ const validated = validateCommentValue({ anchor, selectedText, comment });
553
+ if (!validated.ok) {
554
+ return { ok: false, reason: "invalid-value", message: validated.error };
555
+ }
556
+
557
+ // Construct and append the comment
558
+ const newComment: Comment = {
559
+ id: defaultNanoid(),
560
+ anchor: validated.value.anchor,
561
+ selectedText: validated.value.selectedText,
562
+ comment: validated.value.comment,
563
+ createdAt: new Date().toISOString(),
564
+ };
565
+
566
+ interactive.comments.push(newComment);
567
+
568
+ // Patch meta JSON only — body mutation happens in Phase 5/6
569
+ html = patchMetaInHtml(html, interactive);
570
+ await atomicWrite(artifactPath, html);
571
+
572
+ return { ok: true, comment: newComment };
573
+ });
574
+ }
575
+
576
+ // ─── removeComment ────────────────────────────────────────────────────────────
577
+
578
+ export async function removeComment(input: RemoveCommentInput): Promise<RemoveCommentOutcome> {
579
+ const { artifactPath, commentId } = input;
580
+ const lockPath = `${artifactPath}.lock`;
581
+
582
+ return withLock({ lockPath }, async () => {
583
+ const read = await readAnnotateArtifact(artifactPath);
584
+ if (!read.ok) return read.outcome as RemoveCommentOutcome;
585
+
586
+ let { html } = read;
587
+ const { interactive } = read;
588
+
589
+ // Check expiresAt
590
+ if (interactive.status === "open" && Date.parse(interactive.expiresAt) < Date.now()) {
591
+ interactive.status = "expired";
592
+ html = patchMetaInHtml(html, interactive);
593
+ await atomicWrite(artifactPath, html);
594
+ return { ok: false, reason: "expired" };
595
+ }
394
596
 
395
- return { ok: true, status: interactive.status, answers, remaining };
597
+ // Find the comment
598
+ const idx = interactive.comments.findIndex((c) => c.id === commentId);
599
+ if (idx === -1) {
600
+ return { ok: false, reason: "comment-not-found" };
601
+ }
602
+
603
+ // Remove it
604
+ interactive.comments = interactive.comments.filter((c) => c.id !== commentId);
605
+
606
+ html = patchMetaInHtml(html, interactive);
607
+ await atomicWrite(artifactPath, html);
608
+
609
+ return { ok: true };
610
+ });
611
+ }
612
+
613
+ // ─── setVerdict ───────────────────────────────────────────────────────────────
614
+
615
+ function setAnnotateScaffoldStatus(html: string, status: string): string {
616
+ // Use parse5 to find <section class="cs-annotate-scaffold"> and set
617
+ // data-cesium-status="<status>". Defensive: if absent, return html unchanged.
618
+ const doc = parseFragment(html);
619
+ const nodes = ta.getChildNodes(doc) as ChildNode[];
620
+
621
+ function findScaffold(children: ChildNode[]): Element | null {
622
+ for (const node of children) {
623
+ if (ta.isElementNode(node)) {
624
+ const el = node as Element;
625
+ const tag = ta.getTagName(el);
626
+ if (tag === "section") {
627
+ const attrs = ta.getAttrList(el);
628
+ const hasScaffold = attrs.some((a) => a.name === "data-cesium-annotate-scaffold");
629
+ if (hasScaffold) return el;
630
+ }
631
+ const found = findScaffold(ta.getChildNodes(el) as ChildNode[]);
632
+ if (found !== null) return found;
633
+ }
634
+ }
635
+ return null;
636
+ }
637
+
638
+ const scaffold = findScaffold(nodes);
639
+ if (scaffold === null) return html; // defensive: absent → no-op
640
+
641
+ const attrs = ta.getAttrList(scaffold);
642
+ const existing = attrs.find((a) => a.name === "data-cesium-status");
643
+ if (existing !== undefined) {
644
+ existing.value = status;
645
+ } else {
646
+ attrs.push({ name: "data-cesium-status", value: status });
647
+ }
648
+
649
+ return serialize(doc);
650
+ }
651
+
652
+ // ─── Rail replacement ─────────────────────────────────────────────────────────
653
+ //
654
+ // Replaces <aside class="cs-comment-rail" data-cesium-comment-rail></aside>
655
+ // with the fully-populated frozen rail via parse5.
656
+
657
+ function replaceEmptyRailInHtml(html: string, comments: Comment[]): string {
658
+ const doc = parseFragment(html);
659
+ const nodes = ta.getChildNodes(doc) as ChildNode[];
660
+
661
+ function findEmptyRail(children: ChildNode[]): Element | null {
662
+ for (const node of children) {
663
+ if (!ta.isElementNode(node)) continue;
664
+ const el = node as Element;
665
+ const tag = ta.getTagName(el);
666
+ if (tag === "aside") {
667
+ const attrs = ta.getAttrList(el);
668
+ const hasRailAttr = attrs.some((a) => a.name === "data-cesium-comment-rail");
669
+ if (hasRailAttr) return el;
670
+ }
671
+ const found = findEmptyRail(ta.getChildNodes(el) as ChildNode[]);
672
+ if (found !== null) return found;
673
+ }
674
+ return null;
675
+ }
676
+
677
+ const target = findEmptyRail(nodes);
678
+ if (target === null) return html;
679
+
680
+ const parent = ta.getParentNode(target) as Element | null;
681
+ if (parent === null) return html;
682
+
683
+ const replacement = parseFragment(renderFrozenRail(comments));
684
+ const replacementNodes = ta.getChildNodes(replacement) as ChildNode[];
685
+
686
+ for (const rn of replacementNodes) {
687
+ ta.insertBefore(parent, rn, target);
688
+ }
689
+ ta.detachNode(target);
690
+
691
+ return serialize(doc);
692
+ }
693
+
694
+ // ─── Verdict pill insertion ───────────────────────────────────────────────────
695
+ //
696
+ // Inserts the verdict pill immediately after <nav class="cesium-back"> via parse5.
697
+
698
+ function insertVerdictPillAfterBackNav(
699
+ html: string,
700
+ verdict: { value: Verdict; decidedAt: string },
701
+ ): string {
702
+ const doc = parseFragment(html);
703
+ const nodes = ta.getChildNodes(doc) as ChildNode[];
704
+
705
+ function findBackNav(children: ChildNode[]): Element | null {
706
+ for (const node of children) {
707
+ if (!ta.isElementNode(node)) continue;
708
+ const el = node as Element;
709
+ const tag = ta.getTagName(el);
710
+ if (tag === "nav") {
711
+ const attrs = ta.getAttrList(el);
712
+ const cls = attrs.find((a) => a.name === "class");
713
+ if (cls?.value?.split(" ").includes("cesium-back")) return el;
714
+ }
715
+ const found = findBackNav(ta.getChildNodes(el) as ChildNode[]);
716
+ if (found !== null) return found;
717
+ }
718
+ return null;
719
+ }
720
+
721
+ const backNav = findBackNav(nodes);
722
+ if (backNav === null) return html; // defensive: absent → no-op
723
+
724
+ const parent = ta.getParentNode(backNav) as Element | null;
725
+ if (parent === null) return html;
726
+
727
+ const pillFragment = parseFragment(renderVerdictPill(verdict));
728
+ const pillNodes = ta.getChildNodes(pillFragment) as ChildNode[];
729
+
730
+ // Find the next sibling after backNav to insert before it
731
+ const siblings = ta.getChildNodes(parent) as ChildNode[];
732
+ const backNavIdx = siblings.indexOf(backNav);
733
+ const nextSibling = backNavIdx !== -1 ? siblings[backNavIdx + 1] : undefined;
734
+
735
+ for (const pn of pillNodes) {
736
+ if (nextSibling !== undefined) {
737
+ ta.insertBefore(parent, pn, nextSibling);
738
+ } else {
739
+ ta.appendChild(parent, pn);
740
+ }
741
+ }
742
+
743
+ return serialize(doc);
744
+ }
745
+
746
+ export async function setVerdict(input: SetVerdictInput): Promise<SetVerdictOutcome> {
747
+ const { artifactPath, verdict } = input;
748
+ const lockPath = `${artifactPath}.lock`;
749
+
750
+ return withLock({ lockPath }, async () => {
751
+ const read = await readAnnotateArtifact(artifactPath);
752
+ if (!read.ok) return read.outcome as SetVerdictOutcome;
753
+
754
+ let { html } = read;
755
+ const { interactive } = read;
756
+
757
+ // Check expiresAt
758
+ if (interactive.status === "open" && Date.parse(interactive.expiresAt) < Date.now()) {
759
+ interactive.status = "expired";
760
+ html = patchMetaInHtml(html, interactive);
761
+ await atomicWrite(artifactPath, html);
762
+ return { ok: false, reason: "expired" };
763
+ }
764
+
765
+ // Validate verdict against the artifact's verdictMode
766
+ const validated = validateVerdictValue({ verdict }, interactive.verdictMode);
767
+ if (!validated.ok) {
768
+ return { ok: false, reason: "invalid-value", message: validated.error };
769
+ }
770
+
771
+ const decidedAt = new Date().toISOString();
772
+ const completedAt = new Date().toISOString();
773
+
774
+ // Update interactive data
775
+ interactive.verdict = { value: validated.value, decidedAt };
776
+ interactive.status = "complete";
777
+ interactive.completedAt = completedAt;
778
+
779
+ // Patch meta JSON
780
+ html = patchMetaInHtml(html, interactive);
781
+
782
+ // NOTE: the client script is intentionally kept — the frozen-mode dispatch
783
+ // in wireAnnotate still needs it for positionBubbles() and wireHoverLinking().
784
+
785
+ // Replace the empty rail with the populated frozen rail
786
+ html = replaceEmptyRailInHtml(html, interactive.comments);
787
+
788
+ // Insert the verdict pill immediately after <nav class="cesium-back">
789
+ html = insertVerdictPillAfterBackNav(html, { value: validated.value, decidedAt });
790
+
791
+ // Mark scaffold as complete via parse5
792
+ html = setAnnotateScaffoldStatus(html, "complete");
793
+
794
+ await atomicWrite(artifactPath, html);
795
+
796
+ return {
797
+ ok: true,
798
+ status: "complete",
799
+ verdict: { value: validated.value, decidedAt },
800
+ };
801
+ });
396
802
  }