@brandon_m_behring/book-scaffold-astro 4.28.0 → 4.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Structural authored-link discovery for `book-scaffold validate` (#190).
3
+ *
4
+ * Markdown and MDX are parsed into their real syntax trees so code examples,
5
+ * comments, nested labels, JSX expressions, and literal decoding follow the
6
+ * same grammar as the rendered book. Raw HTML is parsed only from ranges that
7
+ * the Markdown AST identified as HTML. This module never rewrites content.
8
+ */
9
+ import { parseFragment } from 'parse5';
10
+ import remarkMath from 'remark-math';
11
+ import remarkMdx from 'remark-mdx';
12
+ import remarkParse from 'remark-parse';
13
+ import { unified } from 'unified';
14
+
15
+ const ORIGIN = 'https://book-scaffold.invalid';
16
+ const processors = new Map();
17
+
18
+ /** Normalize the portion of Astro's `base` relevant to containment checks. */
19
+ export function normalizeAstroBase(base = '/') {
20
+ const value = typeof base === 'string' && base.length > 0 ? base : '/';
21
+ const withLeadingSlash = value.startsWith('/') ? value : `/${value}`;
22
+ const pathname = new URL(withLeadingSlash, ORIGIN).pathname;
23
+ return pathname.replace(/\/+$/, '') || '/';
24
+ }
25
+
26
+ /**
27
+ * Parse a browser-rooted same-origin target. A single leading backslash is
28
+ * included because WHATWG HTTP URL parsing treats it as a root slash. True
29
+ * protocol-relative URLs and inputs that resolve onto another host are not
30
+ * internal authored targets.
31
+ */
32
+ function rootTargetUrl(target) {
33
+ if (typeof target !== 'string') return null;
34
+ const value = target.trim();
35
+ if (!value.startsWith('/') && !value.startsWith('\\')) return null;
36
+ if (value.startsWith('//') || value.startsWith('\\\\')) return null;
37
+
38
+ try {
39
+ const url = new URL(value, ORIGIN);
40
+ return url.origin === ORIGIN ? url : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Browser-normalized pathname for a same-origin root target, else null. */
47
+ export function rootTargetPathname(target) {
48
+ const url = rootTargetUrl(target);
49
+ return url ? url.pathname.replace(/\/+$/, '') || '/' : null;
50
+ }
51
+
52
+ /**
53
+ * True only for a same-origin root target outside `base`.
54
+ * Protocols, protocol-relative URLs, fragments, relative URLs, and targets
55
+ * already under the configured base all return false.
56
+ */
57
+ export function rootTargetEscapesBase(target, base) {
58
+ const normalizedBase = normalizeAstroBase(base);
59
+ if (normalizedBase === '/') return false;
60
+
61
+ const pathname = rootTargetPathname(target);
62
+ if (pathname === null) return false;
63
+ return pathname !== normalizedBase && !pathname.startsWith(`${normalizedBase}/`);
64
+ }
65
+
66
+ /**
67
+ * Return a browser-normalized, base-contained suggestion for a violating
68
+ * target. Normalizing before prefixing prevents `..` (including percent-
69
+ * encoded dot segments) from escaping the base a second time.
70
+ */
71
+ export function suggestBaseContainedTarget(target, base) {
72
+ const normalizedBase = normalizeAstroBase(base);
73
+ const url = rootTargetUrl(target);
74
+ if (!url) return target;
75
+ const prefix = normalizedBase === '/' ? '' : normalizedBase;
76
+ return `${prefix}${url.pathname}${url.search}${url.hash}`;
77
+ }
78
+
79
+ function blankFrontmatter(content) {
80
+ return content.replace(
81
+ /^\uFEFF?---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/,
82
+ (match) => match.replace(/[^\r\n]/g, ' '),
83
+ );
84
+ }
85
+
86
+ function processorFor(format, math) {
87
+ const key = `${format}:${math ? 'math' : 'plain'}`;
88
+ if (!processors.has(key)) {
89
+ const processor = unified().use(remarkParse);
90
+ if (format === 'mdx') processor.use(remarkMdx);
91
+ // Math syntax is profile-dependent. Enabling it for every book would hide
92
+ // real links authored between dollar signs in non-math profiles.
93
+ if (math) processor.use(remarkMath);
94
+ processors.set(key, processor);
95
+ }
96
+ return processors.get(key);
97
+ }
98
+
99
+ function positionStart(node) {
100
+ return node?.position?.start?.offset ?? 0;
101
+ }
102
+
103
+ function attributeValueOffset(source, start, end) {
104
+ const attribute = source.slice(start, end);
105
+ const equals = attribute.indexOf('=');
106
+ if (equals < 0) return start;
107
+
108
+ let offset = equals + 1;
109
+ while (/\s/.test(attribute[offset] ?? '')) offset += 1;
110
+ if (attribute[offset] === '"' || attribute[offset] === "'") offset += 1;
111
+ return start + offset;
112
+ }
113
+
114
+ function markdownDestinationOffset(node, source) {
115
+ const start = positionStart(node);
116
+ const end = node?.position?.end?.offset ?? start;
117
+ const authored = source.slice(start, end);
118
+ const marker = node.type === 'definition' ? authored.lastIndexOf(']:') : authored.lastIndexOf('](');
119
+ if (marker < 0) return start;
120
+
121
+ let offset = marker + 2;
122
+ while (/\s/.test(authored[offset] ?? '')) offset += 1;
123
+ if (authored[offset] === '<') offset += 1;
124
+ return start + offset;
125
+ }
126
+
127
+ function literalMdxAttribute(attribute, source) {
128
+ if (typeof attribute.value === 'string') {
129
+ return {
130
+ target: attribute.value,
131
+ index: attributeValueOffset(
132
+ source,
133
+ positionStart(attribute),
134
+ attribute.position?.end?.offset ?? positionStart(attribute),
135
+ ),
136
+ };
137
+ }
138
+
139
+ if (attribute.value?.type !== 'mdxJsxAttributeValueExpression') return null;
140
+ const program = attribute.value.data?.estree;
141
+ if (program?.body?.length !== 1 || program.body[0]?.type !== 'ExpressionStatement') return null;
142
+ const expression = program.body[0].expression;
143
+
144
+ if (expression?.type === 'Literal' && typeof expression.value === 'string') {
145
+ return { target: expression.value, index: expression.start ?? positionStart(attribute) };
146
+ }
147
+ if (
148
+ expression?.type === 'TemplateLiteral' &&
149
+ expression.expressions?.length === 0 &&
150
+ expression.quasis?.length === 1 &&
151
+ typeof expression.quasis[0].value?.cooked === 'string'
152
+ ) {
153
+ return {
154
+ target: expression.quasis[0].value.cooked,
155
+ index: expression.quasis[0].start ?? expression.start ?? positionStart(attribute),
156
+ };
157
+ }
158
+ return null;
159
+ }
160
+
161
+ /**
162
+ * Resolve one JSX prop in source order. A later explicit literal overrides an
163
+ * earlier spread, while a later spread or dynamic expression makes the final
164
+ * runtime value unknowable. This mirrors JSX object-spread semantics.
165
+ */
166
+ function literalMdxProp(element, name, source) {
167
+ let result = null;
168
+ for (const attribute of element.attributes ?? []) {
169
+ if (attribute.type === 'mdxJsxExpressionAttribute') {
170
+ result = null;
171
+ continue;
172
+ }
173
+ if (
174
+ attribute.type !== 'mdxJsxAttribute' ||
175
+ typeof attribute.name !== 'string' ||
176
+ attribute.name.toLowerCase() !== name
177
+ ) {
178
+ continue;
179
+ }
180
+ result = literalMdxAttribute(attribute, source);
181
+ }
182
+ return result;
183
+ }
184
+
185
+ function htmlSourceFromRanges(source, ranges) {
186
+ const chars = new Array(source.length);
187
+ for (let i = 0; i < source.length; i += 1) {
188
+ chars[i] = source[i] === '\n' || source[i] === '\r' ? source[i] : ' ';
189
+ }
190
+ for (const [start, end] of ranges) {
191
+ for (let i = start; i < end; i += 1) chars[i] = source[i];
192
+ }
193
+ return chars.join('');
194
+ }
195
+
196
+ function collectParsedHtmlTargets(source, ranges, add) {
197
+ if (ranges.length === 0) return;
198
+ const htmlSource = htmlSourceFromRanges(source, ranges);
199
+ const tree = parseFragment(htmlSource, { sourceCodeLocationInfo: true });
200
+
201
+ const visit = (node, insideCode = false) => {
202
+ const tagName = typeof node.tagName === 'string' ? node.tagName.toLowerCase() : null;
203
+ const inCodeContainer = insideCode || tagName === 'pre' || tagName === 'code';
204
+
205
+ if (tagName && !inCodeContainer) {
206
+ for (const attribute of node.attrs ?? []) {
207
+ const name = attribute.name.toLowerCase();
208
+ if (name !== 'href' && name !== 'src') continue;
209
+ const location = node.sourceCodeLocation?.attrs?.[attribute.name];
210
+ const start = location?.startOffset ?? node.sourceCodeLocation?.startOffset ?? 0;
211
+ const end = location?.endOffset ?? start;
212
+ add(attribute.value, attributeValueOffset(source, start, end), `${name} attribute`);
213
+ }
214
+ }
215
+
216
+ for (const child of node.childNodes ?? []) visit(child, inCodeContainer);
217
+ if (node.content) visit(node.content, inCodeContainer);
218
+ };
219
+
220
+ visit(tree);
221
+ }
222
+
223
+ function formatFrom(options) {
224
+ const format = typeof options === 'string' ? options : options?.format ?? 'mdx';
225
+ if (format !== 'md' && format !== 'mdx') {
226
+ throw new TypeError(`authored link parser format must be "md" or "mdx" (got ${JSON.stringify(format)})`);
227
+ }
228
+ return format;
229
+ }
230
+
231
+ /**
232
+ * Return every statically knowable authored Markdown/HTML/MDX href/src target.
233
+ * Result indices use the original source's UTF-16 offsets and are intended for
234
+ * file/line diagnostics; decoded targets need not occur verbatim at that index.
235
+ */
236
+ export function findAuthoredTargets(content, options = {}) {
237
+ if (typeof content !== 'string') throw new TypeError('authored link content must be a string');
238
+ const format = formatFrom(options);
239
+ const math = typeof options === 'object' && options?.math === true;
240
+ const source = blankFrontmatter(content);
241
+ const tree = processorFor(format, math).parse(source);
242
+ const targets = [];
243
+ const seen = new Set();
244
+ const rawHtmlRanges = [];
245
+ const referencedIdentifiers = new Set();
246
+ const definitions = new Map();
247
+
248
+ const add = (target, index, kind) => {
249
+ if (typeof target !== 'string') return;
250
+ const value = target.trim();
251
+ const key = `${index}:${kind}:${value}`;
252
+ if (seen.has(key)) return;
253
+ seen.add(key);
254
+ targets.push({ target: value, index, kind });
255
+ };
256
+
257
+ const visit = (node) => {
258
+ if (!node || typeof node !== 'object') return;
259
+
260
+ if (node.type === 'link' || node.type === 'image') {
261
+ const kind = node.type === 'image' ? 'Markdown image destination' : 'Markdown link destination';
262
+ add(node.url, markdownDestinationOffset(node, source), kind);
263
+ }
264
+
265
+ if (
266
+ (node.type === 'linkReference' || node.type === 'imageReference') &&
267
+ typeof node.identifier === 'string'
268
+ ) {
269
+ referencedIdentifiers.add(node.identifier);
270
+ }
271
+
272
+ if (node.type === 'definition' && typeof node.identifier === 'string') {
273
+ // CommonMark resolves duplicate labels to the first definition.
274
+ if (!definitions.has(node.identifier)) definitions.set(node.identifier, node);
275
+ }
276
+
277
+ if (node.type === 'html') {
278
+ const start = positionStart(node);
279
+ rawHtmlRanges.push([start, node.position?.end?.offset ?? start]);
280
+ return;
281
+ }
282
+
283
+ if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
284
+ const name = typeof node.name === 'string' ? node.name : '';
285
+ if (name === 'pre' || name === 'code') return;
286
+ for (const attributeName of ['href', 'src']) {
287
+ const literal = literalMdxProp(node, attributeName, source);
288
+ if (literal) add(literal.target, literal.index, `${attributeName} attribute`);
289
+ }
290
+ }
291
+
292
+ // Code nodes contain source text but no rendered links. MDX expressions and
293
+ // ESM nodes are also opaque unless they are a parsed JSX attribute above.
294
+ if (
295
+ node.type === 'code' ||
296
+ node.type === 'inlineCode' ||
297
+ node.type === 'mdxFlowExpression' ||
298
+ node.type === 'mdxTextExpression' ||
299
+ node.type === 'mdxjsEsm'
300
+ ) {
301
+ return;
302
+ }
303
+
304
+ for (const child of node.children ?? []) visit(child);
305
+ };
306
+
307
+ visit(tree);
308
+ for (const identifier of referencedIdentifiers) {
309
+ const definition = definitions.get(identifier);
310
+ if (definition) {
311
+ add(
312
+ definition.url,
313
+ markdownDestinationOffset(definition, source),
314
+ 'Markdown reference destination',
315
+ );
316
+ }
317
+ }
318
+ if (format === 'md') collectParsedHtmlTargets(source, rawHtmlRanges, add);
319
+ return targets.sort((a, b) => a.index - b.index);
320
+ }
321
+
322
+ /** Find only literal targets that browser-resolve outside `base`. */
323
+ export function findEscapingAuthoredTargets(content, base, options = {}) {
324
+ if (normalizeAstroBase(base) === '/') return [];
325
+ return findAuthoredTargets(content, options).filter(({ target }) =>
326
+ rootTargetEscapesBase(target, base),
327
+ );
328
+ }
@@ -2,9 +2,9 @@
2
2
  /**
3
3
  * build-labels.mjs — emit src/data/labels.json for <XRef> resolution.
4
4
  *
5
- * Walks the consumer's `src/content/chapters/**\/*.mdx`, extracts each
6
- * labelable component invocation (Theorem, Figure, Section, … — see
7
- * `LABELABLE_TYPES` below), and assigns it an entry of the form
5
+ * Walks the consumer's `src/content/chapters/**\/*.mdx`, extracts every h2–h6
6
+ * Markdown heading plus each labelable component invocation (Theorem, Figure,
7
+ * Section, … — see `LABELABLE_TYPES` below), and assigns it an entry of the form
8
8
  * { href, display: "Theorem 4.2", number: "4.2" }
9
9
  * matching the LaTeX `\cref` convention. The map is consumed by XRef.astro
10
10
  * (display + href) AND Theorem.astro (#126: `number`, so a heading auto-
@@ -25,11 +25,15 @@
25
25
  * - tools profile: `chapter` field (number).
26
26
  * - academic profile: `week` field (number).
27
27
  *
28
- * Slug used for the href: the chapter's frontmatter `slug:` if set,
29
- * else filename minus `.mdx`. The href shape mirrors the consumer's pages
30
- * router: `/chapters/<slug>#<id>`. Academic books using `[...slug].astro`
31
- * get the same shape since Astro slugifies filenames identically when no
32
- * frontmatter override is present.
28
+ * Chapter IDs use frontmatter `slug:` when set, else the chapter-relative path
29
+ * minus `.md`/`.mdx` (so nested content IDs preserve their directory). Hrefs
30
+ * are resolved through the evaluated defineBookConfig `chapterRoute` and
31
+ * `bookField`; the default remains `chapters/<id>#<label>`.
32
+ *
33
+ * Heading anchors come from Astro's own Markdown processor, including its
34
+ * smartypants text normalization and GitHubSlugger duplicate suffixes. h1 is
35
+ * intentionally excluded because it is the chapter title, not an in-chapter
36
+ * BookLink target.
33
37
  *
34
38
  * Optional override:
35
39
  * <Theorem id="…" label="Custom display" />
@@ -45,7 +49,9 @@
45
49
  * Designed to run in <2 s on a medium book.
46
50
  */
47
51
  import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
48
- import { resolve, relative, join, basename, dirname } from 'node:path';
52
+ import { resolve, relative, join, dirname, sep } from 'node:path';
53
+ import { pathToFileURL } from 'node:url';
54
+ import { createMarkdownProcessor } from '@astrojs/markdown-remark';
49
55
  import { readChaptersBase } from './walk-mdx.mjs';
50
56
  import { loadResolvedBookConfig } from './resolve-book-config.mjs';
51
57
  // #126: reuse the ONE kind vocabulary (theorem-label.ts → its own lean tsup
@@ -54,13 +60,16 @@ import { loadResolvedBookConfig } from './resolve-book-config.mjs';
54
60
  // throw as the render path, #121) one build step earlier. Requires `dist/`
55
61
  // (run `npm run build` first; the published tarball ships dist + scripts).
56
62
  import { theoremLabel } from '../dist/lib/theorem-label.mjs';
63
+ // #147: reuse the same route-token resolver as Sidebar/ChapterNav rather than
64
+ // maintaining a second, hardcoded `chapters/<slug>` interpretation here.
65
+ import { chapterHref } from '../dist/lib/nav-href.mjs';
57
66
 
58
67
  // --help / -h: non-mutating (closes #14).
59
68
  const USAGE = `Usage: book-scaffold build-labels
60
69
 
61
- Emit src/data/labels.json for <XRef> resolution. Walks chapter MDX files,
62
- extracts labelable components (Theorem, Figure, ...), assigns display strings
63
- like "Theorem 4.2" matching LaTeX \\cref.
70
+ Emit src/data/labels.json for <XRef> and <BookLink> resolution. Walks chapter
71
+ Markdown/MDX files, indexes h2–h6 anchors, and extracts labelable components
72
+ (Theorem, Figure, ...) with display strings like "Theorem 4.2".
64
73
 
65
74
  Env:
66
75
  BOOK_CHAPTERS_DIR Override chapters dir (default: src/content/chapters).
@@ -69,8 +78,8 @@ Env:
69
78
  Options:
70
79
  --help, -h Print this message and exit (non-mutating).
71
80
 
72
- Numbering is read from defineBookConfig / defineStyle numberStyle. It defaults
73
- to shared when no scaffold integration can be resolved.
81
+ Numbering and chapter hrefs are read from evaluated defineBookConfig metadata.
82
+ Defaults are shared numbering and /chapters/:id/ when no integration resolves.
74
83
  `;
75
84
 
76
85
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
@@ -111,12 +120,14 @@ const TYPE_DISPLAY = {
111
120
 
112
121
  // ===== Frontmatter parsing =====
113
122
 
114
- function parseFrontmatter(source) {
123
+ function splitFrontmatter(source) {
115
124
  // Standard MDX/YAML frontmatter: `---\n…\n---`.
116
- const m = source.match(/^---\n([\s\S]*?)\n---/);
117
- if (!m) return {};
125
+ // Remove it before Markdown processing: YAML comments beginning with `#`
126
+ // must not become phantom headings.
127
+ const m = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
128
+ if (!m) return { frontmatter: {}, body: source };
118
129
  const fm = {};
119
- for (const line of m[1].split('\n')) {
130
+ for (const line of m[1].split(/\r?\n/)) {
120
131
  const kv = line.match(/^(\w+)\s*:\s*(.+?)\s*$/);
121
132
  if (!kv) continue;
122
133
  const [, key, raw] = kv;
@@ -125,7 +136,7 @@ function parseFrontmatter(source) {
125
136
  if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
126
137
  fm[key] = val;
127
138
  }
128
- return fm;
139
+ return { frontmatter: fm, body: source.slice(m[0].length) };
129
140
  }
130
141
 
131
142
  function chapterNumberOf(frontmatter) {
@@ -187,9 +198,13 @@ async function walkChapters(dir) {
187
198
 
188
199
  async function main() {
189
200
  const cwd = process.cwd();
190
- const { numberStyle } = await loadResolvedBookConfig(cwd);
201
+ const { numberStyle, chapterRoute, bookField } = await loadResolvedBookConfig(cwd);
191
202
  const chaptersDir = resolve(cwd, CHAPTERS_DIR);
192
203
  const files = await walkChapters(chaptersDir);
204
+ // Syntax highlighting cannot affect heading metadata and is expensive to
205
+ // initialize. Everything that does affect Astro heading text/IDs (GFM,
206
+ // smartypants, rehypeHeadingIds/GitHubSlugger) retains Astro's defaults.
207
+ const headingProcessor = await createMarkdownProcessor({ syntaxHighlight: false });
193
208
 
194
209
  const labels = {};
195
210
  const tagRegex = buildTagRegex();
@@ -198,16 +213,63 @@ async function main() {
198
213
 
199
214
  for (const file of files) {
200
215
  const source = await readFile(file, 'utf8');
201
- const fm = parseFrontmatter(source);
216
+ const { frontmatter: fm, body } = splitFrontmatter(source);
202
217
  const chapterNum = chapterNumberOf(fm);
203
- const slug = (typeof fm.slug === 'string' && fm.slug.length > 0)
218
+ const contentId = relative(chaptersDir, file)
219
+ .split(sep)
220
+ .join('/')
221
+ .replace(/\.mdx?$/, '');
222
+ const entryId = (typeof fm.slug === 'string' && fm.slug.length > 0)
204
223
  ? fm.slug
205
- : basename(file).replace(/\.mdx?$/, '');
224
+ : contentId;
225
+ const chapterPath = chapterHref(
226
+ { id: entryId, data: fm },
227
+ chapterRoute,
228
+ '/',
229
+ bookField,
230
+ ).replace(/^\/+|\/+$/g, '');
231
+
232
+ const addLabel = (id, value) => {
233
+ if (labels[id]) {
234
+ // Duplicate id — surface but don't fail; consumer's validator catches
235
+ // collisions with full diagnostic context. Component IDs retain their
236
+ // historical precedence over an identically named heading anchor.
237
+ process.stderr.write(
238
+ `build-labels: WARN duplicate id "${id}" (first in ` +
239
+ `${labels[id].href.split('#')[0]}, now in ${entryId})\n`,
240
+ );
241
+ }
242
+ labels[id] = value;
243
+ };
206
244
 
207
245
  // Per-chapter counters reset for each file.
208
246
  const counters = {};
209
247
  let foundInChapter = 0;
210
248
 
249
+ // #147: BookLink fragments normally target prose sections rather than
250
+ // labelable components. Astro owns both text extraction and GitHub-style
251
+ // slug collision behavior, so consume its heading metadata directly.
252
+ const rendered = await headingProcessor.render(body, {
253
+ fileURL: pathToFileURL(file),
254
+ frontmatter: fm,
255
+ });
256
+ for (const heading of rendered.metadata.headings) {
257
+ if (heading.depth < 2 || heading.depth > 6) continue;
258
+ foundInChapter += 1;
259
+ totalIds += 1;
260
+ const href = `${chapterPath}#${heading.slug}`;
261
+ // Heading fragments are only document-local: many chapters legitimately
262
+ // contain `#summary`. A path-qualified, opaque key preserves every one
263
+ // without colliding with component IDs or another chapter. BookLink
264
+ // validation resolves the exact href VALUE; consumers must not derive
265
+ // heading semantics from this internal key.
266
+ addLabel(`heading:${href}`, {
267
+ href,
268
+ display: heading.text,
269
+ number: null,
270
+ });
271
+ }
272
+
211
273
  for (const match of source.matchAll(tagRegex)) {
212
274
  const [, componentName, attrs] = match;
213
275
  const id = extractAttr(attrs, 'id');
@@ -267,21 +329,13 @@ async function main() {
267
329
  : String(counters[counterKey]);
268
330
  const display = labelOverride ?? `${word} ${number}`;
269
331
 
270
- if (labels[id]) {
271
- // Duplicate id — surface but don't fail; consumer's validator
272
- // catches collisions with full diagnostic context.
273
- process.stderr.write(
274
- `build-labels: WARN duplicate id "${id}" (first in ` +
275
- `${labels[id].href.split('#')[0]}, now in ${slug})\n`,
276
- );
277
- }
278
- labels[id] = {
332
+ addLabel(id, {
279
333
  // #142: base-less ref — XRef.astro prefixes BASE_URL at render so one
280
334
  // labels.json serves any deploy base (root or path-proxied series).
281
- href: `chapters/${slug}#${id}`,
335
+ href: `${chapterPath}#${id}`,
282
336
  display,
283
337
  number,
284
- };
338
+ });
285
339
  }
286
340
 
287
341
  if (foundInChapter > 0) chaptersWithIds += 1;
@@ -5,6 +5,10 @@ import { loadConfigFromFile } from 'vite';
5
5
  export const DEFAULT_TOOLING_CONFIG = Object.freeze({
6
6
  preset: null,
7
7
  numberStyle: 'shared',
8
+ siblingBooks: Object.freeze({}),
9
+ chapterRoute: '/chapters/:id/',
10
+ bookField: 'book',
11
+ base: '/',
8
12
  integrationFound: false,
9
13
  });
10
14
 
@@ -42,6 +46,75 @@ function assertPreset(value, configPath) {
42
46
  }
43
47
  }
44
48
 
49
+ function resolveNonEmptyString(value, fallback, field, configPath) {
50
+ if (value == null) return fallback;
51
+ if (typeof value !== 'string' || value.length === 0) {
52
+ throw new Error(
53
+ `book-scaffold tooling: ${configPath} resolved invalid ${field} ` +
54
+ `${JSON.stringify(value)}; expected a non-empty string.`,
55
+ );
56
+ }
57
+ return value;
58
+ }
59
+
60
+ function resolveBase(value, configPath) {
61
+ if (value == null || value === '') return '/';
62
+ if (typeof value !== 'string') {
63
+ throw new Error(
64
+ `book-scaffold tooling: ${configPath} resolved invalid Astro base ` +
65
+ `${JSON.stringify(value)}; expected a string.`,
66
+ );
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function resolveSiblingBooks(value, configPath) {
72
+ if (value == null) return {};
73
+ if (typeof value !== 'object' || Array.isArray(value)) {
74
+ throw new Error(
75
+ `book-scaffold tooling: ${configPath} resolved invalid siblingBooks ` +
76
+ `${JSON.stringify(value)}; expected an object registry.`,
77
+ );
78
+ }
79
+
80
+ const resolved = [];
81
+ for (const [book, entry] of Object.entries(value)) {
82
+ if (typeof entry === 'string') {
83
+ if (entry.length === 0) {
84
+ throw new Error(
85
+ `book-scaffold tooling: ${configPath} resolved invalid siblingBooks.${book}; ` +
86
+ 'URL strings must not be empty.',
87
+ );
88
+ }
89
+ resolved.push([book, entry]);
90
+ continue;
91
+ }
92
+
93
+ if (
94
+ entry === null ||
95
+ typeof entry !== 'object' ||
96
+ Array.isArray(entry) ||
97
+ typeof entry.url !== 'string' ||
98
+ entry.url.length === 0 ||
99
+ (entry.labels !== undefined &&
100
+ (typeof entry.labels !== 'string' || entry.labels.length === 0))
101
+ ) {
102
+ throw new Error(
103
+ `book-scaffold tooling: ${configPath} resolved invalid siblingBooks.${book}; ` +
104
+ 'expected a URL string or { url: string, labels?: string }.',
105
+ );
106
+ }
107
+ resolved.push([
108
+ book,
109
+ {
110
+ url: entry.url,
111
+ ...(entry.labels === undefined ? {} : { labels: entry.labels }),
112
+ },
113
+ ]);
114
+ }
115
+ return Object.fromEntries(resolved);
116
+ }
117
+
45
118
  /**
46
119
  * Evaluate the consumer's actual Astro config and read the scaffold
47
120
  * integration's internal resolved metadata. Absence of a config/integration is
@@ -74,15 +147,16 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
74
147
  const integrations = Array.isArray(loaded.config?.integrations)
75
148
  ? loaded.config.integrations.flat(Infinity)
76
149
  : [];
150
+ const base = resolveBase(loaded.config?.base, configPath);
77
151
  const integration = integrations.find((candidate) => candidate?.name === 'book-scaffold-astro');
78
- if (!integration) return { ...DEFAULT_TOOLING_CONFIG };
152
+ if (!integration) return { ...DEFAULT_TOOLING_CONFIG, base };
79
153
 
80
154
  const metadata = integration.__bookScaffoldResolvedConfig;
81
155
  if (!metadata) {
82
156
  // A config can contain an older scaffold integration with no metadata.
83
157
  // Preserve the historical numbering default rather than treating upgrade
84
158
  // sequencing as a config error.
85
- return { ...DEFAULT_TOOLING_CONFIG, integrationFound: true };
159
+ return { ...DEFAULT_TOOLING_CONFIG, base, integrationFound: true };
86
160
  }
87
161
 
88
162
  const numberStyle = metadata.numberStyle ?? 'shared';
@@ -91,6 +165,20 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
91
165
  return {
92
166
  preset: metadata.preset ?? null,
93
167
  numberStyle,
168
+ siblingBooks: resolveSiblingBooks(metadata.siblingBooks, configPath),
169
+ chapterRoute: resolveNonEmptyString(
170
+ metadata.chapterRoute,
171
+ DEFAULT_TOOLING_CONFIG.chapterRoute,
172
+ 'chapterRoute',
173
+ configPath,
174
+ ),
175
+ bookField: resolveNonEmptyString(
176
+ metadata.bookField,
177
+ DEFAULT_TOOLING_CONFIG.bookField,
178
+ 'bookField',
179
+ configPath,
180
+ ),
181
+ base,
94
182
  integrationFound: true,
95
183
  };
96
184
  }