feat(voyage): implement block-boundary-fallback for code-fence/table/list anchors

Step 17 of v4.3 playground plan. Pure function relocateAnchorsToBlockBoundaries
(text, anchors) detects atomic markdown blocks (fenced code, tables, deeply
nested lists) and relocates anchor-comment insertion to the line BEFORE block
opening rather than inside the block. Pure markdown-text -> markdown-text
transform (no DOM, no markdown-it dependency).

Companion test tests/integration/annotation-block-boundary.test.mjs extracts
the function via balanced-brace scan and exercises it through Function() —
7 unit tests covering empty anchors, outside-block stays, fenced-code
relocation, table relocation, deeply-nested list relocation, mixed
inside/outside, and shape contract.

Trace: SC6, research/04 Dim 3 (Notion block-level fallback), plan-critic
major #6 (DOM-vs-no-DOM contradiction resolved via pure-function design).
This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 17:04:27 +02:00
commit 75130fe979
3 changed files with 269 additions and 0 deletions

View file

@ -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, '&quot;') + '"');
if (d.intent) attrParts.push('intent="' + d.intent + '"');
var anchorLine = '<!-- voyage:anchor ' + attrParts.join(' ') + ' -->';
lines.splice(dl - 1, 0, anchorLine, '');
}
return lines.join('\n');
}
</script>
<link rel="stylesheet" href="vendor/playground-design-system/fonts.css">