// 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(/
/gi, "") //
renders as an element: Range.toString() adds no text for it
.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,
};
}