diff --git a/plugins/voyage/playground/voyage-playground.html b/plugins/voyage/playground/voyage-playground.html index 2baf445..4242d61 100644 --- a/plugins/voyage/playground/voyage-playground.html +++ b/plugins/voyage/playground/voyage-playground.html @@ -60,6 +60,101 @@ if (intent !== null && VOYAGE_ANCHOR_INTENTS.indexOf(intent) === -1) return null; return { id: attrs.id, target: attrs.target, line: lineNum, snippet: snippet, intent: intent }; } + + // Block-boundary fallback (Step 17). Pure markdown-text -> markdown-text transform. + // For each anchor whose line falls inside an atomic block (fenced code-block, + // table-row, or deeply-nested list), inject the anchor-comment at the line + // BEFORE block-opening rather than inside. Anchors outside atomic blocks + // inject at their original line. Mirrors addAnchors semantics from + // lib/parsers/anchor-parser.mjs but with block-boundary awareness. + function relocateAnchorsToBlockBoundaries(text, anchors) { + if (typeof text !== 'string') return text; + if (!Array.isArray(anchors) || anchors.length === 0) return text; + + var lines = text.split(/\r?\n/); + var FENCED_RE = /^\s*```/; + var TABLE_ROW_RE = /^\s*\|.*\|\s*$/; + var TABLE_SEP_RE = /^\s*\|[\s\-:|]+\|\s*$/; + var LIST_RE = /^(\s*)(?:[-*+]|\d+[.)])\s+/; + + var atomicRanges = []; + var inFence = false; + var fenceStart = -1; + var inTable = false; + var tableStart = -1; + + for (var i = 0; i < lines.length; i++) { + var ln = lines[i]; + if (FENCED_RE.test(ln)) { + if (!inFence) { inFence = true; fenceStart = i + 1; } + else { atomicRanges.push({ start: fenceStart, end: i + 1 }); inFence = false; fenceStart = -1; } + continue; + } + if (inFence) continue; + + if (!inTable) { + var nextLine = i + 1 < lines.length ? lines[i + 1] : ''; + if (TABLE_ROW_RE.test(ln) && TABLE_SEP_RE.test(nextLine)) { + inTable = true; + tableStart = i + 1; + } + } else if (!TABLE_ROW_RE.test(ln) || ln.trim() === '') { + atomicRanges.push({ start: tableStart, end: i }); + inTable = false; + tableStart = -1; + } + } + if (inTable) atomicRanges.push({ start: tableStart, end: lines.length }); + if (inFence && fenceStart > 0) atomicRanges.push({ start: fenceStart, end: lines.length }); + + // Deeply-nested list-items (indent >= 4 spaces = depth >= 2 in CommonMark) + for (var j = 0; j < lines.length; j++) { + var lm = lines[j].match(LIST_RE); + if (lm && lm[1].length >= 4) { + var nestStart = j + 1; + var k = j; + while (k + 1 < lines.length) { + var nm = lines[k + 1].match(LIST_RE); + if (nm && nm[1].length >= 2) k++; + else break; + } + atomicRanges.push({ start: nestStart, end: k + 1 }); + j = k; + } + } + + function insertionLine(line) { + var n = Number(line); + if (!Number.isInteger(n) || n < 1) return n; + for (var r = 0; r < atomicRanges.length; r++) { + var range = atomicRanges[r]; + if (n >= range.start && n <= range.end) { + return Math.max(1, range.start - 1); + } + } + return n; + } + + var adjusted = anchors.map(function (a) { + var newLine = insertionLine(a.line); + return Object.assign({}, a, { line: newLine }); + }); + + var sorted = adjusted.slice().sort(function (a, b) { + return (Number(b.line) || 0) - (Number(a.line) || 0); + }); + for (var s = 0; s < sorted.length; s++) { + var d = sorted[s]; + var dl = Number(d.line); + if (!dl || dl < 1 || dl > lines.length + 1) continue; + var attrParts = ['id="' + d.id + '"', 'target="' + (d.target || 'page') + '"', 'line="' + dl + '"']; + if (d.snippet) attrParts.push('snippet="' + String(d.snippet).slice(0, 80).replace(/"/g, '"') + '"'); + if (d.intent) attrParts.push('intent="' + d.intent + '"'); + var anchorLine = ''; + lines.splice(dl - 1, 0, anchorLine, ''); + } + return lines.join('\n'); + } diff --git a/plugins/voyage/tests/integration/annotation-block-boundary.test.mjs b/plugins/voyage/tests/integration/annotation-block-boundary.test.mjs new file mode 100644 index 0000000..3dd2285 --- /dev/null +++ b/plugins/voyage/tests/integration/annotation-block-boundary.test.mjs @@ -0,0 +1,168 @@ +// tests/integration/annotation-block-boundary.test.mjs +// Step 17 — verify relocateAnchorsToBlockBoundaries pure-function transforms +// markdown anchors away from atomic-block interiors (fenced code, tables, +// deeply-nested lists) toward the block-boundary line. +// +// Function lives in playground/voyage-playground.html as inline-script (file:// +// compat). We extract it via balanced-brace scan and exercise via Function(). + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..', '..'); +const HTML = join(ROOT, 'playground', 'voyage-playground.html'); + +function extractFunctionSource(text, fnName) { + const needle = `function ${fnName}`; + const start = text.indexOf(needle); + if (start === -1) return null; + const braceStart = text.indexOf('{', start); + if (braceStart === -1) return null; + let depth = 0; + for (let i = braceStart; i < text.length; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}') { + depth--; + if (depth === 0) return text.slice(start, i + 1); + } + } + return null; +} + +function loadRelocate() { + const html = readFileSync(HTML, 'utf-8'); + const src = extractFunctionSource(html, 'relocateAnchorsToBlockBoundaries'); + if (!src) throw new Error('relocateAnchorsToBlockBoundaries not found in HTML'); + // Function() factory creates an isolated scope; safe for pure function. + // eslint-disable-next-line no-new-func + const factory = new Function(`${src}; return relocateAnchorsToBlockBoundaries;`); + return factory(); +} + +const relocate = loadRelocate(); + +test('relocateAnchorsToBlockBoundaries returns input unchanged when anchors empty', () => { + const md = 'Line 1\nLine 2\nLine 3\n'; + assert.equal(relocate(md, []), md); +}); + +test('relocateAnchorsToBlockBoundaries leaves anchor outside atomic block at original line', () => { + const lines = []; + for (let i = 1; i <= 20; i++) lines.push(`Line ${i}`); + const md = lines.join('\n'); + const out = relocate(md, [{ id: 'ANN-0001', target: 'sec-a', line: 5 }]); + const outLines = out.split('\n'); + // Anchor injected at output line 5 (1-indexed = index 4); blank line at index 5 + assert.match(outLines[4], /