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>
921 lines
39 KiB
JavaScript
921 lines
39 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/annotate.mjs
|
|
//
|
|
// Operator-annotation HTML for a voyage artifact (brief.md / plan.md /
|
|
// review.md). The producing commands run this on their last step and
|
|
// print the file:// link. The operator opens the HTML in their browser,
|
|
// the page renders the artifact as a proper article (headings, lists,
|
|
// paragraphs, code blocks — not raw lines), and the operator drives every
|
|
// annotation themselves: select text or click any element, choose intent
|
|
// (Fiks / Endre / Spørsmål), write a comment, save. The sidebar shows
|
|
// every annotation grouped by section; Copy Prompt assembles them into
|
|
// one structured markdown the operator pastes back into Claude.
|
|
//
|
|
// UX modelled on the claude-code-100x annotation surface
|
|
// (build-site.js, 2026 — same pencil-toggle, intent buttons, form popover,
|
|
// localStorage persistence, structured markdown export).
|
|
//
|
|
// • Operator drives every annotation. No Claude-generated suggestions.
|
|
// • Three intent categories: Fiks (fix) / Endre (change) / Spørsmål (question).
|
|
// • Element + selection anchoring — clicking an element captures it whole;
|
|
// selecting text inside an element captures the exact substring.
|
|
// • Section context auto-detected (nearest h1/h2 above).
|
|
// • Annotations persist in localStorage keyed on the absolute artifact path.
|
|
// • Zero npm deps, zero external network, deterministic output.
|
|
|
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { basename, resolve } from 'node:path';
|
|
import { splitFrontmatter } from '../lib/util/frontmatter.mjs';
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function deriveTitle(mdText, fallbackName) {
|
|
const { hasFrontmatter, frontmatter } = splitFrontmatter(mdText);
|
|
if (hasFrontmatter) {
|
|
const m = frontmatter.match(/^task:\s*(.+)$/m) || frontmatter.match(/^slug:\s*(.+)$/m);
|
|
if (m) return m[1].trim().replace(/^["']|["']$/g, '');
|
|
}
|
|
const h1 = mdText.match(/^#\s+(.+)$/m);
|
|
if (h1) return h1[1].trim();
|
|
return fallbackName;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Markdown → HTML with data-anchor-id on every annotatable element.
|
|
// Hand-rolled subset matching what artifact templates emit.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function renderInline(escaped) {
|
|
let s = escaped.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
|
|
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, t, h) => {
|
|
const safe = /^(https?:|mailto:|#|\.|\/)/i.test(h) ? h : '#';
|
|
return `<a href="${safe}" target="_blank" rel="noopener">${t}</a>`;
|
|
});
|
|
s = s.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
|
s = s.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => `${pre}<em>${c}</em>`);
|
|
return s;
|
|
}
|
|
|
|
function renderMarkdown(md) {
|
|
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
|
let html = '';
|
|
let anchorId = 0;
|
|
const anchor = () => `anch-${anchorId++}`;
|
|
let i = 0;
|
|
let paraBuf = [];
|
|
|
|
const flushPara = () => {
|
|
if (paraBuf.length) {
|
|
const text = paraBuf.join(' ');
|
|
html += `<p data-anchor-id="${anchor()}">${renderInline(escapeHtml(text))}</p>\n`;
|
|
paraBuf = [];
|
|
}
|
|
};
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
|
|
// Fenced code block — NOT annotatable as a whole; we keep it readable
|
|
// but skip the data-anchor-id so the operator clicks around it.
|
|
const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
|
|
if (fence) {
|
|
flushPara();
|
|
const marker = fence[2][0];
|
|
const lang = (fence[3] || '').trim().split(/\s+/)[0];
|
|
const buf = [];
|
|
i++;
|
|
while (i < lines.length && !new RegExp('^\\s*' + marker + '{3,}\\s*$').test(lines[i])) {
|
|
buf.push(lines[i]);
|
|
i++;
|
|
}
|
|
i++; // closing fence
|
|
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
|
|
html += `<pre data-anchor-id="${anchor()}"><code${cls}>${escapeHtml(buf.join('\n'))}\n</code></pre>\n`;
|
|
continue;
|
|
}
|
|
|
|
// ATX heading
|
|
const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
if (h) {
|
|
flushPara();
|
|
const lvl = h[1].length;
|
|
html += `<h${lvl} data-anchor-id="${anchor()}">${renderInline(escapeHtml(h[2]))}</h${lvl}>\n`;
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Horizontal rule
|
|
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
|
flushPara();
|
|
html += '<hr>\n';
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Table
|
|
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length &&
|
|
/^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
|
flushPara();
|
|
const rows = [];
|
|
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(lines[i]); i++; }
|
|
const cells = (l) => l.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
|
|
const header = cells(rows[0]);
|
|
const body = rows.slice(2).map(cells);
|
|
html += '<table>\n<thead><tr>';
|
|
for (const c of header) html += `<th data-anchor-id="${anchor()}">${renderInline(escapeHtml(c))}</th>`;
|
|
html += '</tr></thead>\n<tbody>\n';
|
|
for (const r of body) {
|
|
html += '<tr>';
|
|
for (let k = 0; k < header.length; k++) html += `<td data-anchor-id="${anchor()}">${renderInline(escapeHtml(r[k] || ''))}</td>`;
|
|
html += '</tr>\n';
|
|
}
|
|
html += '</tbody>\n</table>\n';
|
|
continue;
|
|
}
|
|
|
|
// Blockquote
|
|
if (/^\s*>\s?/.test(line)) {
|
|
flushPara();
|
|
const buf = [];
|
|
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
|
buf.push(lines[i].replace(/^\s*>\s?/, ''));
|
|
i++;
|
|
}
|
|
html += `<blockquote data-anchor-id="${anchor()}">${renderInline(escapeHtml(buf.join(' ')))}</blockquote>\n`;
|
|
continue;
|
|
}
|
|
|
|
// Lists — one block, allow blank lines between items
|
|
const listMatch = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
|
if (listMatch) {
|
|
flushPara();
|
|
const items = [];
|
|
while (i < lines.length) {
|
|
const m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
|
if (m) {
|
|
items.push({ indent: m[1].length, ordered: /\d/.test(m[2]), text: m[3] });
|
|
i++;
|
|
} else if (lines[i].trim() === '' && i + 1 < lines.length &&
|
|
lines[i + 1].match(/^(\s*)([-*+]|\d+[.)])\s+/)) {
|
|
i++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
html += renderList(items, anchor);
|
|
continue;
|
|
}
|
|
|
|
// Blank
|
|
if (line.trim() === '') {
|
|
flushPara();
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Default: paragraph accumulation
|
|
paraBuf.push(line.trim());
|
|
i++;
|
|
}
|
|
flushPara();
|
|
return html;
|
|
}
|
|
|
|
function renderList(items, anchor) {
|
|
let html = '';
|
|
const stack = [];
|
|
for (const { indent, ordered, text } of items) {
|
|
while (stack.length && (indent < stack[stack.length - 1].indent ||
|
|
(indent === stack[stack.length - 1].indent && ordered !== stack[stack.length - 1].ordered))) {
|
|
const top = stack.pop();
|
|
html += top.ordered ? '</li></ol>' : '</li></ul>';
|
|
}
|
|
if (!stack.length || indent > stack[stack.length - 1].indent) {
|
|
html += ordered ? '<ol>' : '<ul>';
|
|
stack.push({ indent, ordered });
|
|
} else {
|
|
html += '</li>';
|
|
}
|
|
html += `<li data-anchor-id="${anchor()}">${renderInline(escapeHtml(text))}`;
|
|
}
|
|
while (stack.length) {
|
|
const top = stack.pop();
|
|
html += top.ordered ? '</li></ol>' : '</li></ul>';
|
|
}
|
|
return html + '\n';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build full HTML document
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildHtml(artifactPath, mdText) {
|
|
const fileName = basename(artifactPath);
|
|
const title = deriveTitle(mdText, fileName);
|
|
const { body } = splitFrontmatter(mdText);
|
|
const articleHtml = renderMarkdown(body);
|
|
return '<!DOCTYPE html>\n'
|
|
+ '<html lang="en">\n'
|
|
+ '<head>\n'
|
|
+ '<meta charset="utf-8">\n'
|
|
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
|
+ '<title>' + escapeHtml(title) + ' — annotate</title>\n'
|
|
+ '<style>\n' + STYLE + '\n</style>\n'
|
|
+ '</head>\n'
|
|
+ '<body class="ann-mode">\n'
|
|
+ '<header class="topbar">\n'
|
|
+ ' <div class="hdr-meta">\n'
|
|
+ ' <h1>' + escapeHtml(title) + '</h1>\n'
|
|
+ ' <p class="path" title="' + escapeHtml(artifactPath) + '">' + escapeHtml(fileName) + '</p>\n'
|
|
+ ' </div>\n'
|
|
+ ' <div class="hdr-actions">\n'
|
|
+ ' <button class="ann-toggle" id="ann-toggle" title="Toggle annotation mode (pencil)">✎ <span id="ann-toggle-label">Annotation mode: ON</span> <span class="ann-badge" id="ann-badge">0</span></button>\n'
|
|
+ ' <button class="ghost-btn" id="open-panel">Show annotations</button>\n'
|
|
+ ' </div>\n'
|
|
+ '</header>\n'
|
|
+ '<main class="article-wrap">\n'
|
|
+ ' <div class="article-help" id="article-help">Click any heading, paragraph, list item, table cell, or quote to add an annotation. To anchor on a specific phrase, <strong>select the text first</strong>, then click. Toggle annotation mode off (pencil button) to read normally / follow links.</div>\n'
|
|
+ ' <article class="article" id="article">\n'
|
|
+ articleHtml
|
|
+ '\n </article>\n'
|
|
+ '</main>\n'
|
|
+ '<div class="ann-form" id="ann-form" role="dialog" aria-label="New annotation">\n'
|
|
+ ' <div class="ann-form-section">\n'
|
|
+ ' <div class="ann-form-section-label">Section</div>\n'
|
|
+ ' <div class="ann-form-section-value" id="ann-form-section">—</div>\n'
|
|
+ ' </div>\n'
|
|
+ ' <div class="ann-form-snippet">\n'
|
|
+ ' <div class="ann-form-section-label">Anchored to</div>\n'
|
|
+ ' <blockquote class="ann-form-snippet-text" id="ann-form-snippet">…</blockquote>\n'
|
|
+ ' </div>\n'
|
|
+ ' <div class="ann-form-intents">\n'
|
|
+ ' <button class="ann-intent" data-intent="fiks" title="Something is wrong or broken — needs to be fixed">Fiks</button>\n'
|
|
+ ' <button class="ann-intent" data-intent="endre" title="Change the wording or content">Endre</button>\n'
|
|
+ ' <button class="ann-intent" data-intent="spørsmål" title="An open question or clarification request">Spørsmål</button>\n'
|
|
+ ' </div>\n'
|
|
+ ' <textarea class="ann-form-comment" id="ann-form-comment" placeholder="Your comment (optional but helpful)…"></textarea>\n'
|
|
+ ' <div class="ann-form-actions">\n'
|
|
+ ' <button class="btn" id="ann-form-cancel">Cancel (Esc)</button>\n'
|
|
+ ' <button class="btn primary" id="ann-form-save" disabled>Save (⌘Enter)</button>\n'
|
|
+ ' </div>\n'
|
|
+ '</div>\n'
|
|
+ '<aside class="ann-panel" id="ann-panel" aria-label="Annotations panel">\n'
|
|
+ ' <div class="ann-panel-head">\n'
|
|
+ ' <h2>Your annotations</h2>\n'
|
|
+ ' <button class="icon-btn" id="ann-panel-close" title="Close">✕</button>\n'
|
|
+ ' </div>\n'
|
|
+ ' <div class="ann-panel-body" id="ann-panel-body"></div>\n'
|
|
+ ' <div class="ann-panel-foot">\n'
|
|
+ ' <button class="ghost-btn" id="ann-clear-all">Clear all</button>\n'
|
|
+ ' <button class="btn primary" id="ann-copy" disabled>Copy Prompt</button>\n'
|
|
+ ' </div>\n'
|
|
+ '</aside>\n'
|
|
+ '<div class="ann-toast" id="ann-toast" role="status" aria-live="polite"></div>\n'
|
|
+ '<div class="ann-overlay" id="ann-overlay"></div>\n'
|
|
+ '<script>\n'
|
|
+ 'const ARTIFACT_PATH = ' + JSON.stringify(resolve(artifactPath)) + ';\n'
|
|
+ 'const ARTIFACT_NAME = ' + JSON.stringify(fileName) + ';\n'
|
|
+ APP_JS
|
|
+ '\n</script>\n'
|
|
+ '</body>\n'
|
|
+ '</html>\n';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stylesheet — light + dark + print. Design-system-aligned.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const STYLE = `
|
|
:root {
|
|
--bg: #f7f7f8;
|
|
--bg-elev: #ffffff;
|
|
--bg-soft: #ececef;
|
|
--border: #d6d8dc;
|
|
--border-strong: #b3b7bd;
|
|
--text: #1a1a1a;
|
|
--text-dim: #555a63;
|
|
--text-mute: #8a8f97;
|
|
--accent: #0855a8;
|
|
--accent-soft: #e4ecf6;
|
|
--amber: #a86b00;
|
|
--amber-soft: #fbeed1;
|
|
--green: #1a7f37;
|
|
--green-soft: #d5ecdb;
|
|
--red: #b3262d;
|
|
--red-soft: #f6d9da;
|
|
--blue: #0855a8;
|
|
--blue-soft: #e4ecf6;
|
|
--orange: #d4790a;
|
|
--orange-soft: #fceede;
|
|
--purple: #6638b6;
|
|
--purple-soft: #ebe1f9;
|
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
--sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
|
|
--serif: ui-serif, "Source Serif 4", Georgia, "Times New Roman", serif;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #0e1218;
|
|
--bg-elev: #161b22;
|
|
--bg-soft: #1c232c;
|
|
--border: #2a323c;
|
|
--border-strong: #3b4554;
|
|
--text: #e5e9ef;
|
|
--text-dim: #a5adba;
|
|
--text-mute: #6e7681;
|
|
--accent: #6db0ee;
|
|
--accent-soft: rgba(109, 176, 238, 0.15);
|
|
--amber: #d4a017;
|
|
--amber-soft: rgba(212, 160, 23, 0.12);
|
|
--green: #3fb950;
|
|
--green-soft: rgba(63, 185, 80, 0.12);
|
|
--red: #f0626a;
|
|
--red-soft: rgba(240, 98, 106, 0.12);
|
|
--blue: #6db0ee;
|
|
--blue-soft: rgba(109, 176, 238, 0.15);
|
|
--orange: #f6ad55;
|
|
--orange-soft: rgba(246, 173, 85, 0.15);
|
|
--purple: #d2a8ff;
|
|
--purple-soft: rgba(210, 168, 255, 0.15);
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text);
|
|
font-family: var(--sans); font-size: 15px; line-height: 1.6; }
|
|
body { min-height: 100vh; }
|
|
/* Topbar */
|
|
.topbar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; justify-content: space-between;
|
|
gap: 16px; padding: 12px 24px; background: var(--bg-elev); border-bottom: 1px solid var(--border); }
|
|
.hdr-meta h1 { font-size: 16px; font-weight: 650; margin: 0; }
|
|
.hdr-meta .path { color: var(--text-dim); font-size: 12px; font-family: var(--mono); margin: 2px 0 0; word-break: break-all; }
|
|
.hdr-actions { display: flex; gap: 8px; align-items: center; }
|
|
.ann-toggle { display: inline-flex; align-items: center; gap: 6px;
|
|
background: var(--accent); color: #fff; border: 1px solid var(--accent);
|
|
border-radius: 5px; padding: 6px 12px; font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
|
|
.ann-toggle:hover { filter: brightness(1.05); }
|
|
body:not(.ann-mode) .ann-toggle { background: var(--bg-soft); color: var(--text-dim); border-color: var(--border); }
|
|
body:not(.ann-mode) .ann-toggle:hover { color: var(--text); border-color: var(--border-strong); }
|
|
.ann-badge { background: rgba(255,255,255,0.25); color: inherit; padding: 0 6px; border-radius: 99px; font-size: 11px; font-weight: 700; }
|
|
body:not(.ann-mode) .ann-badge { background: var(--bg); color: var(--text-dim); }
|
|
.ghost-btn { background: transparent; color: var(--text-dim); border: 1px solid var(--border);
|
|
border-radius: 5px; padding: 6px 12px; font-family: inherit; font-size: 13px; cursor: pointer; }
|
|
.ghost-btn:hover { color: var(--text); border-color: var(--border-strong); }
|
|
.icon-btn { background: transparent; border: none; color: var(--text-dim); cursor: pointer;
|
|
font-size: 16px; padding: 4px 8px; border-radius: 4px; }
|
|
.icon-btn:hover { color: var(--text); background: var(--bg-soft); }
|
|
/* Article */
|
|
.article-wrap { max-width: 820px; margin: 0 auto; padding: 24px 32px 96px; }
|
|
.article-help { font-size: 13px; color: var(--text-dim); background: var(--accent-soft);
|
|
border: 1px solid var(--accent); border-radius: 6px; padding: 10px 14px; margin: 0 0 24px; line-height: 1.5; }
|
|
body:not(.ann-mode) .article-help { display: none; }
|
|
.article-help strong { color: var(--text); }
|
|
.article { font-size: 15px; line-height: 1.7; }
|
|
.article h1, .article h2, .article h3, .article h4, .article h5, .article h6 {
|
|
font-family: var(--serif); font-weight: 700; line-height: 1.25; margin: 1.8em 0 .55em; color: var(--text); }
|
|
.article h1 { font-size: 2rem; margin-top: 0; }
|
|
.article h2 { font-size: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: .3em; }
|
|
.article h3 { font-size: 1.2rem; }
|
|
.article h4 { font-size: 1.05rem; }
|
|
.article p { margin: .9em 0; }
|
|
.article a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
|
|
.article code { font-family: var(--mono); font-size: .9em; background: var(--bg-soft);
|
|
padding: .12em .4em; border-radius: 4px; }
|
|
.article pre { background: #1e1e24; color: #e6e6eb; padding: 16px 18px; border-radius: 8px;
|
|
overflow-x: auto; font-size: .88rem; line-height: 1.55; margin: 1.2em 0; }
|
|
.article pre code { background: none; padding: 0; color: inherit; font-size: inherit; }
|
|
.article blockquote { margin: 1.2em 0; padding: .5em 1.2em; border-left: 4px solid var(--accent);
|
|
background: var(--accent-soft); color: var(--text-dim); border-radius: 0 6px 6px 0; }
|
|
.article ul, .article ol { padding-left: 1.8em; margin: .9em 0; }
|
|
.article li { margin: .3em 0; }
|
|
.article table { border-collapse: collapse; width: 100%; margin: 1.4em 0; font-size: .92em; }
|
|
.article th, .article td { border: 1px solid var(--border); padding: .55em .8em; text-align: left; vertical-align: top; }
|
|
.article th { background: var(--bg-soft); font-weight: 650; }
|
|
.article hr { border: none; border-top: 1px solid var(--border); margin: 2.2em 0; }
|
|
.article strong { font-weight: 700; }
|
|
.article em { font-style: italic; }
|
|
/* Annotation mode: highlight annotatable elements on hover, mark annotated ones */
|
|
.article [data-anchor-id] { position: relative; transition: background .08s, outline .08s; border-radius: 3px; }
|
|
body.ann-mode .article [data-anchor-id] { cursor: pointer; }
|
|
body.ann-mode .article [data-anchor-id]:hover {
|
|
outline: 2px dashed var(--accent); outline-offset: 2px; background: var(--accent-soft);
|
|
}
|
|
.article [data-anchor-id].annotated {
|
|
background: var(--amber-soft);
|
|
outline: 1px solid var(--amber); outline-offset: 1px;
|
|
}
|
|
.article [data-anchor-id].annotated::after {
|
|
content: attr(data-ann-count); position: absolute; right: -22px; top: 2px;
|
|
background: var(--amber); color: #fff; font-size: 10px; font-weight: 700;
|
|
padding: 1px 6px; border-radius: 99px; font-family: var(--sans);
|
|
}
|
|
body.ann-mode .article [data-anchor-id].annotated:hover { outline-color: var(--amber); }
|
|
.article [data-anchor-id].flash {
|
|
animation: flash 1.6s ease-out;
|
|
}
|
|
@keyframes flash {
|
|
0% { background: var(--accent-soft); outline: 2px solid var(--accent); }
|
|
100% { background: var(--amber-soft); outline: 1px solid var(--amber); }
|
|
}
|
|
/* Form popover */
|
|
.ann-form { position: fixed; z-index: 200; background: var(--bg-elev); border: 1px solid var(--border-strong);
|
|
border-radius: 8px; padding: 14px; box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
|
width: 380px; max-width: calc(100vw - 24px); display: none; flex-direction: column; gap: 10px;
|
|
font-family: var(--sans); }
|
|
.ann-form.visible { display: flex; }
|
|
.ann-form-section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
|
color: var(--text-mute); font-weight: 600; margin-bottom: 3px; }
|
|
.ann-form-section-value { font-size: 13px; color: var(--text-dim); font-style: italic; }
|
|
.ann-form-snippet-text { margin: 0; padding: 6px 10px; border-left: 3px solid var(--accent);
|
|
background: var(--bg); border-radius: 0 4px 4px 0; font-family: var(--mono); font-size: 12px;
|
|
color: var(--text); max-height: 100px; overflow-y: auto; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
|
|
.ann-form-intents { display: flex; gap: 6px; }
|
|
.ann-intent { flex: 1; padding: 7px 10px; border-radius: 5px; border: 1px solid var(--border);
|
|
background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
.ann-intent:hover { color: var(--text); border-color: var(--border-strong); }
|
|
.ann-intent[data-intent="fiks"].selected { background: var(--red); color: #fff; border-color: var(--red); }
|
|
.ann-intent[data-intent="endre"].selected { background: var(--orange); color: #fff; border-color: var(--orange); }
|
|
.ann-intent[data-intent="spørsmål"].selected { background: var(--blue); color: #fff; border-color: var(--blue); }
|
|
.ann-form-comment { width: 100%; min-height: 80px; padding: 8px 10px;
|
|
font-family: inherit; font-size: 13px; line-height: 1.5; color: var(--text);
|
|
background: var(--bg); border: 1px solid var(--border); border-radius: 5px; resize: vertical; }
|
|
.ann-form-comment:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
|
|
.ann-form-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
|
.btn { padding: 6px 14px; border-radius: 5px; border: 1px solid var(--border);
|
|
background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
.btn:hover { color: var(--text); border-color: var(--border-strong); }
|
|
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
.btn.primary:hover:not(:disabled) { filter: brightness(1.1); color: #fff; }
|
|
.btn.primary:disabled { background: var(--bg-soft); color: var(--text-mute); border-color: var(--border); cursor: not-allowed; filter: none; }
|
|
/* Annotations panel (slide-in sidebar) */
|
|
.ann-panel { position: fixed; top: 0; right: 0; bottom: 0; width: 420px; max-width: 100vw;
|
|
background: var(--bg-elev); border-left: 1px solid var(--border); z-index: 150;
|
|
transform: translateX(100%); transition: transform .2s ease;
|
|
display: flex; flex-direction: column; box-shadow: -4px 0 20px rgba(0,0,0,0.15); }
|
|
.ann-panel.open { transform: translateX(0); }
|
|
.ann-panel-head { display: flex; align-items: center; justify-content: space-between;
|
|
padding: 14px 18px; border-bottom: 1px solid var(--border); }
|
|
.ann-panel-head h2 { font-size: 14px; font-weight: 650; margin: 0; }
|
|
.ann-panel-body { flex: 1; overflow-y: auto; padding: 12px 14px; }
|
|
.ann-panel-foot { display: flex; justify-content: space-between; gap: 8px;
|
|
padding: 12px 14px; border-top: 1px solid var(--border); }
|
|
.ann-panel-empty { color: var(--text-mute); font-size: 13px; text-align: center; padding: 32px 12px;
|
|
font-style: italic; line-height: 1.5; }
|
|
.ann-section { margin: 12px 0 6px; font-size: 11px; font-weight: 650; text-transform: uppercase;
|
|
letter-spacing: 0.04em; color: var(--text-mute); padding: 0 4px; }
|
|
.ann-section:first-child { margin-top: 0; }
|
|
.ann-item { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
|
padding: 10px 12px; margin-bottom: 8px; cursor: pointer; }
|
|
.ann-item:hover { border-color: var(--border-strong); }
|
|
.ann-item .ann-item-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; gap: 6px; }
|
|
.ann-item-intent { font-size: 10px; font-weight: 700; text-transform: uppercase;
|
|
letter-spacing: 0.04em; padding: 2px 8px; border-radius: 99px; }
|
|
.ann-item-intent.fiks { background: var(--red-soft); color: var(--red); }
|
|
.ann-item-intent.endre { background: var(--orange-soft); color: var(--orange); }
|
|
.ann-item-intent.spørsmål { background: var(--blue-soft); color: var(--blue); }
|
|
.ann-item-delete { background: transparent; border: none; color: var(--text-mute);
|
|
cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
|
.ann-item-delete:hover { color: var(--red); background: var(--red-soft); }
|
|
.ann-item-snippet { font-family: var(--mono); font-size: 11px; color: var(--text-mute);
|
|
margin: 0 0 6px; line-height: 1.5; padding: 4px 8px; background: var(--bg-soft);
|
|
border-left: 2px solid var(--border-strong); border-radius: 0 4px 4px 0;
|
|
max-height: 60px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; }
|
|
.ann-item-comment { font-size: 13px; color: var(--text); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
|
|
.ann-item-comment.empty { color: var(--text-mute); font-style: italic; }
|
|
/* Toast */
|
|
.ann-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
|
|
background: var(--text); color: var(--bg-elev); padding: 9px 16px; border-radius: 6px;
|
|
font-size: 13px; font-weight: 500; opacity: 0; pointer-events: none;
|
|
transition: opacity .2s, transform .2s; z-index: 300; }
|
|
.ann-toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
/* Overlay (form backdrop) */
|
|
.ann-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 100;
|
|
opacity: 0; pointer-events: none; transition: opacity .15s; }
|
|
.ann-overlay.visible { opacity: 1; pointer-events: auto; }
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 6px; }
|
|
::-webkit-scrollbar-thumb:hover { background: var(--text-mute); }
|
|
/* Print: hide annotation chrome, show article only */
|
|
@media print {
|
|
.topbar, .ann-form, .ann-panel, .ann-toast, .ann-overlay, .article-help { display: none !important; }
|
|
.article-wrap { max-width: none; padding: 0; }
|
|
body { background: #fff; color: #000; }
|
|
}
|
|
`.trim();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Embedded JS app. Uses concatenation (no template literals) to avoid
|
|
// backtick collisions with the outer mjs string assembly.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const APP_JS = `
|
|
const STORAGE_KEY = 'voyage-annotate:v2:' + ARTIFACT_PATH;
|
|
const INTENT_LABELS = { fiks: 'Fiks', endre: 'Endre', 'spørsmål': 'Spørsmål' };
|
|
const INTENT_ORDER = ['fiks', 'endre', 'spørsmål'];
|
|
|
|
let annotations = [];
|
|
let nextId = 1;
|
|
let mode = true;
|
|
let currentTarget = null;
|
|
let currentSection = null;
|
|
let currentSnippet = null;
|
|
let currentIntent = null;
|
|
|
|
// ── Storage ──
|
|
function loadState() {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return;
|
|
const data = JSON.parse(raw);
|
|
if (data && Array.isArray(data.annotations)) {
|
|
annotations = data.annotations;
|
|
nextId = data.nextId || (annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
function saveState() {
|
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: annotations, nextId: nextId })); } catch (e) {}
|
|
}
|
|
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
|
|
// ── DOM refs ──
|
|
const body = document.body;
|
|
const article = document.getElementById('article');
|
|
const form = document.getElementById('ann-form');
|
|
const formSection = document.getElementById('ann-form-section');
|
|
const formSnippet = document.getElementById('ann-form-snippet');
|
|
const formComment = document.getElementById('ann-form-comment');
|
|
const formSave = document.getElementById('ann-form-save');
|
|
const formCancel = document.getElementById('ann-form-cancel');
|
|
const intents = document.querySelectorAll('.ann-intent');
|
|
const panel = document.getElementById('ann-panel');
|
|
const panelBody = document.getElementById('ann-panel-body');
|
|
const panelCloseBtn = document.getElementById('ann-panel-close');
|
|
const openPanelBtn = document.getElementById('open-panel');
|
|
const clearAllBtn = document.getElementById('ann-clear-all');
|
|
const copyBtn = document.getElementById('ann-copy');
|
|
const annToggle = document.getElementById('ann-toggle');
|
|
const annToggleLabel = document.getElementById('ann-toggle-label');
|
|
const annBadge = document.getElementById('ann-badge');
|
|
const toast = document.getElementById('ann-toast');
|
|
const overlay = document.getElementById('ann-overlay');
|
|
|
|
// ── Section lookup ──
|
|
function findSection(el) {
|
|
let p = el;
|
|
while (p) {
|
|
if (p.previousElementSibling) {
|
|
let s = p.previousElementSibling;
|
|
while (s) {
|
|
if (s.matches && (s.matches('h1') || s.matches('h2'))) return s.textContent.trim();
|
|
s = s.previousElementSibling;
|
|
}
|
|
}
|
|
p = p.parentElement;
|
|
if (p && p.tagName === 'ARTICLE') break;
|
|
}
|
|
// Fallback: first h1 in article
|
|
const firstH = article.querySelector('h1, h2');
|
|
return firstH ? firstH.textContent.trim() : '(top)';
|
|
}
|
|
|
|
// ── Snippet from selection or element text ──
|
|
function captureSnippet(el) {
|
|
const sel = window.getSelection();
|
|
if (sel && sel.toString().trim().length > 0 && el.contains(sel.anchorNode)) {
|
|
return sel.toString().trim().slice(0, 300);
|
|
}
|
|
return (el.textContent || '').trim().slice(0, 200);
|
|
}
|
|
|
|
// ── Form open/close ──
|
|
function openForm(evt, target) {
|
|
currentTarget = target;
|
|
currentSection = findSection(target);
|
|
currentSnippet = captureSnippet(target);
|
|
currentIntent = null;
|
|
formSection.textContent = currentSection || '(top)';
|
|
formSnippet.textContent = currentSnippet || '(empty)';
|
|
formComment.value = '';
|
|
intents.forEach(function(b) { b.classList.remove('selected'); });
|
|
formSave.disabled = true;
|
|
|
|
// Position near the click (clamped to viewport)
|
|
const fw = 380, fh = 320;
|
|
let x = evt.clientX + 14;
|
|
let y = evt.clientY + 14;
|
|
if (x + fw > window.innerWidth) x = window.innerWidth - fw - 12;
|
|
if (y + fh > window.innerHeight) y = Math.max(12, window.innerHeight - fh - 12);
|
|
if (x < 12) x = 12;
|
|
if (y < 12) y = 12;
|
|
form.style.left = x + 'px';
|
|
form.style.top = y + 'px';
|
|
form.classList.add('visible');
|
|
overlay.classList.add('visible');
|
|
setTimeout(function() { formComment.focus(); }, 50);
|
|
}
|
|
function closeForm() {
|
|
form.classList.remove('visible');
|
|
overlay.classList.remove('visible');
|
|
currentTarget = null;
|
|
currentSection = null;
|
|
currentSnippet = null;
|
|
currentIntent = null;
|
|
}
|
|
|
|
// ── Save ──
|
|
function saveAnnotation() {
|
|
if (!currentIntent || !currentTarget) return;
|
|
const a = {
|
|
id: nextId++,
|
|
anchorId: currentTarget.getAttribute('data-anchor-id'),
|
|
section: currentSection || '(top)',
|
|
snippet: currentSnippet || '',
|
|
intent: currentIntent,
|
|
comment: (formComment.value || '').trim(),
|
|
ts: new Date().toISOString(),
|
|
};
|
|
annotations.push(a);
|
|
saveState();
|
|
closeForm();
|
|
refreshArticleAnnotations();
|
|
renderPanel();
|
|
updateCounts();
|
|
showToast('Annotasjon lagret (' + annotations.length + ')');
|
|
}
|
|
|
|
// ── Delete ──
|
|
function deleteAnnotation(id) {
|
|
annotations = annotations.filter(function(a) { return a.id !== id; });
|
|
saveState();
|
|
refreshArticleAnnotations();
|
|
renderPanel();
|
|
updateCounts();
|
|
showToast('Annotasjon slettet');
|
|
}
|
|
|
|
// ── Refresh article markers ──
|
|
function refreshArticleAnnotations() {
|
|
// Clear all current markers
|
|
article.querySelectorAll('[data-anchor-id].annotated').forEach(function(el) {
|
|
el.classList.remove('annotated');
|
|
el.removeAttribute('data-ann-count');
|
|
});
|
|
// Group by anchorId
|
|
const byAnchor = {};
|
|
for (const a of annotations) {
|
|
if (!a.anchorId) continue;
|
|
if (!byAnchor[a.anchorId]) byAnchor[a.anchorId] = 0;
|
|
byAnchor[a.anchorId]++;
|
|
}
|
|
for (const anchorId in byAnchor) {
|
|
const el = article.querySelector('[data-anchor-id="' + CSS.escape(anchorId) + '"]');
|
|
if (el) {
|
|
el.classList.add('annotated');
|
|
el.setAttribute('data-ann-count', byAnchor[anchorId]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Sidebar panel render ──
|
|
function renderPanel() {
|
|
if (annotations.length === 0) {
|
|
panelBody.innerHTML = '<div class="ann-panel-empty">No annotations yet.<br><br>Click any heading, paragraph, list item, or quote in the article to add one.</div>';
|
|
return;
|
|
}
|
|
// Group by section (preserve insertion order)
|
|
const groups = [];
|
|
const groupMap = {};
|
|
// Sort by document order using anchorId numerical suffix
|
|
const sorted = annotations.slice().sort(function(a, b) {
|
|
const ai = parseInt((a.anchorId || '').replace('anch-', ''), 10) || 0;
|
|
const bi = parseInt((b.anchorId || '').replace('anch-', ''), 10) || 0;
|
|
if (ai !== bi) return ai - bi;
|
|
return a.id - b.id;
|
|
});
|
|
for (const a of sorted) {
|
|
if (!groupMap[a.section]) {
|
|
groupMap[a.section] = { section: a.section, items: [] };
|
|
groups.push(groupMap[a.section]);
|
|
}
|
|
groupMap[a.section].items.push(a);
|
|
}
|
|
let html = '';
|
|
for (const g of groups) {
|
|
html += '<div class="ann-section">' + escHtml(g.section) + '</div>';
|
|
for (const a of g.items) {
|
|
html += '<div class="ann-item" data-anchor-id="' + escHtml(a.anchorId || '') + '" data-id="' + a.id + '">'
|
|
+ '<div class="ann-item-head">'
|
|
+ '<span class="ann-item-intent ' + escHtml(a.intent) + '">' + escHtml(INTENT_LABELS[a.intent] || a.intent) + '</span>'
|
|
+ '<button class="ann-item-delete" data-del="' + a.id + '" title="Delete">✕</button>'
|
|
+ '</div>'
|
|
+ '<blockquote class="ann-item-snippet">' + escHtml(a.snippet || '(empty)') + '</blockquote>'
|
|
+ '<div class="ann-item-comment' + (a.comment ? '' : ' empty') + '">' + escHtml(a.comment || '(no comment)') + '</div>'
|
|
+ '</div>';
|
|
}
|
|
}
|
|
panelBody.innerHTML = html;
|
|
|
|
panelBody.querySelectorAll('.ann-item-delete').forEach(function(b) {
|
|
b.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
if (confirm('Delete this annotation?')) deleteAnnotation(parseInt(b.dataset.del, 10));
|
|
});
|
|
});
|
|
panelBody.querySelectorAll('.ann-item').forEach(function(card) {
|
|
card.addEventListener('click', function() {
|
|
const anchor = card.getAttribute('data-anchor-id');
|
|
const el = article.querySelector('[data-anchor-id="' + CSS.escape(anchor) + '"]');
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
el.classList.remove('flash');
|
|
void el.offsetWidth;
|
|
el.classList.add('flash');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Counts + toggle label ──
|
|
function updateCounts() {
|
|
annBadge.textContent = String(annotations.length);
|
|
copyBtn.disabled = annotations.length === 0;
|
|
}
|
|
|
|
function setMode(on) {
|
|
mode = on;
|
|
body.classList.toggle('ann-mode', on);
|
|
annToggleLabel.textContent = on ? 'Annotation mode: ON' : 'Annotation mode: OFF';
|
|
if (!on) closeForm();
|
|
}
|
|
|
|
// ── Toast ──
|
|
function showToast(msg) {
|
|
toast.textContent = msg;
|
|
toast.classList.add('visible');
|
|
setTimeout(function() { toast.classList.remove('visible'); }, 1800);
|
|
}
|
|
|
|
// ── Copy Prompt ──
|
|
function buildPromptMarkdown() {
|
|
if (annotations.length === 0) return '';
|
|
const sorted = annotations.slice().sort(function(a, b) {
|
|
const ai = parseInt((a.anchorId || '').replace('anch-', ''), 10) || 0;
|
|
const bi = parseInt((b.anchorId || '').replace('anch-', ''), 10) || 0;
|
|
if (ai !== bi) return ai - bi;
|
|
return a.id - b.id;
|
|
});
|
|
let p = 'Please revise the voyage artifact at \\\`' + ARTIFACT_PATH + '\\\` with the operator annotations below.\\n';
|
|
p += 'Each annotation has an intent — **Fiks** (something is wrong / fix it), **Endre** (change wording/content),\\n';
|
|
p += 'or **Spørsmål** (operator question — clarify or answer). The quote shows what the operator anchored to.\\n';
|
|
p += 'Treat the operator notes as authoritative direction.\\n\\n';
|
|
p += '## Annotations (' + annotations.length + ' total)\\n\\n';
|
|
let n = 0;
|
|
for (const a of sorted) {
|
|
n++;
|
|
p += '### ' + n + '. [' + (INTENT_LABELS[a.intent] || a.intent) + '] Section: ' + a.section + '\\n';
|
|
if (a.snippet) p += 'Quote: «' + a.snippet + '»\\n';
|
|
p += 'Comment: ' + (a.comment || '(no comment)') + '\\n\\n';
|
|
}
|
|
return p;
|
|
}
|
|
|
|
async function copyPrompt() {
|
|
const md = buildPromptMarkdown();
|
|
if (!md) return;
|
|
try {
|
|
await navigator.clipboard.writeText(md);
|
|
showToast('Prompt copied (' + annotations.length + ' annotation' + (annotations.length === 1 ? '' : 's') + ')');
|
|
} catch (e) {
|
|
// Fallback
|
|
const ta = document.createElement('textarea');
|
|
ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
document.body.appendChild(ta); ta.select();
|
|
try { document.execCommand('copy'); showToast('Prompt copied'); } catch (e2) { alert('Copy failed: ' + e2.message); }
|
|
ta.remove();
|
|
}
|
|
}
|
|
|
|
// ── Wiring ──
|
|
article.addEventListener('click', function(e) {
|
|
if (!mode) return;
|
|
const target = e.target.closest('[data-anchor-id]');
|
|
if (!target) return;
|
|
// Don't open form when clicking inside an already-open form (overlay catches outside clicks)
|
|
if (e.target.closest('.ann-form')) return;
|
|
// Don't open form when clicking a link the user wants to follow — but only if they didn't select text
|
|
if (e.target.tagName === 'A' && (!window.getSelection() || window.getSelection().toString().trim().length === 0)) {
|
|
// Allow link clicks in mode if no selection
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
openForm(e, target);
|
|
});
|
|
|
|
intents.forEach(function(b) {
|
|
b.addEventListener('click', function() {
|
|
intents.forEach(function(x) { x.classList.remove('selected'); });
|
|
b.classList.add('selected');
|
|
currentIntent = b.dataset.intent;
|
|
formSave.disabled = false;
|
|
});
|
|
});
|
|
|
|
formSave.addEventListener('click', saveAnnotation);
|
|
formCancel.addEventListener('click', closeForm);
|
|
overlay.addEventListener('click', closeForm);
|
|
|
|
formComment.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !formSave.disabled) {
|
|
saveAnnotation();
|
|
} else if (e.key === 'Escape') {
|
|
closeForm();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape' && form.classList.contains('visible')) closeForm();
|
|
});
|
|
|
|
annToggle.addEventListener('click', function() { setMode(!mode); });
|
|
|
|
openPanelBtn.addEventListener('click', function() {
|
|
panel.classList.toggle('open');
|
|
});
|
|
panelCloseBtn.addEventListener('click', function() { panel.classList.remove('open'); });
|
|
|
|
clearAllBtn.addEventListener('click', function() {
|
|
if (annotations.length === 0) return;
|
|
if (confirm('Remove all ' + annotations.length + ' annotations? This cannot be undone.')) {
|
|
annotations = [];
|
|
saveState();
|
|
refreshArticleAnnotations();
|
|
renderPanel();
|
|
updateCounts();
|
|
showToast('All annotations cleared');
|
|
}
|
|
});
|
|
|
|
copyBtn.addEventListener('click', copyPrompt);
|
|
|
|
// ── Init ──
|
|
loadState();
|
|
refreshArticleAnnotations();
|
|
renderPanel();
|
|
updateCounts();
|
|
setMode(true);
|
|
`.trim();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function parseArgs(argv) {
|
|
const args = { input: null, out: null, help: false };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--out') args.out = argv[++i];
|
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
else if (!args.input) args.input = a;
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function render(inputPath, outputPath) {
|
|
if (!existsSync(inputPath)) {
|
|
process.stderr.write('annotate: input not found: ' + inputPath + '\n');
|
|
process.exit(2);
|
|
}
|
|
const text = readFileSync(inputPath, 'utf-8');
|
|
const html = buildHtml(resolve(inputPath), text);
|
|
const out = outputPath || inputPath.replace(/\.md$/, '.html');
|
|
writeFileSync(out, html);
|
|
return out;
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help || !args.input) {
|
|
process.stdout.write(
|
|
'Usage: annotate <artifact.md> [--out <file.html>]\n\n'
|
|
+ 'Builds a self-contained operator-annotation HTML for a voyage\n'
|
|
+ 'artifact. The operator opens the HTML, selects text or clicks any\n'
|
|
+ 'element, picks an intent (Fiks / Endre / Spørsmål), writes a\n'
|
|
+ 'comment, and copies a structured prompt to paste back into Claude.\n'
|
|
+ 'Annotations persist in localStorage per artifact path.\n\n'
|
|
+ 'Default output: <input-basename>.html next to input.\n',
|
|
);
|
|
process.exit(args.help ? 0 : 2);
|
|
}
|
|
const out = render(args.input, args.out);
|
|
process.stdout.write(out + '\n');
|
|
}
|
|
|
|
export { render, buildHtml, renderMarkdown, parseArgs };
|