diff --git a/playground/voyage-playground.html b/playground/voyage-playground.html
index 2baf445..4242d61 100644
--- a/playground/voyage-playground.html
+++ b/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/tests/integration/annotation-block-boundary.test.mjs b/tests/integration/annotation-block-boundary.test.mjs
new file mode 100644
index 0000000..3dd2285
--- /dev/null
+++ b/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], /