@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +100 -1
  2. package/package.json +1 -1
  3. package/src/index.ts +2 -0
  4. package/src/prompt/system-fragment.md +73 -8
  5. package/src/render/annotate-frozen.ts +90 -0
  6. package/src/render/blocks/render.ts +20 -0
  7. package/src/render/blocks/renderers/callout.ts +3 -2
  8. package/src/render/blocks/renderers/code.ts +17 -2
  9. package/src/render/blocks/renderers/compare-table.ts +3 -2
  10. package/src/render/blocks/renderers/diagram.ts +3 -2
  11. package/src/render/blocks/renderers/diff.ts +23 -9
  12. package/src/render/blocks/renderers/hero.ts +3 -2
  13. package/src/render/blocks/renderers/kv.ts +3 -2
  14. package/src/render/blocks/renderers/list.ts +5 -4
  15. package/src/render/blocks/renderers/pill-row.ts +3 -2
  16. package/src/render/blocks/renderers/prose.ts +8 -2
  17. package/src/render/blocks/renderers/raw-html.ts +8 -2
  18. package/src/render/blocks/renderers/risk-table.ts +3 -2
  19. package/src/render/blocks/renderers/section.ts +4 -2
  20. package/src/render/blocks/renderers/timeline.ts +3 -2
  21. package/src/render/blocks/renderers/tldr.ts +3 -2
  22. package/src/render/client-js.ts +804 -6
  23. package/src/render/critique.ts +5 -335
  24. package/src/render/theme.ts +431 -6
  25. package/src/render/validate.ts +353 -97
  26. package/src/render/wrap.ts +67 -9
  27. package/src/server/api.ts +162 -3
  28. package/src/storage/index-gen.ts +4 -2
  29. package/src/storage/mutate.ts +433 -27
  30. package/src/tools/annotate.ts +336 -0
  31. package/src/tools/ask.ts +2 -6
  32. package/src/tools/critique.ts +15 -45
  33. package/src/tools/publish.ts +16 -56
  34. package/src/tools/styleguide.ts +7 -1
  35. package/src/tools/wait.ts +77 -24
package/CHANGELOG.md CHANGED
@@ -1,5 +1,104 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.7.0 — 2026-05-13
4
+
5
+ Headlining feature: a new `cesium_annotate` tool that publishes
6
+ PR-style review artifacts. The user opens the URL, leaves per-line and
7
+ per-block comments — by clicking the "Comment" button on any block, by
8
+ highlighting text to summon a floating menu, or by clicking the gutter
9
+ affordance on any diff or code line — and submits a final verdict:
10
+ Approve, Request changes, or Comment. The agent calls `cesium_wait` and
11
+ receives the structured feedback back. Use it whenever you'd otherwise
12
+ ask the user to read a substantial proposal and paste line references
13
+ into chat — diffs, plans, PRDs, code proposals, RFCs, audits, design docs.
14
+
15
+ The companion breaking change: `cesium_publish` no longer accepts an
16
+ `html` field. Block input is the only mode. The catalog of 16 block
17
+ types covers every shape published artifacts use in practice, and the
18
+ html escape valve was attracting traffic that should have been
19
+ block-rendered. The `raw_html` block remains for genuinely bespoke
20
+ regions inside an otherwise-structured document.
21
+
22
+ - **feat:** New `cesium_annotate` tool. Args: `title`, `blocks`,
23
+ `verdictMode` (`"approve"` | `"approve-or-reject"` | `"full"`),
24
+ `perLineFor` (default `["diff", "code"]`), `requireVerdict`, plus the
25
+ usual `summary` / `tags` / `expiresAt`. Returns the same
26
+ `{ id, httpUrl, terminalSummary, … }` shape as `cesium_ask`.
27
+ - **feat:** `cesium_wait` now surfaces `comments` and `verdict` fields
28
+ (plus a `kind: "annotate"` discriminator) when polling an annotate
29
+ session. Existing ask sessions still return `answers` / `remaining`
30
+ exactly as before.
31
+ - **feat:** Render-time `data-cesium-anchor` attributes on every
32
+ annotatable block (`block-N`) and on each line of `diff` and `code`
33
+ blocks (`block-N.line-M`). Anchor IDs are stable and validated against
34
+ `/^block-\d+(\.line-\d+)?$/` on both ends of the wire.
35
+ - **feat:** Three new API routes — `POST /comments`, `DELETE
36
+ /comments/:id`, `POST /verdict` — alongside the existing answer/state
37
+ routes. Per-artifact file locking serializes concurrent writes; the
38
+ embedded `cesium-meta` JSON remains the source of truth.
39
+ - **feat:** Client UI with always-visible "Comment" buttons in the
40
+ top-right of every annotatable block, hover-revealed gutter
41
+ affordances on diff and code lines (no layout shift), a floating
42
+ selection menu that captures highlighted text as comment context, and
43
+ a sticky verdict footer with comment-count gating.
44
+ - **feat:** Frozen post-verdict rendering. After the user submits a
45
+ verdict, the artifact is rewritten with a verdict pill near the title,
46
+ comment bubbles populated server-side at their anchor positions, and
47
+ the interactive scaffold suppressed via CSS. The client script is
48
+ retained — its sole post-verdict job is bubble positioning and
49
+ bidirectional hover linking.
50
+ - **feat:** `cesium_annotate` artifacts get their own top-level
51
+ `kind: "annotate"` value, so the index UI and filter chips treat them
52
+ as a distinct class.
53
+ - **feat:** Two new reference fixtures —
54
+ `examples/annotate-pr-review.html` (open session) and
55
+ `examples/annotate-pr-review-closed.html` (frozen post-verdict). Both
56
+ open standalone via `file://` and demonstrate the full surface.
57
+ - **feat:** Project and global index eyebrows are now clickable links to
58
+ the parent index — a small navigation nicety that closes the breadcrumb
59
+ loop.
60
+ - **breaking:** `cesium_publish` no longer accepts `html`. Use `blocks`,
61
+ with `raw_html` for genuinely bespoke regions. The validator rejects
62
+ `html` input with a clear error message. Artifacts already on disk are
63
+ unaffected — they continue to render exactly as before.
64
+ - **breaking:** The `inputMode` field on `ArtifactMeta` is removed. All
65
+ new artifacts are blocks-based by definition; the discriminator no
66
+ longer carries information.
67
+ - **internal:** New `InteractiveAnnotateData` discriminated variant
68
+ alongside `InteractiveAskData`, gated by a `kind` field. A tolerant
69
+ reader (`coerceInteractiveData`) treats existing ask-only artifacts
70
+ without a `kind` field as `kind: "ask"`, so no on-disk rewrite is
71
+ required.
72
+ - **internal:** Prompt fragment grew a routing-rules section so agents
73
+ consistently reach for `cesium_annotate` when reviewing generated
74
+ content rather than asking for chat-based feedback.
75
+ - **tests:** ~200 new tests across schema, anchor stamping, tool
76
+ handler, mutate, API, client JS, frozen rendering, and a full
77
+ publish → comment → delete → verdict → wait end-to-end lifecycle.
78
+ Total suite is now 1530+ tests.
79
+
80
+ Old `cesium_ask` artifacts on disk are not affected by any of this — the
81
+ discriminator was added with a tolerant reader specifically for backward
82
+ compatibility. The annotate flow lives entirely on the new code paths.
83
+
84
+ ## v0.6.2 — 2026-05-13
85
+
86
+ Prompt fix. The `diff` block was missing from the agent-facing quick reference
87
+ in `system-fragment.md` — only fifteen of the sixteen catalog blocks were
88
+ surfaced — so agents would reach for `code` blocks with hand-rolled `+`/`-`
89
+ lines when asked to walk through a diff or release. The `diff` block is now
90
+ listed alongside `code`/`timeline`, and a short trigger note pairs "release
91
+ walkthrough / version diff / before-after / refactor proposal" with the `diff`
92
+ block explicitly.
93
+
94
+ - **fix:** Add `diff` to the prompt's quick block reference.
95
+ - **fix:** Add "When showing code changes" trigger paragraph.
96
+ - **chore:** Bump the block-type enumeration tests from 15 → 16 to keep the
97
+ catalog and the human-curated reference in lockstep.
98
+
99
+ No runtime or rendering changes; the `diff` block itself was already wired
100
+ end-to-end since v0.4.
101
+
3
102
  ## v0.6.1 — 2026-05-13
4
103
 
5
104
  Fixes a regression introduced by the v0.4 blocks refactor: the README's
@@ -68,7 +167,7 @@ continue rendering unchanged.
68
167
  - Restructure `src/server/api.ts` regex matching to remove four
69
168
  `!` non-null assertions.
70
169
  - Replace `match![1]!` patterns in tests with a local `unwrap(value,
71
- name)` helper for proper type narrowing.
170
+ name)` helper for proper type narrowing.
72
171
  - Replace `handle!.url` in tests with an explicit null check.
73
172
  - Add targeted `eslint-disable-next-line no-await-in-loop` comments
74
173
  (with `--` reason annotations matching the existing repo convention)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfbender/cesium",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Beautiful self-contained HTML artifacts from your opencode agent.",
5
5
  "keywords": [
6
6
  "agent",
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import type { Plugin, Hooks } from "@opencode-ai/plugin";
7
7
  import { createPublishTool } from "./tools/publish.ts";
8
8
  import { createAskTool } from "./tools/ask.ts";
9
+ import { createAnnotateTool } from "./tools/annotate.ts";
9
10
  import { createWaitTool } from "./tools/wait.ts";
10
11
  import { createStyleguideTool } from "./tools/styleguide.ts";
11
12
  import { createCritiqueTool } from "./tools/critique.ts";
@@ -27,6 +28,7 @@ export const CesiumPlugin: Plugin = async (ctx): Promise<Hooks> => {
27
28
  tool: {
28
29
  cesium_publish: createPublishTool(ctx),
29
30
  cesium_ask: createAskTool(ctx),
31
+ cesium_annotate: createAnnotateTool(ctx),
30
32
  cesium_wait: createWaitTool(ctx),
31
33
  cesium_styleguide: createStyleguideTool(ctx),
32
34
  cesium_critique: createCritiqueTool(ctx),
@@ -2,20 +2,21 @@
2
2
 
3
3
  Cesium publishes beautiful self-contained artifacts to a local server you can open in a browser.
4
4
 
5
- You have access to six tools:
5
+ You have access to seven tools:
6
6
 
7
7
  - `cesium_publish` — write a substantive response as a self-contained HTML document
8
8
  - `cesium_ask` — publish an interactive Q&A artifact; returns `{ id, httpUrl, ... }`
9
- - `cesium_wait` — block until the user completes a `cesium_ask` artifact (polls disk)
9
+ - `cesium_annotate` — publish a review artifact so the user can comment on the content and give a verdict
10
+ - `cesium_wait` — block until the user completes a `cesium_ask` or `cesium_annotate` artifact
10
11
  - `cesium_styleguide` — fetch the full block reference (call before writing anything complex)
11
12
  - `cesium_critique` — analyze a draft artifact; returns a 0-100 score and findings
12
13
  - `cesium_stop` — stop the running cesium HTTP server
13
14
 
14
- ## Two input modes
15
+ ## Describing content with blocks
15
16
 
16
- `cesium_publish` accepts either `blocks: Block[]` (preferred) or `html: string` (escape valve). Provide exactly one.
17
+ `cesium_publish` takes a `blocks: Block[]` array a closed set of typed building blocks (hero, tldr, section, prose, list, callout, code, diff, timeline, compare_table, risk_table, kv, pill_row, divider, diagram, raw_html). Blocks are token-efficient, server-templated, and machine-checkable. Call `cesium_styleguide` for the full block catalog with schemas and rendered examples.
17
18
 
18
- **Prefer `blocks`** for plans, reviews, reports, explainers, comparisons, audits, design docs. Blocks are token-efficient (no structural boilerplate), server-templated, and machine-checkable. Use `html` only when the whole document needs bespoke art-direction. For isolated bespoke regions, use `raw_html` or `diagram` blocks.
19
+ For content that doesn't fit any typed block, use `raw_html` (an escape hatch for free-form HTML) or `diagram` (for inline SVG/HTML visualizations).
19
20
 
20
21
  ### Example
21
22
 
@@ -78,11 +79,16 @@ Call `cesium_styleguide` for full schemas and rendered examples.
78
79
  - `prose` — free-form markdown; `list` — bullet/numbered/checklist
79
80
  - `callout` — aside with variant: note/warn/risk; `divider` — rule
80
81
  - `code` — fenced code with lang; `timeline` — milestone list
82
+ - `diff` — side-by-side before/after code with bezier connectors
81
83
  - `compare_table` — comparison grid; `risk_table` — risk grid
82
84
  - `kv` — key-value pairs; `pill_row` — pill/tag chips
83
85
  - `diagram` — SVG/HTML visual (scrubbed)
84
86
  - `raw_html` — custom HTML escape hatch (scrubbed; add `purpose`)
85
87
 
88
+ ## When showing code changes
89
+
90
+ Showing what changed — release walkthroughs, version diffs, refactor proposals, before/after — use the `diff` block, not a `code` block with hand-rolled `+`/`-` lines. Pass either `patch` (unified diff) or both `before` and `after`. One `diff` block per file or hunk.
91
+
86
92
  {{BLOCK_FIELD_REFERENCE}}
87
93
 
88
94
  ## When to use raw_html / diagram
@@ -96,15 +102,33 @@ Publish when: ≥ 400 words; comparison/matrix/plan/PRD/RFC; code review with >3
96
102
 
97
103
  User overrides: "/cesium" or "publish this" → publish; "in terminal" → don't.
98
104
 
105
+ ## Choosing between cesium_publish, cesium_ask, and cesium_annotate
106
+
107
+ Three tools, three different shapes of conversation:
108
+
109
+ - **`cesium_publish`** — one-way broadcast. Use when delivering a finished artifact the user is likely to re-read or share, with no structured response needed.
110
+ - **`cesium_ask`** — structured Q&A. Use when you need bounded input: a pick between N options, a numeric slider, a confirm, an approve/reject reaction on a small proposal.
111
+ - **`cesium_annotate`** — review of substantive content. Use whenever you are asking the user to review, give feedback on, or approve generated content too rich for a yes/no — diffs, plans, PRDs, code proposals, RFCs, audits, design docs. The user can leave per-line and per-block comments and a final verdict (Approve / Request changes / Comment).
112
+
113
+ Routing rules:
114
+
115
+ - "Here's a diff — does it look right?" → `cesium_annotate`.
116
+ - "Here's a plan — any concerns?" → `cesium_annotate`.
117
+ - "Approve this short thing yes/no?" → `cesium_ask` with a `react` question.
118
+ - "Pick one of these three options" → `cesium_ask` with `pick_one`.
119
+ - Just delivering a finished artifact, no feedback needed → `cesium_publish`.
120
+
121
+ Default bias for review-flavored work: prefer `cesium_annotate` over chat back-and-forth. Inline comments on the actual content are dramatically higher-fidelity than asking the user to paste line references into a chat reply.
122
+
99
123
  ## Self-check before publishing
100
124
 
101
- Call `cesium_critique` before `cesium_publish` on substantive artifacts. Mode is auto-detected (pass `html` or `blocks`). Act on warn-level findings; consider suggest-level. If score < 70, revise.
125
+ Call `cesium_critique` before `cesium_publish` on substantive artifacts. Act on warn-level findings; consider suggest-level. If score < 70, revise.
102
126
 
103
127
  ## After publishing
104
128
 
105
- Print a 2-line terminal summary: `Cesium · <Title> (<kind>)` + the HTTP URL. Do not paste the full document content into the terminal.
129
+ Print a 2-line terminal summary: `Cesium · <Title> (<kind>)` + the HTTP URL. Both `cesium_ask` and `cesium_annotate` return a `terminalSummary` field — print it the same way. Do not paste the full document content into the terminal.
106
130
 
107
- ## Interactive Q&A: cesium_ask + cesium_wait
131
+ ## Interactive Q&A: cesium_ask
108
132
 
109
133
  1. `cesium_ask({ title, body, questions: [...] })` → returns `{ id, httpUrl, ... }`
110
134
  2. Print the terminalSummary so the user knows where to click.
@@ -113,6 +137,47 @@ Print a 2-line terminal summary: `Cesium · <Title> (<kind>)` + the HTTP URL. Do
113
137
 
114
138
  Question types: pick_one, pick_many, confirm, ask_text, slider, react. Set `optional: true` on an `ask_text` question to add a Skip button. Don't use cesium_ask for trivial yes/no questions — use it when the question deserves to live on disk as a decision record.
115
139
 
140
+ ## Reviewing content with cesium_annotate + cesium_wait
141
+
142
+ For PR-style reviews of substantive content (diffs, plans, PRDs, code, designs):
143
+
144
+ 1. `cesium_annotate({ title, blocks: [...] })` → returns `{ id, httpUrl, terminalSummary, ... }`. The blocks render with hover affordances on every annotatable unit (per-line on `diff`/`code`, per-block elsewhere).
145
+ 2. Print the terminalSummary so the user knows where to click.
146
+ 3. `cesium_wait({ id })` → blocks until the user submits a verdict (10-min default timeout).
147
+ 4. Read `result.kind`, `result.comments`, and `result.verdict`. Ignore `result.answers` and `result.remaining` — those are populated only for `cesium_ask` sessions.
148
+
149
+ Result shape for a completed annotate session:
150
+
151
+ ```json
152
+ {
153
+ "status": "complete",
154
+ "kind": "annotate",
155
+ "comments": [
156
+ {
157
+ "id": "...",
158
+ "anchor": "block-2.line-5",
159
+ "selectedText": "return x + 1",
160
+ "comment": "Should this be x + 1 or x - 1?",
161
+ "createdAt": "..."
162
+ }
163
+ ],
164
+ "verdict": { "value": "request_changes", "decidedAt": "..." }
165
+ }
166
+ ```
167
+
168
+ Verdict values: `approve`, `request_changes`, `comment`. `verdict` may be `null` if the session timed out before the user decided. Each comment has a unique `id` and an `anchor` — either `block-N` (a whole block) or `block-N.line-M` (a single line inside a `diff` or `code` block).
169
+
170
+ **Round-trip:** when the verdict is `request_changes`, revise the content and publish a new `cesium_annotate` with `supersedes` pointing at the prior id. The user can review the revision the same way.
171
+
172
+ **Configuration:**
173
+
174
+ - `verdictMode`: `"approve"` | `"approve-or-reject"` | `"full"` (default `"full"` — exposes all three verdict buttons).
175
+ - `perLineFor`: array of block types that get per-line anchors. Default `["diff", "code"]`.
176
+ - `requireVerdict`: if true (default), the session stays open until the user picks a verdict; otherwise the user can submit just comments.
177
+ - `expiresAt`: ISO timestamp after which the session auto-expires. Default 24 hours from publish.
178
+
179
+ Use `cesium_annotate` whenever a review with targeted comments would be higher fidelity than a chat back-and-forth.
180
+
116
181
  ## Stopping the server
117
182
 
118
183
  Call `cesium_stop` to stop or restart. The next `cesium_publish` will lazy-start a fresh server.
@@ -0,0 +1,90 @@
1
+ // Pure rendering functions for the frozen (post-verdict) annotate state.
2
+ // No I/O — only string-in, string-out.
3
+ //
4
+ // These are called from setVerdict to bake the static review into the HTML
5
+ // before the file is persisted. The client script still runs for positioning,
6
+ // but all interactive affordances are hidden by CSS.
7
+
8
+ import { escapeHtml, escapeAttr } from "./blocks/escape.ts";
9
+ import type { Comment, Verdict } from "./validate.ts";
10
+
11
+ // ─── Anchor humanization ──────────────────────────────────────────────────────
12
+ //
13
+ // Mirrors the client-side humanizeAnchor helper in client-js.ts so that
14
+ // server-rendered labels match what the client would have shown.
15
+ //
16
+ // "block-3" → "Block 3"
17
+ // "block-3.line-12" → "Block 3 · line 12"
18
+
19
+ function humanizeAnchor(anchor: string): string {
20
+ const parts = anchor.split(".");
21
+ const blockPart = parts[0] ?? "";
22
+ const blockNum = blockPart.replace("block-", "");
23
+ if (parts.length === 1) {
24
+ return `Block ${blockNum}`;
25
+ }
26
+ const linePart = parts[1] ?? "";
27
+ const lineNum = linePart.replace("line-", "");
28
+ return `Block ${blockNum} \u00b7 line ${lineNum}`;
29
+ }
30
+
31
+ // ─── renderFrozenBubble ───────────────────────────────────────────────────────
32
+
33
+ /** Renders a single read-only comment bubble (no delete button). */
34
+ export function renderFrozenBubble(comment: Comment): string {
35
+ const label = escapeHtml(humanizeAnchor(comment.anchor));
36
+ const text = escapeHtml(comment.comment);
37
+ const commentIdAttr = escapeAttr(comment.id);
38
+ const anchorAttr = escapeAttr(comment.anchor);
39
+
40
+ const quoteBlock =
41
+ comment.selectedText.trim() !== ""
42
+ ? `\n <blockquote class="cs-comment-bubble-quote">${escapeHtml(comment.selectedText)}</blockquote>`
43
+ : "";
44
+
45
+ return `<article class="cs-comment-bubble" data-comment-id="${commentIdAttr}" data-anchor="${anchorAttr}">
46
+ <header class="cs-comment-bubble-head">
47
+ <span class="cs-comment-anchor-label">${label}</span>
48
+ </header>
49
+ <p class="cs-comment-text">${text}</p>${quoteBlock}
50
+ </article>`;
51
+ }
52
+
53
+ // ─── renderFrozenRail ─────────────────────────────────────────────────────────
54
+
55
+ /** Renders the comment rail populated with all frozen bubbles. */
56
+ export function renderFrozenRail(comments: Comment[]): string {
57
+ const inner = comments.map((c) => renderFrozenBubble(c)).join("\n");
58
+ return `<aside class="cs-comment-rail" data-cesium-comment-rail aria-label="Review comments">${inner.length > 0 ? `\n${inner}\n` : ""}</aside>`;
59
+ }
60
+
61
+ // ─── renderVerdictPill ────────────────────────────────────────────────────────
62
+
63
+ const VERDICT_LABELS: Record<Verdict, string> = {
64
+ approve: "Approved",
65
+ request_changes: "Changes requested",
66
+ comment: "Reviewed",
67
+ };
68
+
69
+ /** Formats an ISO date string as "Month DD, YYYY" (e.g. "May 13, 2026"). */
70
+ function formatDecidedAt(decidedAt: string): string {
71
+ return new Date(decidedAt).toLocaleDateString("en-US", {
72
+ year: "numeric",
73
+ month: "short",
74
+ day: "numeric",
75
+ });
76
+ }
77
+
78
+ /** Renders a verdict pill to display prominently near the top of the artifact. */
79
+ export function renderVerdictPill(verdict: { value: Verdict; decidedAt: string }): string {
80
+ const label = VERDICT_LABELS[verdict.value];
81
+ const dateDisplay = formatDecidedAt(verdict.decidedAt);
82
+ const decidedAtAttr = escapeAttr(verdict.decidedAt);
83
+ const valueAttr = escapeAttr(verdict.value);
84
+
85
+ return `<aside class="cs-verdict-pill cs-verdict-pill-${verdict.value}" data-cesium-verdict="${valueAttr}">
86
+ <span class="eyebrow">Verdict</span>
87
+ <strong>${escapeHtml(label)}</strong>
88
+ <time datetime="${decidedAtAttr}">${escapeHtml(dateDisplay)}</time>
89
+ </aside>`;
90
+ }
@@ -35,6 +35,24 @@ export interface RenderCtx {
35
35
  path: string;
36
36
  /** Shiki highlight theme derived from the active cesium theme preset. */
37
37
  highlightTheme: HighlightTheme;
38
+ /**
39
+ * Anchor id for this block's outermost element, e.g. "block-3".
40
+ * Null for the divider block (no anchor) and for child blocks rendered inside a section
41
+ * (they live inside the section's anchored container).
42
+ */
43
+ anchor: string | null;
44
+ }
45
+
46
+ /**
47
+ * Returns a single attribute snippet ' data-cesium-anchor="block-N"' (with leading space),
48
+ * or empty string when ctx.anchor is null. Renderers splice this into the opening tag
49
+ * of their outermost element.
50
+ *
51
+ * The anchor value is safe by construction (matches block-\d+(\.line-\d+)?) — no escaping needed.
52
+ */
53
+ export function anchorAttr(ctx: RenderCtx): string {
54
+ if (ctx.anchor === null) return "";
55
+ return ` data-cesium-anchor="${ctx.anchor}"`;
38
56
  }
39
57
 
40
58
  function makeRootCtx(highlightTheme: HighlightTheme = "claret-dark"): RenderCtx {
@@ -43,6 +61,7 @@ function makeRootCtx(highlightTheme: HighlightTheme = "claret-dark"): RenderCtx
43
61
  depth: 0,
44
62
  path: "blocks",
45
63
  highlightTheme,
64
+ anchor: null,
46
65
  };
47
66
  }
48
67
 
@@ -97,6 +116,7 @@ export async function renderBlocks(
97
116
  const blockCtx: RenderCtx = {
98
117
  ...ctx,
99
118
  path: `blocks[${i}]`,
119
+ anchor: block.type === "divider" ? null : `block-${i}`,
100
120
  };
101
121
  // eslint-disable-next-line no-await-in-loop -- sequential render required; section counter is a shared mutable ref
102
122
  parts.push(await renderBlock(block, blockCtx));
@@ -4,16 +4,17 @@
4
4
  import type { CalloutBlock } from "../types.ts";
5
5
  import type { BlockMeta } from "../types.ts";
6
6
  import type { RenderCtx } from "../render.ts";
7
+ import { anchorAttr } from "../render.ts";
7
8
  import { escapeHtml } from "../escape.ts";
8
9
  import { renderMarkdown } from "../markdown.ts";
9
10
 
10
- export function renderCallout(block: CalloutBlock, _ctx: RenderCtx): string {
11
+ export function renderCallout(block: CalloutBlock, ctx: RenderCtx): string {
11
12
  const titleHtml =
12
13
  block.title !== undefined && block.title !== ""
13
14
  ? `<strong>${escapeHtml(block.title)}</strong> `
14
15
  : "";
15
16
  const contentHtml = renderMarkdown(block.markdown);
16
- return `<aside class="callout ${block.variant}">\n${titleHtml}${contentHtml}\n</aside>`;
17
+ return `<aside class="callout ${block.variant}"${anchorAttr(ctx)}>\n${titleHtml}${contentHtml}\n</aside>`;
17
18
  }
18
19
 
19
20
  export const meta: BlockMeta = {
@@ -4,9 +4,22 @@
4
4
  import type { CodeBlock } from "../types.ts";
5
5
  import type { BlockMeta } from "../types.ts";
6
6
  import type { RenderCtx } from "../render.ts";
7
+ import { anchorAttr } from "../render.ts";
7
8
  import { escapeHtml, escapeAttr } from "../escape.ts";
8
9
  import { highlightCode } from "../highlight.ts";
9
10
 
11
+ /**
12
+ * Inject data-cesium-anchor attributes onto each <span class="line"> in highlighted HTML.
13
+ * Counter is 1-indexed. The anchor value is safe by construction — no escaping needed.
14
+ */
15
+ function injectLineAnchors(html: string, blockAnchor: string): string {
16
+ let lineNum = 0;
17
+ return html.replace(/<span class="line">/g, () => {
18
+ lineNum++;
19
+ return `<span class="line" data-cesium-anchor="${blockAnchor}.line-${lineNum}">`;
20
+ });
21
+ }
22
+
10
23
  export async function renderCode(block: CodeBlock, ctx: RenderCtx): Promise<string> {
11
24
  const parts: string[] = [];
12
25
 
@@ -16,9 +29,11 @@ export async function renderCode(block: CodeBlock, ctx: RenderCtx): Promise<stri
16
29
  }
17
30
 
18
31
  const highlighted = await highlightCode(block.code, block.lang, ctx.highlightTheme);
19
- parts.push(` <pre><code class="lang-${escapeAttr(block.lang)}">${highlighted}</code></pre>`);
32
+ const withAnchors =
33
+ ctx.anchor !== null ? injectLineAnchors(highlighted, ctx.anchor) : highlighted;
34
+ parts.push(` <pre><code class="lang-${escapeAttr(block.lang)}">${withAnchors}</code></pre>`);
20
35
 
21
- return `<figure class="code">\n${parts.join("\n")}\n</figure>`;
36
+ return `<figure class="code"${anchorAttr(ctx)}>\n${parts.join("\n")}\n</figure>`;
22
37
  }
23
38
 
24
39
  export const meta: BlockMeta = {
@@ -4,10 +4,11 @@
4
4
  import type { CompareTableBlock } from "../types.ts";
5
5
  import type { BlockMeta } from "../types.ts";
6
6
  import type { RenderCtx } from "../render.ts";
7
+ import { anchorAttr } from "../render.ts";
7
8
  import { escapeHtml } from "../escape.ts";
8
9
  import { renderMarkdown } from "../markdown.ts";
9
10
 
10
- export function renderCompareTable(block: CompareTableBlock, _ctx: RenderCtx): string {
11
+ export function renderCompareTable(block: CompareTableBlock, ctx: RenderCtx): string {
11
12
  const headerCells = block.headers.map((h) => ` <th>${escapeHtml(h)}</th>`).join("\n");
12
13
 
13
14
  const bodyRows = block.rows
@@ -23,7 +24,7 @@ export function renderCompareTable(block: CompareTableBlock, _ctx: RenderCtx): s
23
24
  .join("\n");
24
25
 
25
26
  return (
26
- `<table class="compare-table">\n` +
27
+ `<table class="compare-table"${anchorAttr(ctx)}>\n` +
27
28
  ` <thead>\n <tr>\n${headerCells}\n </tr>\n </thead>\n` +
28
29
  ` <tbody>\n${bodyRows}\n </tbody>\n` +
29
30
  `</table>`
@@ -4,10 +4,11 @@
4
4
  import type { DiagramBlock } from "../types.ts";
5
5
  import type { BlockMeta } from "../types.ts";
6
6
  import type { RenderCtx } from "../render.ts";
7
+ import { anchorAttr } from "../render.ts";
7
8
  import { escapeHtml } from "../escape.ts";
8
9
  import { scrub } from "../../scrub.ts";
9
10
 
10
- export function renderDiagram(block: DiagramBlock, _ctx: RenderCtx): string {
11
+ export function renderDiagram(block: DiagramBlock, ctx: RenderCtx): string {
11
12
  const payload = block.svg ?? block.html ?? "";
12
13
  const scrubResult = scrub(payload);
13
14
  const scrubbed = scrubResult.html;
@@ -19,7 +20,7 @@ export function renderDiagram(block: DiagramBlock, _ctx: RenderCtx): string {
19
20
  parts.push(`<figcaption>${escapeHtml(block.caption)}</figcaption>`);
20
21
  }
21
22
 
22
- return `<figure class="diagram">\n${parts.join("\n")}\n</figure>`;
23
+ return `<figure class="diagram"${anchorAttr(ctx)}>\n${parts.join("\n")}\n</figure>`;
23
24
  }
24
25
 
25
26
  export const meta: BlockMeta = {
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { DiffBlock, BlockMeta } from "../types.ts";
5
5
  import type { RenderCtx } from "../render.ts";
6
+ import { anchorAttr } from "../render.ts";
6
7
  import { escapeHtml, escapeAttr } from "../escape.ts";
7
8
  import { highlightCode } from "../highlight.ts";
8
9
  import { parseUnifiedDiff } from "../diff/parse-unified.ts";
@@ -195,7 +196,7 @@ export async function renderDiff(block: DiffBlock, ctx: RenderCtx): Promise<stri
195
196
  : "";
196
197
  const header = filename !== "" ? `<header class="diff-header">${filename}</header>\n` : "";
197
198
  return (
198
- `<figure class="diff-block fallback" data-lang="${escapeAttr(lang)}">\n` +
199
+ `<figure class="diff-block fallback" data-lang="${escapeAttr(lang)}"${anchorAttr(ctx)}>\n` +
199
200
  header +
200
201
  ` <pre><code>${escaped}</code></pre>\n` +
201
202
  `</figure>`
@@ -266,13 +267,22 @@ export async function renderDiff(block: DiffBlock, ctx: RenderCtx): Promise<stri
266
267
  let rightHighIdx = 0;
267
268
  const leftRows: string[] = [];
268
269
  const rightRows: string[] = [];
270
+ // Per-line anchor counter — 1-indexed, increments for every <li> emitted in source order.
271
+ let lineNum = 0;
269
272
 
270
273
  for (const entry of entries) {
271
274
  if (entry.kind === "hunk-sep") {
272
275
  const sepLabel = `… @ ${entry.newStart}`;
273
- const sepHtml = `<li class="diff-line hunk-sep"><span class="num"></span><span class="content">${escapeHtml(sepLabel)}</span></li>`;
274
- leftRows.push(sepHtml);
275
- rightRows.push(sepHtml);
276
+ const leftLineAnchor =
277
+ ctx.anchor !== null ? ` data-cesium-anchor="${ctx.anchor}.line-${++lineNum}"` : "";
278
+ const rightLineAnchor =
279
+ ctx.anchor !== null ? ` data-cesium-anchor="${ctx.anchor}.line-${++lineNum}"` : "";
280
+ leftRows.push(
281
+ `<li class="diff-line hunk-sep"${leftLineAnchor}><span class="num"></span><span class="content">${escapeHtml(sepLabel)}</span></li>`,
282
+ );
283
+ rightRows.push(
284
+ `<li class="diff-line hunk-sep"${rightLineAnchor}><span class="num"></span><span class="content">${escapeHtml(sepLabel)}</span></li>`,
285
+ );
276
286
  continue;
277
287
  }
278
288
 
@@ -282,9 +292,11 @@ export async function renderDiff(block: DiffBlock, ctx: RenderCtx): Promise<stri
282
292
  const hl =
283
293
  leftHighlighted[leftHighIdx] ?? `<span class="line">${escapeHtml(entry.text)}</span>`;
284
294
  leftHighIdx++;
285
- const lineNum = entry.beforeLineNum !== null ? String(entry.beforeLineNum) : "";
295
+ const displayNum = entry.beforeLineNum !== null ? String(entry.beforeLineNum) : "";
296
+ const lineAnchor =
297
+ ctx.anchor !== null ? ` data-cesium-anchor="${ctx.anchor}.line-${++lineNum}"` : "";
286
298
  leftRows.push(
287
- `<li class="diff-line ${kind}"><span class="num">${escapeHtml(lineNum)}</span><span class="content">${hl}</span></li>`,
299
+ `<li class="diff-line ${kind}"${lineAnchor}><span class="num">${escapeHtml(displayNum)}</span><span class="content">${hl}</span></li>`,
288
300
  );
289
301
  }
290
302
 
@@ -292,9 +304,11 @@ export async function renderDiff(block: DiffBlock, ctx: RenderCtx): Promise<stri
292
304
  const hl =
293
305
  rightHighlighted[rightHighIdx] ?? `<span class="line">${escapeHtml(entry.text)}</span>`;
294
306
  rightHighIdx++;
295
- const lineNum = entry.afterLineNum !== null ? String(entry.afterLineNum) : "";
307
+ const displayNum = entry.afterLineNum !== null ? String(entry.afterLineNum) : "";
308
+ const lineAnchor =
309
+ ctx.anchor !== null ? ` data-cesium-anchor="${ctx.anchor}.line-${++lineNum}"` : "";
296
310
  rightRows.push(
297
- `<li class="diff-line ${kind}"><span class="num">${escapeHtml(lineNum)}</span><span class="content">${hl}</span></li>`,
311
+ `<li class="diff-line ${kind}"${lineAnchor}><span class="num">${escapeHtml(displayNum)}</span><span class="content">${hl}</span></li>`,
298
312
  );
299
313
  }
300
314
  }
@@ -339,7 +353,7 @@ export async function renderDiff(block: DiffBlock, ctx: RenderCtx): Promise<stri
339
353
  ` </ol>`;
340
354
 
341
355
  return (
342
- `<figure class="diff-block" data-lang="${escapeAttr(lang)}">\n` +
356
+ `<figure class="diff-block" data-lang="${escapeAttr(lang)}"${anchorAttr(ctx)}>\n` +
343
357
  ` ${headerHtml}\n` +
344
358
  ` <div class="diff-grid">\n` +
345
359
  `${leftOl}\n` +
@@ -4,9 +4,10 @@
4
4
  import type { HeroBlock } from "../types.ts";
5
5
  import type { BlockMeta } from "../types.ts";
6
6
  import type { RenderCtx } from "../render.ts";
7
+ import { anchorAttr } from "../render.ts";
7
8
  import { escapeHtml } from "../escape.ts";
8
9
 
9
- export function renderHero(block: HeroBlock, _ctx: RenderCtx): string {
10
+ export function renderHero(block: HeroBlock, ctx: RenderCtx): string {
10
11
  const parts: string[] = [];
11
12
 
12
13
  if (block.eyebrow !== undefined && block.eyebrow !== "") {
@@ -26,7 +27,7 @@ export function renderHero(block: HeroBlock, _ctx: RenderCtx): string {
26
27
  parts.push(` <dl class="kv">\n${rows}\n </dl>`);
27
28
  }
28
29
 
29
- return `<header>\n${parts.join("\n")}\n</header>`;
30
+ return `<header${anchorAttr(ctx)}>\n${parts.join("\n")}\n</header>`;
30
31
  }
31
32
 
32
33
  export const meta: BlockMeta = {
@@ -4,13 +4,14 @@
4
4
  import type { KvBlock } from "../types.ts";
5
5
  import type { BlockMeta } from "../types.ts";
6
6
  import type { RenderCtx } from "../render.ts";
7
+ import { anchorAttr } from "../render.ts";
7
8
  import { escapeHtml } from "../escape.ts";
8
9
 
9
- export function renderKv(block: KvBlock, _ctx: RenderCtx): string {
10
+ export function renderKv(block: KvBlock, ctx: RenderCtx): string {
10
11
  const rows = block.rows
11
12
  .map((row) => ` <dt>${escapeHtml(row.k)}</dt><dd>${escapeHtml(row.v)}</dd>`)
12
13
  .join("\n");
13
- return `<dl class="kv">\n${rows}\n</dl>`;
14
+ return `<dl class="kv"${anchorAttr(ctx)}>\n${rows}\n</dl>`;
14
15
  }
15
16
 
16
17
  export const meta: BlockMeta = {