chore(voyage): release v5.0.3 — annotation UX matches the claude-code-100x reference
The operator pointed at ~/repos/claude-code-100x/claude-code-100x/build-site.js
as the annotation reference from the start. v4.2/v4.3 built a bespoke
playground instead. v5.0.0 deleted it. v5.0.1 pointed at /playground
document-critique (Claude-leads, wrong direction). v5.0.2 was operator-led
but too thin (line-click + freeform note, no intent). v5.0.3 finally
matches the reference.
scripts/annotate.mjs rewritten:
- Markdown rendered as proper article HTML (h1/p/li/ul/table/blockquote/pre)
instead of line-numbered raw lines.
- Pencil-toggle annotation mode in the topbar, default ON.
- Select text OR click any element → form popover at cursor.
- Three intent buttons: Fiks (red) / Endre (orange) / Spørsmål (blue).
- Comment textarea. Save (Cmd+Enter), Cancel (Esc).
- Section context auto-detected from nearest h1/h2.
- Sidebar panel: annotations grouped by section, intent badges,
snippet quotes, delete buttons, click-to-scroll with flash highlight.
- Copy Prompt: structured markdown export with intent labels.
- localStorage persistence keyed on absolute artifact path
(voyage-annotate:v2: prefix to avoid colliding with v5.0.2 state).
Tests: 12 (up from 10), all passing. npm test: 518 / 516 pass / 0 fail / 2 skipped.
Reference: ~/repos/claude-code-100x/claude-code-100x/build-site.js
lines 1431–2255 (annotation UI section).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
8ea692bc60
commit
9ba8b682ef
11 changed files with 974 additions and 491 deletions
|
|
@ -1,26 +1,29 @@
|
|||
// tests/scripts/annotate.test.mjs
|
||||
// Covers scripts/annotate.mjs — the v5.0.2 operator-annotation HTML
|
||||
// generator. The producing commands call it to print a file:// link the
|
||||
// operator opens in a browser, where they click lines, write their own
|
||||
// notes, copy a structured prompt, and paste back into Claude.
|
||||
// Covers scripts/annotate.mjs — the v5.0.3 operator-annotation HTML
|
||||
// generator. UX modelled on claude-code-100x/build-site.js (pencil
|
||||
// toggle, intent buttons, form popover, selection-anchoring, localStorage
|
||||
// persistence, structured markdown export).
|
||||
//
|
||||
// What we pin:
|
||||
// • Output is a complete, self-contained HTML document.
|
||||
// • Zero external network references in the static HTML.
|
||||
// • No external <link href=> or <script src=>.
|
||||
// • The embedded inline <script> parses as valid JavaScript.
|
||||
// • Source document content + artifact path are embedded verbatim
|
||||
// (so the browser-side app can render exactly the same lines).
|
||||
// • HTML special chars and code-fence content are escaped — no raw
|
||||
// <script>-injection from the source .md.
|
||||
// • The artifact path is embedded (used as the localStorage key + prompt context).
|
||||
// • The markdown source is rendered to proper HTML (h1/p/li etc.), not as raw lines.
|
||||
// • HTML metacharacters in the title are escaped (XSS).
|
||||
// • Inline content from a hostile .md never appears as a live attribute.
|
||||
// • render() is deterministic — two runs produce byte-identical output.
|
||||
// • Default output path is <input-basename>.html next to the input.
|
||||
// • The v5.0.3 affordances are wired into the HTML: pencil-toggle, form
|
||||
// popover with three intent buttons (Fiks/Endre/Spørsmål), annotations
|
||||
// sidebar, Copy Prompt button, Clear all, localStorage persistence.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { buildHtml, render, parseArgs } from '../../scripts/annotate.mjs';
|
||||
import { buildHtml, renderMarkdown, render, parseArgs } from '../../scripts/annotate.mjs';
|
||||
|
||||
const SAMPLE = `---
|
||||
type: trekplan
|
||||
|
|
@ -55,9 +58,6 @@ test('buildHtml produces a complete self-contained HTML document', () => {
|
|||
|
||||
test('buildHtml has zero external network references in static HTML', () => {
|
||||
const html = buildHtml('/abs/path/plan.md', SAMPLE);
|
||||
// Strip the script section (the embedded clipboard-API safe link list may
|
||||
// technically include example.com in inline content; that's allowed).
|
||||
// Static HTML structure must not have any <link href=...> or <script src=...>.
|
||||
assert.ok(!/<link[^>]+href\s*=/i.test(html), 'no external <link href> stylesheets');
|
||||
assert.ok(!/<script[^>]+src\s*=/i.test(html), 'no external <script src>');
|
||||
});
|
||||
|
|
@ -66,56 +66,48 @@ test('buildHtml embeds the inline <script> as parseable JavaScript', () => {
|
|||
const html = buildHtml('/abs/path/plan.md', SAMPLE);
|
||||
const m = html.match(/<script>([\s\S]*?)<\/script>/);
|
||||
assert.ok(m, 'must contain a <script> block');
|
||||
// Function() parses but doesn't execute references to document/localStorage,
|
||||
// so a SyntaxError here is the only failure mode we want to surface.
|
||||
assert.doesNotThrow(() => new Function(m[1]), 'inline script must parse without SyntaxError');
|
||||
});
|
||||
|
||||
test('buildHtml embeds the artifact path and source lines verbatim', () => {
|
||||
test('buildHtml embeds the artifact path (used as localStorage key + prompt context)', () => {
|
||||
const html = buildHtml('/abs/projects/2026-05-13-foo/brief.md', SAMPLE);
|
||||
assert.ok(html.includes('/abs/projects/2026-05-13-foo/brief.md'),
|
||||
'artifact path must appear in the HTML so the script can use it as the localStorage key + prompt context');
|
||||
// The DOC_LINES JSON literal must contain the actual content of the source.
|
||||
assert.match(html, /DOC_LINES\s*=\s*\[/);
|
||||
assert.ok(html.includes('Operator-annotation smoke test'),
|
||||
'source document content must round-trip into the embedded DOC_LINES');
|
||||
assert.ok(html.includes('inline code'),
|
||||
'inline content from the source markdown must appear in DOC_LINES');
|
||||
});
|
||||
|
||||
test('buildHtml renders the markdown source to proper article HTML', () => {
|
||||
const html = buildHtml('/abs/path/plan.md', SAMPLE);
|
||||
// Headings, paragraph content, list items, code fence — all present as HTML.
|
||||
assert.ok(html.includes('<h1 data-anchor-id='), 'top-level heading rendered as <h1>');
|
||||
assert.ok(html.includes('<h2 data-anchor-id='), '## heading rendered as <h2>');
|
||||
assert.ok(html.includes('Operator-annotation smoke test'), 'h1 text preserved');
|
||||
assert.ok(html.includes('<li data-anchor-id='), 'list items rendered with anchor ids');
|
||||
assert.ok(html.includes('first item'), 'list content preserved');
|
||||
assert.ok(html.includes('<pre data-anchor-id='), 'code fence rendered with anchor');
|
||||
assert.ok(html.includes('const x = 1;'), 'code fence body preserved (escaped)');
|
||||
assert.ok(html.includes('<blockquote data-anchor-id='), 'blockquote rendered with anchor');
|
||||
});
|
||||
|
||||
test('buildHtml escapes HTML metacharacters in the title (XSS surface)', () => {
|
||||
const md = '---\ntype: trekbrief\ntask: "<script>alert(1)</script>"\n---\n\n# Foo\n';
|
||||
const html = buildHtml('/abs/path/brief.md', md);
|
||||
// The raw <script> from the title must NOT appear unescaped anywhere
|
||||
// outside our own inline <script>.
|
||||
const titleMatch = html.match(/<title>([\s\S]*?)<\/title>/);
|
||||
assert.ok(titleMatch, 'must have a title');
|
||||
assert.ok(!titleMatch[1].includes('<script>'), 'title must not carry a raw <script> tag');
|
||||
assert.ok(titleMatch[1].includes('<script>') || titleMatch[1].includes('alert'),
|
||||
'title must be HTML-escaped');
|
||||
assert.match(titleMatch[1], /<script>/, 'title must be HTML-escaped');
|
||||
});
|
||||
|
||||
test('buildHtml escapes source-document content so a malicious .md cannot inject code', () => {
|
||||
// The embedded DOC_LINES is a JSON literal, which already escapes anything
|
||||
// dangerous. But the script then renders content into the DOM — the inline
|
||||
// renderer escapes everything before DOM-insertion. This test pins the
|
||||
// JSON-level escaping invariant.
|
||||
const md = '# Heading\n\n<img src=x onerror="alert(1)">\n';
|
||||
test('hostile inline content cannot inject as live HTML attributes', () => {
|
||||
const md = '# Heading\n\nA paragraph with <img src=x onerror="alert(1)"> embedded.\n';
|
||||
const html = buildHtml('/abs/path/brief.md', md);
|
||||
// The dangerous attribute must NOT appear as a live HTML construct outside
|
||||
// a JSON string. Easiest pin: the literal substring should appear inside
|
||||
// quoted JSON (with backslash-escaped quotes), and the raw construct
|
||||
// `onerror="alert(1)"` should not appear with unescaped double quotes
|
||||
// outside the script-embedded JSON literal.
|
||||
const m = html.match(/<script>([\s\S]*?)<\/script>/);
|
||||
assert.ok(m, 'must contain script');
|
||||
// The JSON encoding of `"` is `\"`, so we expect at least the escaped form.
|
||||
assert.ok(m[1].includes('onerror=\\"alert(1)\\"') || m[1].includes("onerror=\\'alert(1)\\'"),
|
||||
'dangerous source attribute should be JSON-escaped in DOC_LINES');
|
||||
// Static HTML outside <script> must not carry the live attribute either.
|
||||
const outsideScript = html.replace(/<script>[\s\S]*?<\/script>/, '');
|
||||
assert.ok(!/onerror\s*=\s*"alert/i.test(outsideScript),
|
||||
'static HTML must not carry a live onerror attribute');
|
||||
// The article body must not carry a live onerror="..." attribute (the renderer
|
||||
// HTML-escapes everything in the body, so `<` → `<`).
|
||||
const articleMatch = html.match(/<article[^>]*>([\s\S]*?)<\/article>/);
|
||||
assert.ok(articleMatch, 'must have article body');
|
||||
assert.ok(!/onerror\s*=\s*"alert/i.test(articleMatch[1]),
|
||||
'article body must not carry a live onerror attribute');
|
||||
assert.ok(articleMatch[1].includes('<img'),
|
||||
'hostile <img> must be escaped to <img');
|
||||
});
|
||||
|
||||
test('render() is deterministic — two runs byte-identical', () => {
|
||||
|
|
@ -151,17 +143,66 @@ test('parseArgs handles --out, positional input, and --help', () => {
|
|||
assert.equal(parseArgs(['--help']).help, true);
|
||||
});
|
||||
|
||||
test('buildHtml output contains the operator-driven copy-paste loop affordances', () => {
|
||||
// Pin the user-visible affordances that v5.0.2 promised:
|
||||
// - Per-line click target (gutter '+' marker)
|
||||
// - "Your annotations" sidebar
|
||||
// - "Copy Prompt" button
|
||||
// - "Clear all" button (so the operator can reset state)
|
||||
// - localStorage persistence
|
||||
test('buildHtml wires the v5.0.3 operator-driven annotation affordances', () => {
|
||||
// Pin every UX-critical affordance modelled on claude-code-100x/build-site.js:
|
||||
// - Pencil-toggle button (annotation mode on/off)
|
||||
// - Form popover with three intent buttons (Fiks/Endre/Spørsmål)
|
||||
// - Annotations sidebar (Your annotations + Clear all + Copy Prompt)
|
||||
// - Selection capture (window.getSelection())
|
||||
// - Section context auto-detection (findSection)
|
||||
// - localStorage persistence (voyage-annotate:v2:...)
|
||||
// - Annotatable elements (data-anchor-id on h1-h6, p, li, td, blockquote, pre)
|
||||
const html = buildHtml('/abs/path/brief.md', SAMPLE);
|
||||
assert.ok(html.includes('Your annotations'), 'must show a "Your annotations" sidebar');
|
||||
assert.ok(html.includes('Copy Prompt'), 'must have a "Copy Prompt" button');
|
||||
assert.ok(html.includes('Clear all'), 'must have a "Clear all" affordance');
|
||||
assert.ok(html.includes('localStorage'), 'must persist state in localStorage');
|
||||
assert.ok(html.includes('Click any line'), 'must tell the operator how to start');
|
||||
// Toggle
|
||||
assert.ok(html.includes('ann-toggle'), 'must have the pencil-toggle button');
|
||||
assert.ok(html.includes('Annotation mode: ON'), 'must label the toggle state');
|
||||
// Form + intents (the three CSS classes for selected state)
|
||||
assert.ok(html.includes('data-intent="fiks"'), 'must have Fiks intent button');
|
||||
assert.ok(html.includes('data-intent="endre"'), 'must have Endre intent button');
|
||||
assert.ok(html.includes('data-intent="spørsmål"'), 'must have Spørsmål intent button');
|
||||
// Form popover
|
||||
assert.ok(html.includes('ann-form'), 'must have the form popover');
|
||||
assert.ok(html.includes('ann-form-comment'), 'must have a comment textarea');
|
||||
assert.ok(html.includes('ann-form-save'), 'must have a Save button');
|
||||
// Sidebar
|
||||
assert.ok(html.includes('ann-panel'), 'must have the annotations sidebar');
|
||||
assert.ok(html.includes('Your annotations'), 'sidebar must title the list');
|
||||
assert.ok(html.includes('Clear all'), 'sidebar must offer Clear all');
|
||||
assert.ok(html.includes('Copy Prompt'), 'sidebar must offer Copy Prompt');
|
||||
// Selection + section
|
||||
assert.ok(html.includes('window.getSelection'), 'must capture selection');
|
||||
assert.ok(html.includes('findSection'), 'must auto-detect section context');
|
||||
// Persistence
|
||||
assert.ok(html.includes("'voyage-annotate:v2:'"), 'must use the v2 localStorage key prefix');
|
||||
// Anchor coverage
|
||||
const anchors = (html.match(/data-anchor-id="anch-/g) || []).length;
|
||||
assert.ok(anchors >= 5, 'must emit data-anchor-id on enough elements (got ' + anchors + ')');
|
||||
});
|
||||
|
||||
test('renderMarkdown produces headings, lists, code, table, blockquote with anchors', () => {
|
||||
const html = renderMarkdown(`# H1
|
||||
## H2
|
||||
- a
|
||||
- b
|
||||
|
||||
1. one
|
||||
2. two
|
||||
|
||||
| Col | Val |
|
||||
|-----|-----|
|
||||
| x | 1 |
|
||||
|
||||
\`\`\`
|
||||
plain code
|
||||
\`\`\`
|
||||
|
||||
> quote
|
||||
`);
|
||||
assert.match(html, /<h1 data-anchor-id="anch-0">H1<\/h1>/);
|
||||
assert.match(html, /<h2 data-anchor-id="anch-1">H2<\/h2>/);
|
||||
assert.match(html, /<ul><li data-anchor-id=/);
|
||||
assert.match(html, /<ol><li data-anchor-id=/);
|
||||
assert.match(html, /<table>[\s\S]*<th data-anchor-id=/);
|
||||
assert.match(html, /<pre data-anchor-id=/);
|
||||
assert.match(html, /<blockquote data-anchor-id=/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue