book(I6): cross-references — [text](#chapter-N) → page numbers

Markdown links pointing at in-book anchors get their link text
augmented with the destination's page number at pagination time.

Slug matching:
- _bookSlugify(s) = lowercase + non-alphanumeric → hyphen, trim
  edge dashes (matches GitHub-style heading-anchor convention).
- _computeBookSlugToPageMap(book) walks every reflow chapter with
  _skipXref:true to avoid recursion, building a slug → page-number
  map that registers:
    * Each chapter's slugified chapterTitle.
    * "chapter-N" aliases for entries with chapterNumber.
    * Each H1-H6 heading's slugified text (first occurrence wins).

Substitution sites:
- paginateChapter: when the chapter markdown contains `](#anchor)`
  and !opts._skipXref, rewrites every matching link to include
  " (p. N)" in its visible text. Renders through DOM tile thumbs,
  Reader Mode, and PDF real-text emission the same way.
- markdownToSafeHtml: live preview pane gets the same rewrite so
  the author sees page numbers as they type.
- Unknown slugs pass through unchanged so a typo'd anchor stays
  visible as a regular markdown link instead of silently breaking.

Phase I now has every spec'd feature except I4 (footnotes) and I5
(auto-index from inline ^[term] markers) shipped end-to-end.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-06-03 18:43:48 -04:00
1 parent 4b2f124fde
commit f3d8135a9a
1 file changed
+92 -3
+92 -3
View File
@@ -9903,6 +9903,58 @@
});
return entries;
}
// Slugify a heading / title into a lowercase-hyphen anchor —
// matches the GitHub-style #my-heading convention authors
// expect when writing cross-references.
function _bookSlugify(s) {
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
// Build a slug → page-number map for cross-reference resolution
// (I6). Chapter titles, "chapter-N" aliases, and every H1-H6
// heading register. Uses _skipXref:true on inner pagination so
// the map computation can't recurse.
function _computeBookSlugToPageMap(book) {
if (!book) return new Map();
const map = new Map();
const numbering = book.spec?.pageNumbering || {};
const enabled = numbering.enabled !== false;
const startAt = Number.isFinite(numbering.startAt) ? numbering.startAt : 1;
const firstNumberedIdx = (Number.isFinite(numbering.firstNumberedPage) ? numbering.firstNumberedPage : 1) - 1;
let printedNum = startAt;
(book.pages || []).forEach((p, manifestIdx) => {
const baseNumbered = enabled && !p.skipNumber && manifestIdx >= firstNumberedIdx;
if (p.type === 'reflow') {
let pag;
try { pag = paginateChapter(p, book, { _skipXref: true }); }
catch { pag = { pages: [] }; }
(pag.pages || []).forEach((page, idxInChapter) => {
const skipThis = baseNumbered && p.skipNumberOnFirstPage && idxInChapter === 0;
const numbered = baseNumbered && !skipThis;
const pageNumber = numbered ? printedNum : null;
if (idxInChapter === 0 && pageNumber != null) {
if (p.chapterTitle) {
const slug = _bookSlugify(p.chapterTitle);
if (slug) map.set(slug, pageNumber);
}
if (p.chapterNumber != null) {
map.set(`chapter-${p.chapterNumber}`, pageNumber);
}
}
if (pageNumber != null) {
for (const block of (page.blocks || [])) {
if (!/^h[1-6]$/.test(String(block.kind || ''))) continue;
const slug = _bookSlugify((block.lines || []).join(' '));
if (slug && !map.has(slug)) map.set(slug, pageNumber);
}
}
if (numbered) printedNum++;
});
} else if (baseNumbered) {
printedNum++;
}
});
return map;
}
function _formatBookTocMarkdown(entries) {
if (!entries.length) return '*No headings yet — add `# Heading` lines to your chapters and the TOC will fill in.*';
const lines = ['## Table of Contents', ''];
@@ -9935,6 +9987,27 @@
// distort the headings' page numbers via a "{toc}" paragraph.
md = md.replace(/^[ \t]*\{toc\}[ \t]*\n?/gm, '');
}
// Cross-reference substitution (I6). Markdown links pointing
// at an in-book anchor (`](#chapter-3)` etc) get their link
// text augmented with the destination's page number — slug
// matched against the same slugifier the build-time map uses.
// _skipXref:true short-circuits recursion from the map builder.
if (!opts._skipXref && /\]\(#[^)]+\)/.test(md)) {
try {
const xrefMap = _computeBookSlugToPageMap(book);
if (xrefMap.size) {
md = md.replace(/\[([^\]]*)\]\(#([^)]+)\)/g, (whole, text, href) => {
const slug = _bookSlugify(href);
const page = xrefMap.get(slug);
if (page != null && text) return `[${text} (p. ${page})](#${href})`;
if (page != null) return `[p. ${page}](#${href})`;
return whole;
});
}
} catch (err) {
console.warn('xref substitution failed', err);
}
}
const styles = (book?.spec?.textStyles) || defaultBookTextStyles();
const masterId = (typeof effectiveMasterIdForPage === 'function')
? effectiveMasterIdForPage(entry)
@@ -11717,9 +11790,25 @@
// the rendered chapter will look like with the auto-generated
// table of contents in place.
let md = String(src || '');
if (state.templateMode === 'book' && state.book && /^[ \t]*\{toc\}[ \t]*$/m.test(md)) {
const tocMd = _formatBookTocMarkdown(_computeBookTocEntries(state.book));
md = md.replace(/^[ \t]*\{toc\}[ \t]*$/gm, tocMd);
if (state.templateMode === 'book' && state.book) {
if (/^[ \t]*\{toc\}[ \t]*$/m.test(md)) {
const tocMd = _formatBookTocMarkdown(_computeBookTocEntries(state.book));
md = md.replace(/^[ \t]*\{toc\}[ \t]*$/gm, tocMd);
}
// Cross-reference substitution in the live preview too.
if (/\]\(#[^)]+\)/.test(md)) {
try {
const xrefMap = _computeBookSlugToPageMap(state.book);
if (xrefMap.size) {
md = md.replace(/\[([^\]]*)\]\(#([^)]+)\)/g, (whole, text, href) => {
const page = xrefMap.get(_bookSlugify(href));
if (page != null && text) return `[${text} (p. ${page})](#${href})`;
if (page != null) return `[p. ${page}](#${href})`;
return whole;
});
}
} catch {}
}
}
const html = window.marked.parse(md, {
mangle: false,