@cfbender/cesium 0.6.2 → 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 +82 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/prompt/system-fragment.md +68 -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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,86 @@
|
|
|
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
|
+
|
|
3
84
|
## v0.6.2 — 2026-05-13
|
|
4
85
|
|
|
5
86
|
Prompt fix. The `diff` block was missing from the agent-facing quick reference
|
|
@@ -86,7 +167,7 @@ continue rendering unchanged.
|
|
|
86
167
|
- Restructure `src/server/api.ts` regex matching to remove four
|
|
87
168
|
`!` non-null assertions.
|
|
88
169
|
- Replace `match![1]!` patterns in tests with a local `unwrap(value,
|
|
89
|
-
|
|
170
|
+
name)` helper for proper type narrowing.
|
|
90
171
|
- Replace `handle!.url` in tests with an explicit null check.
|
|
91
172
|
- Add targeted `eslint-disable-next-line no-await-in-loop` comments
|
|
92
173
|
(with `--` reason annotations matching the existing repo convention)
|
package/package.json
CHANGED
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
|
|
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
|
-
- `
|
|
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
|
-
##
|
|
15
|
+
## Describing content with blocks
|
|
15
16
|
|
|
16
|
-
`cesium_publish`
|
|
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
|
-
|
|
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
|
|
|
@@ -101,15 +102,33 @@ Publish when: ≥ 400 words; comparison/matrix/plan/PRD/RFC; code review with >3
|
|
|
101
102
|
|
|
102
103
|
User overrides: "/cesium" or "publish this" → publish; "in terminal" → don't.
|
|
103
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
|
+
|
|
104
123
|
## Self-check before publishing
|
|
105
124
|
|
|
106
|
-
Call `cesium_critique` before `cesium_publish` on substantive artifacts.
|
|
125
|
+
Call `cesium_critique` before `cesium_publish` on substantive artifacts. Act on warn-level findings; consider suggest-level. If score < 70, revise.
|
|
107
126
|
|
|
108
127
|
## After publishing
|
|
109
128
|
|
|
110
|
-
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.
|
|
111
130
|
|
|
112
|
-
## Interactive Q&A: cesium_ask
|
|
131
|
+
## Interactive Q&A: cesium_ask
|
|
113
132
|
|
|
114
133
|
1. `cesium_ask({ title, body, questions: [...] })` → returns `{ id, httpUrl, ... }`
|
|
115
134
|
2. Print the terminalSummary so the user knows where to click.
|
|
@@ -118,6 +137,47 @@ Print a 2-line terminal summary: `Cesium · <Title> (<kind>)` + the HTTP URL. Do
|
|
|
118
137
|
|
|
119
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.
|
|
120
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
|
+
|
|
121
181
|
## Stopping the server
|
|
122
182
|
|
|
123
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
|
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,
|
|
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 = {
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
import type { ListBlock } 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 { renderMarkdown } from "../markdown.ts";
|
|
8
9
|
|
|
9
|
-
export function renderList(block: ListBlock,
|
|
10
|
+
export function renderList(block: ListBlock, ctx: RenderCtx): string {
|
|
10
11
|
const style = block.style ?? "bullet";
|
|
11
12
|
|
|
12
13
|
const items = block.items
|
|
@@ -17,7 +18,7 @@ export function renderList(block: ListBlock, _ctx: RenderCtx): string {
|
|
|
17
18
|
.join("\n");
|
|
18
19
|
|
|
19
20
|
if (style === "number") {
|
|
20
|
-
return `<ol>\n${items}\n</ol>`;
|
|
21
|
+
return `<ol${anchorAttr(ctx)}>\n${items}\n</ol>`;
|
|
21
22
|
} else if (style === "check") {
|
|
22
23
|
const checkItems = block.items
|
|
23
24
|
.map((item) => {
|
|
@@ -25,9 +26,9 @@ export function renderList(block: ListBlock, _ctx: RenderCtx): string {
|
|
|
25
26
|
return ` <li class="check">${content}</li>`;
|
|
26
27
|
})
|
|
27
28
|
.join("\n");
|
|
28
|
-
return `<ul class="check-list">\n${checkItems}\n</ul>`;
|
|
29
|
+
return `<ul class="check-list"${anchorAttr(ctx)}>\n${checkItems}\n</ul>`;
|
|
29
30
|
} else {
|
|
30
|
-
return `<ul>\n${items}\n</ul>`;
|
|
31
|
+
return `<ul${anchorAttr(ctx)}>\n${items}\n</ul>`;
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
import type { PillRowBlock } 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 renderPillRow(block: PillRowBlock,
|
|
10
|
+
export function renderPillRow(block: PillRowBlock, ctx: RenderCtx): string {
|
|
10
11
|
const pills = block.items
|
|
11
12
|
.map((item) => ` <span class="${item.kind}">${escapeHtml(item.text)}</span>`)
|
|
12
13
|
.join("\n");
|
|
13
|
-
return `<div class="pill-row">\n${pills}\n</div>`;
|
|
14
|
+
return `<div class="pill-row"${anchorAttr(ctx)}>\n${pills}\n</div>`;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export const meta: BlockMeta = {
|