// Map a stored comment anchor back onto its SOURCE markdown so an agent can read // comments from the CLI with the relevant slice of the file in hand. // // The viewer anchors comments to RENDERED text ({quote, prefix, suffix}; see // manifest.ts) and re-finds the quote in the live DOM. The CLI has no DOM, so we // approximate the same match against a normalized copy of the source: strip the // inline/block markdown syntax the renderer would have dropped, collapse // whitespace (paragraphs render as one line), and search for the quote. From the // matched line range we return the enclosing markdown block (the paragraph / list // / blockquote the quote sits in, blank-line delimited) plus the nearest heading // for orientation — that block is the editable unit handed back to the agent. import type { Comment } from "./manifest.ts"; export interface LocatedComment extends Comment { /** True when the quote was found in the source. */ matched: boolean; /** 1-based source line where the quote starts; null when unmatched. */ line: number | null; /** 1-based source line where the quote ends; null when unmatched. */ endLine: number | null; /** Nearest enclosing heading text (orientation only); null if none / unmatched. */ heading: string | null; /** The markdown block (paragraph/list/quote) the quote sits in; null when unmatched. */ context: string | null; /** 1-based source line range of `context` (inclusive); null when unmatched. */ contextLines: [number, number] | null; } const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/; /** Strip inline markdown the renderer collapses to plain text. * HTML comments are deliberately NOT stripped here: the markdown renderer keeps * them visible (painted muted, see .md-comment), so a quote can legitimately * contain one and must still match this projection. buildHtmlIndex blanks them * because a real browser hides them — that divergence is intentional. */ function stripInline(s: string): string { return s .replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, name, alias) => (alias ?? name).trim()) // wikilinks → display text .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") // images → alt .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → text .replace(/`([^`]+)`/g, "$1") // inline code .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/__([^_]+)__/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .replace(/_([^_]+)_/g, "$1") .replace(/~~([^~]+)~~/g, "$1"); } /** Strip leading block markers (heading hashes, blockquote, list bullets). */ function stripBlock(line: string): string { return line .replace(/^\s{0,3}#{1,6}\s+/, "") .replace(/^\s*>\s?/, "") .replace(/^\s*([-*+]|\d+[.)])\s+/, ""); } const isFence = (line: string) => /^\s*(```|~~~)/.test(line); const collapseWs = (s: string) => s.replace(/\s+/g, " "); const normWs = (s: string) => collapseWs(s).trim(); /** A source file parsed once: lines, per-line fenced-code state, and a * whitespace-collapsed/syntax-stripped projection with a char→line map. Built * with `buildIndex` so a file with many comments pays the parse cost only once. */ export interface SourceIndex { /** Which projection built this — markdown and html strip different syntax and * disagree on what a "block" is, so every consumer has to branch on it. */ kind: "markdown" | "html"; lines: string[]; /** Per-line "inside a fenced code block" flag (so `#` in code isn't a heading). */ inFence: boolean[]; /** Rendered-text projection: hit positions resolve to lines via `lineOf`. */ text: string; lineOf: number[]; } /** Pick the projection from the file's extension. The viewer anchors comments to * whatever it rendered, so the CLI has to un-render the same language. */ export function buildIndexFor(filePath: string, source: string): SourceIndex { return /\.html?$/i.test(filePath) ? buildHtmlIndex(source) : buildIndex(source); } export function buildIndex(source: string): SourceIndex { const lines = source.split(/\r?\n/); const inFence: boolean[] = []; let fence = false; for (let i = 0; i < lines.length; i++) { inFence[i] = fence; if (isFence(lines[i]!)) fence = !fence; } // Project to rendered text + char→line map: strip markdown outside fences, // keep fenced lines verbatim, collapse all whitespace (paragraphs render as // one line, so a line/paragraph break becomes a single space between words). return { kind: "markdown", lines, inFence, ...project(lines, (raw, i) => isFence(raw) ? null : inFence[i] ? raw : stripInline(stripBlock(raw)), ), }; } /** Collapse a per-line render into the flat rendered text plus its char→line map. * Both projections share it so locateComment's lineOf lookup can only ever mean one * thing. `render` returns null for a line that renders to nothing (a fence marker). */ function project( lines: string[], render: (raw: string, i: number) => string | null, ): { text: string; lineOf: number[] } { const chars: string[] = []; const lineOf: number[] = []; let lastSpace = true; lines.forEach((raw, idx) => { const str = render(raw, idx); if (str == null) return; for (const ch of str) { if (/\s/.test(ch)) { if (lastSpace) continue; chars.push(" "); lineOf.push(idx + 1); lastSpace = true; } else { chars.push(ch); lineOf.push(idx + 1); lastSpace = false; } } // A line end is a word boundary in the rendered text (so is a tag boundary, // which the html projection has already turned into a space). if (!lastSpace) { chars.push(" "); lineOf.push(idx + 1); lastSpace = true; } }); return { text: chars.join(""), lineOf }; } const HTML_HEADING_RE = /]*>([\s\S]*?)<\/h[1-6]>/i; const HTML_HEADING_OPEN = /]*>/i; /** How many lines a heading may span. Formatters and generated pages routinely put * the text on its own line, so matching within one line misses those entirely. */ const HTML_HEADING_SPAN = 8; const ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", }; /** Decode the entities a browser would have turned back into text — the anchor was * captured from the RENDERED page, so its quote holds the character, not the entity. */ function decodeEntities(s: string): string { return s.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (m, ref: string) => { if (ref[0] === "#") { const code = ref[1]?.toLowerCase() === "x" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10); // Out-of-range is not merely un-decodable: fromCodePoint THROWS, and this // runs during `scratch ui`/`export` render, so one bad reference would take // the whole command down instead of leaving the entity as written. return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m; } return ENTITIES[ref.toLowerCase()] ?? m; }); } /** Project an HTML source to the text a browser would render: drop script/style * bodies and comments (never displayed), drop tags, decode entities, collapse * whitespace. Line-accurate because the blanking is length-preserving per line — * every kept char keeps the source line it came from. * * Limitation worth knowing: text a script GENERATES is not in the source, so a * comment anchored to it cannot be located here and reports as unmatched. */ export function buildHtmlIndex(source: string): SourceIndex { const lines = source.split(/\r?\n/); // Blank out non-rendered regions in the FULL text first (they span lines), keeping // newlines so the line numbering below still lines up with the original. const keepNewlines = (m: string) => m.replace(/[^\n]/g, " "); const cleaned = source .replace(//g, keepNewlines) .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, keepNewlines) .replace(/<[^>]*>/g, keepNewlines); return { kind: "html", lines, inFence: lines.map(() => false), ...project(cleaned.split(/\r?\n/), decodeEntities), }; } /** Nearest heading text at or above a 1-based source line (orientation only). */ export function headingFor(idx: SourceIndex, line: number): { heading: string | null; level: number } { for (let i = Math.min(line, idx.lines.length) - 1; i >= 0; i--) { if (idx.inFence[i]) continue; if (idx.kind === "html") { const open = idx.lines[i]!.match(HTML_HEADING_OPEN); if (open) { // Match forward from the opening tag, not within its line — a closing tag // on a later line is the common shape in generated markup. const h = idx.lines.slice(i, i + HTML_HEADING_SPAN).join("\n").match(HTML_HEADING_RE); const text = h ? normWs(decodeEntities(h[1]!.replace(/<[^>]*>/g, " "))) : ""; if (text) return { heading: text, level: Number(open[1]) }; } continue; } const m = idx.lines[i]!.match(HEADING_RE); if (m) return { heading: m[2]!.trim(), level: m[1]!.length }; } return { heading: null, level: 0 }; } /** The markdown block (paragraph / list / blockquote) covering a 1-based line * range — expanded outward to the surrounding blank lines, the natural editable * unit. Headings and fence markers act as hard boundaries. */ export function blockFor( idx: SourceIndex, startLine: number, endLine: number, ): { text: string; lines: [number, number] } { const { lines, inFence } = idx; // HTML has no blank-line block structure — indentation and newlines are free — // so the matched lines ARE the unit. In practice a text run sits inside one // element on one line, which is exactly what an agent needs to edit. if (idx.kind === "html") { const s = Math.max(0, startLine - 1); const e = Math.min(endLine, lines.length) - 1; return { text: lines.slice(s, e + 1).join("\n").trim(), lines: [s + 1, e + 1] }; } const blank = (i: number) => lines[i]!.trim() === ""; const bound = (i: number) => !inFence[i] && (isFence(lines[i]!) || HEADING_RE.test(lines[i]!)); let s = startLine - 1; let e = Math.min(endLine, lines.length) - 1; while (s > 0 && !blank(s - 1) && !bound(s - 1)) s--; while (e < lines.length - 1 && !blank(e + 1) && !bound(e + 1)) e++; return { text: lines.slice(s, e + 1).join("\n").trim(), lines: [s + 1, e + 1] }; } // Find a quote in the projection, disambiguating multiple matches by how many // chars of prefix/suffix match contiguously outward from the quote's boundary // (ties keep the first hit). Mirrors the viewer's cmtFindAnchor so the CLI and // the rendered view resolve the same occurrence. Returns -1 when not found. function findQuote(text: string, q: string, prefix: string, suffix: string): number { if (!q) return -1; const hits: number[] = []; for (let i = text.indexOf(q); i !== -1; i = text.indexOf(q, i + 1)) hits.push(i); if (hits.length <= 1) return hits[0] ?? -1; let best = hits[0]!; let bestScore = -1; for (const h of hits) { const before = text.slice(Math.max(0, h - prefix.length), h); const after = text.slice(h + q.length, h + q.length + suffix.length); let score = 0; for (let k = 1; k <= before.length; k++) { if (prefix[prefix.length - k] === before[before.length - k]) score++; else break; } for (let k = 0; k < after.length; k++) { if (suffix[k] === after[k]) score++; else break; } if (score > bestScore) { bestScore = score; best = h; } } return best; } /** One comment flattened for an agent: the human comment + quoted text + where it * anchors in the source. The shape `scratch comments --json` emits and the viewer * copies (Ctrl+Alt+C), so both stay byte-identical. */ export interface CommentItem { id: string; file: string; comment: string; quote: string; matched: boolean; line: number | null; section_heading: string | null; context: string | null; context_lines: string | null; } /** Resolve a file's comments into agent-friendly items: parse the source once, * locate each comment's quote, and flatten. Shared by the CLI (`cmdComments`) and * the viewer's copy-comments shortcut so their output matches exactly. */ export function toCommentItems(filePath: string, source: string, comments: Comment[]): CommentItem[] { const index = buildIndexFor(filePath, source); // parse once, reuse for all the file's comments return comments.map((c) => { const r = locateComment(index, c); return { id: c.id, file: filePath, comment: c.body, quote: c.anchor.quote.replace(/\s+/g, " ").trim(), matched: r.matched, line: r.line, section_heading: r.heading, context: r.context, context_lines: r.contextLines ? `${r.contextLines[0]}-${r.contextLines[1]}` : null, }; }); } const UNMATCHED = { matched: false, line: null, endLine: null, heading: null, context: null, contextLines: null } as const; /** Locate a comment's quote in a parsed source and resolve its enclosing block. */ export function locateComment(idx: SourceIndex, comment: Comment): LocatedComment { const { text, lineOf } = idx; // Prefix/suffix keep their boundary whitespace (only inner runs collapse) — the // space adjacent to the quote is what disambiguates between occurrences. // An html projection already dropped every tag, so the markdown syntax-stripper // would only corrupt the quote (underscores, asterisks and backticks are literal // text in an author's page). const strip = idx.kind === "html" ? (s: string) => s : stripInline; const prefix = collapseWs(strip(comment.anchor.prefix ?? "")); const suffix = collapseWs(strip(comment.anchor.suffix ?? "")); let q = normWs(strip(comment.anchor.quote)); let at = findQuote(text, q, prefix, suffix); if (at === -1 && comment.anchor.quote) { // Fallback: the quote may have already been plain (e.g. code) — try as-is. q = normWs(comment.anchor.quote); at = findQuote(text, q, prefix, suffix); } if (at === -1) return { ...comment, ...UNMATCHED }; const line = lineOf[at]!; const endLine = lineOf[Math.min(at + q.length - 1, lineOf.length - 1)]!; const block = blockFor(idx, line, endLine); return { ...comment, matched: true, line, endLine, heading: headingFor(idx, line).heading, context: block.text, contextLines: block.lines, }; }