@brandon_m_behring/book-scaffold-astro 4.29.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.
package/CLAUDE.md CHANGED
@@ -201,6 +201,7 @@ the full precedence and customization contract.
201
201
  - Literal `<Theorem n>` values that disagree with `labels.json` (dynamic expressions and `label=` overrides are skipped)
202
202
  - Missing `<Figure src>` files under `public/`
203
203
  - Internal markdown links that don't resolve
204
+ - Authored root-absolute Markdown/HTML/JSX `href` or `src` targets that escape a configured non-root Astro `base` (#190). Structurally parsed and decoded literal targets fail with file/line context; root-base books, external/protocol-relative URLs, fragments, dynamic JSX expressions, and already base-prefixed targets are unaffected. `rel="external"` does not change URL resolution or opt out. Validation never rewrites prose.
204
205
  - Study-guide questions (v4.17.0, #112) — a question whose frontmatter `domain` isn't in `examDomains`, and duplicate question `id`s
205
206
 
206
207
  Missing `src/data/labels.json` or `references.json` self-heals before checks by running the corresponding package build script; an existing artifact is never rewritten implicitly.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books with five typed presets, Tufte typography, citations, search, PDF, and Cloudflare deployment.",
4
- "version": "4.29.0",
4
+ "version": "4.30.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -190,9 +190,6 @@
190
190
  },
191
191
  "rehype-katex": {
192
192
  "optional": true
193
- },
194
- "remark-math": {
195
- "optional": true
196
193
  }
197
194
  },
198
195
  "dependencies": {
@@ -203,6 +200,8 @@
203
200
  "@fontsource-variable/roboto": "^5.2.10",
204
201
  "@fontsource-variable/source-code-pro": "^5.2.7",
205
202
  "pagefind": "^1.5.2",
203
+ "parse5": "^7.3.0",
204
+ "remark-math": "^6.0.0",
206
205
  "remark-mdx": "^3.1.1",
207
206
  "remark-parse": "^11.0.0",
208
207
  "unified": "^11.0.5",
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Profile**: any (Cite checks skip under non-academic).
4
4
 
5
- **TL;DR**: `npm run validate` runs `scripts/validate.mjs` against all chapter MDX files. It first regenerates missing `labels.json` and `references.json`, then catches typo'd bibkeys / XRef ids / Figure paths / internal links that `astro build` would either miss or surface with poor context. Auto-runs as `prebuild`; recommend wiring into pre-commit too.
5
+ **TL;DR**: `npm run validate` runs `scripts/validate.mjs` against chapter MD/MDX files (plus its question-collection checks). It first regenerates missing `labels.json` and `references.json`, then catches typo'd bibkeys / XRef ids / Figure paths / internal links that `astro build` would either miss or surface with poor context. Auto-runs as `prebuild`; recommend wiring into pre-commit too.
6
6
 
7
7
  ## What gets checked
8
8
 
@@ -17,6 +17,7 @@
17
17
  | `<BookLink book= to=>` both present; `book=` registered; literal fragment target resolves in a declared sibling labels index (#96, #147) | all | Pre-flights the component's build-time throw and dead cross-book anchors across all files at once. |
18
18
  | Questions collection: unique `id`s + `domain` in `examDomains` (#112); `<Rationale appendix>` carries `for=` (#114, v4.21.0) | all, when `src/content/questions/` exists | Duplicate ids break the appendix/flashcards cross-ref key; an unregistered domain throws one-at-a-time at build; an appendix rationale without its anchor target throws at build. |
19
19
  | `los[].anchor` ↔ `{/* anchor: <slug> */}` prose markers agree both ways (#130, v4.20.0) | all, when frontmatter has `los:` | A declared objective whose prose marker is missing/misspelled (or an orphan marker) builds green otherwise — frontmatter↔prose drift only a hand audit would catch. |
20
+ | Literal authored Markdown/HTML/JSX `href`/`src` stays inside a non-root Astro `base` (#190) | all | A browser resolves `/chapters/...` from the host root, bypassing Astro's deployment base and producing a convincing 404. This is a build-blocking error, not a rewrite. |
20
21
 
21
22
  Validate also emits two **non-blocking shadow-route warnings** (exit code unaffected): a consumer-owned `src/pages/chapters/[...slug].astro` without `routes: { chapters: false }` (v4.6.0, #76), and a consumer-owned `src/pages/index.astro` without `routes: { landing: false }` (v4.20.0, #129 — Astro has announced this collision becomes a hard error). See [recipe 18](./18-chapter-route-ownership.md).
22
23
 
@@ -48,6 +49,24 @@ Heading-entry keys are intentionally opaque and path-qualified: `#summary` can l
48
49
 
49
50
  A declared `labels` file that is missing, unreadable, malformed, or not a JSON object is an error. An unknown book, unknown fragment, wrong path, or malformed label entry is also an error with the source file and target. Dynamic `book=`/`to=` expressions and prop spreads cannot be evaluated safely, so they emit explicit skip warnings. URL strings and `{ url }` descriptors likewise warn for literal fragment links because no index was declared. Non-fragment route links warn and skip: labels indexes describe anchors, not every sibling route.
50
51
 
52
+ ## Authored links under a non-root base (#190)
53
+
54
+ When evaluated `astro.config.*` sets a non-root base such as `base: '/library/books/'`, a root-absolute authored URL escapes that deployment mount:
55
+
56
+ ```mdx
57
+ <!-- Errors: both resolve from the host root, outside /library/books/. -->
58
+ [Chapter one](/chapters/one/)
59
+ <img src="/figures/overview.svg" alt="Overview" />
60
+
61
+ <!-- Valid: explicitly remains inside the configured base. -->
62
+ [Chapter one](/library/books/chapters/one/)
63
+ <a href={`${import.meta.env.BASE_URL}references/`}>References</a>
64
+ ```
65
+
66
+ The validator structurally parses inline Markdown links and images, Markdown reference definitions, quoted or unquoted HTML `href`/`src`, and quoted or statically braced JSX string attributes. HTML entities and JavaScript string escapes are decoded before containment is checked. It reports the authored file and line, the evaluated target, the evaluated base, and a browser-normalized base-prefixed replacement. It also scans question MD/MDX files already visited by validation.
67
+
68
+ The check is deliberately validation-only: it does not mutate or rewrite content, and there is no opt-out. Under the root base (`/`) it is inert. Protocol URLs (`https:`, `mailto:`, `data:`, and others), protocol-relative URLs (`//cdn…`), fragments, relative targets, targets already inside the base, and dynamic JSX expressions are excluded. `rel="external"` describes a relationship but does not change where a browser navigates, so it does not exempt a host-root URL. Frontmatter, comments, fenced/inline/indented code examples, and `<pre>`/`<code>` examples are excluded by their parsed Markdown/MDX structure so documentation snippets do not become false diagnostics.
69
+
51
70
  ## Missing generated artifacts
52
71
 
53
72
  `src/data/labels.json` and `src/data/references.json` are derived and normally gitignored. v4.27+ `validate` self-heals either missing file unconditionally by invoking the package's own `build-labels` or `build-bib` command before loading data. Existing files are untouched. A failed child command stops validation with the child's original diagnostic and exit status.
@@ -96,7 +115,7 @@ For pre-commit: add to `.pre-commit-config.yaml`:
96
115
 
97
116
  ## Preset / chaptersBase resolution (v4.7.0+, #75)
98
117
 
99
- The validator evaluates `astro.config.*` through Vite first. A resolved scaffold integration supplies the composed preset and `numberStyle`, so CLI tooling sees the same Style chain as the Astro build. Without such an integration, the legacy preset chain remains: `--preset` → process `BOOK_PRESET`/`BOOK_PROFILE` → root `.env` → the literal value in `defineBookSchemas` → a warned `minimal` v4 compatibility fallback. Every selected value is checked against the five-preset enum; config-evaluation failures stop validation instead of silently using defaults.
118
+ The validator evaluates `astro.config.*` through Vite first. A resolved scaffold integration supplies the composed preset and `numberStyle`, so CLI tooling sees the same Style chain as the Astro build. The evaluated Astro `base` is read from that same config regardless of whether the scaffold integration is present; omission defaults to `/`. Without such an integration, the legacy preset chain remains: `--preset` → process `BOOK_PRESET`/`BOOK_PROFILE` → root `.env` → the literal value in `defineBookSchemas` → a warned `minimal` v4 compatibility fallback. Every selected value is checked against the five-preset enum; config-evaluation failures stop validation instead of silently using defaults.
100
119
 
101
120
  `chaptersBase` resolution still consults `BOOK_CHAPTERS_DIR`, content configuration, then `src/content/chapters`. The v4.5+ canonical form is:
102
121
 
@@ -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
+ }
@@ -8,6 +8,7 @@ export const DEFAULT_TOOLING_CONFIG = Object.freeze({
8
8
  siblingBooks: Object.freeze({}),
9
9
  chapterRoute: '/chapters/:id/',
10
10
  bookField: 'book',
11
+ base: '/',
11
12
  integrationFound: false,
12
13
  });
13
14
 
@@ -56,6 +57,17 @@ function resolveNonEmptyString(value, fallback, field, configPath) {
56
57
  return value;
57
58
  }
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
+
59
71
  function resolveSiblingBooks(value, configPath) {
60
72
  if (value == null) return {};
61
73
  if (typeof value !== 'object' || Array.isArray(value)) {
@@ -135,15 +147,16 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
135
147
  const integrations = Array.isArray(loaded.config?.integrations)
136
148
  ? loaded.config.integrations.flat(Infinity)
137
149
  : [];
150
+ const base = resolveBase(loaded.config?.base, configPath);
138
151
  const integration = integrations.find((candidate) => candidate?.name === 'book-scaffold-astro');
139
- if (!integration) return { ...DEFAULT_TOOLING_CONFIG };
152
+ if (!integration) return { ...DEFAULT_TOOLING_CONFIG, base };
140
153
 
141
154
  const metadata = integration.__bookScaffoldResolvedConfig;
142
155
  if (!metadata) {
143
156
  // A config can contain an older scaffold integration with no metadata.
144
157
  // Preserve the historical numbering default rather than treating upgrade
145
158
  // sequencing as a config error.
146
- return { ...DEFAULT_TOOLING_CONFIG, integrationFound: true };
159
+ return { ...DEFAULT_TOOLING_CONFIG, base, integrationFound: true };
147
160
  }
148
161
 
149
162
  const numberStyle = metadata.numberStyle ?? 'shared';
@@ -165,6 +178,7 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
165
178
  'bookField',
166
179
  configPath,
167
180
  ),
181
+ base,
168
182
  integrationFound: true,
169
183
  };
170
184
  }
@@ -25,6 +25,8 @@
25
25
  * 9. Learning-objective anchors (#130) — when a chapter declares frontmatter
26
26
  * `los:` entries with `anchor:` slugs, the declared set and the prose's
27
27
  * MDX anchor-comment marker set must agree in both directions.
28
+ * 10. Authored root-absolute href/src targets (#190) — under a non-root Astro
29
+ * base, literal Markdown, HTML, and JSX targets must stay inside that base.
28
30
  *
29
31
  * Run from the consumer's project root. Closes #8 (was resolving paths
30
32
  * from the package's own directory inside node_modules — false negatives
@@ -35,25 +37,33 @@
35
37
  * book-scaffold validate --preset academic
36
38
  * BOOK_REPO_ROOT=/abs/path npx book-scaffold validate
37
39
  *
38
- * Exit code = total failure count (0 = pass, >=1 = errors).
40
+ * Exit code = total failure count capped at 255 (0 = pass, >=1 = errors).
39
41
  */
40
42
  import { readFile, access } from 'node:fs/promises';
41
43
  import { existsSync, readFileSync } from 'node:fs';
42
- import { resolve, dirname, join } from 'node:path';
44
+ import { resolve, dirname, extname, join } from 'node:path';
43
45
  import { fileURLToPath } from 'node:url';
44
46
  import { spawnSync } from 'node:child_process';
45
47
  import { unified } from 'unified';
46
48
  import remarkParse from 'remark-parse';
49
+ import remarkMath from 'remark-math';
47
50
  import remarkMdx from 'remark-mdx';
48
51
  import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
49
52
  import { readEnvFile } from './read-env.mjs';
50
53
  import { loadResolvedBookConfig } from './resolve-book-config.mjs';
54
+ import {
55
+ findAuthoredTargets,
56
+ normalizeAstroBase,
57
+ rootTargetEscapesBase,
58
+ rootTargetPathname,
59
+ suggestBaseContainedTarget,
60
+ } from './authored-links.mjs';
51
61
 
52
62
  // --help / -h: non-mutating (closes #14).
53
63
  const USAGE = `Usage: book-scaffold validate [--preset <name>]
54
64
 
55
65
  Pre-flight content validator. Checks Cite keys, XRef ids, Figure srcs,
56
- internal markdown links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
66
+ internal authored links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
57
67
 
58
68
  Options:
59
69
  --preset <name> academic | tools | minimal | course-notes | research-portfolio
@@ -65,7 +75,7 @@ Env:
65
75
  BOOK_PROFILE Backward-compat alias for BOOK_PRESET.
66
76
  BOOK_REPO_ROOT Absolute path to a sibling code repo for CodeRef checks.
67
77
 
68
- Exit code = total failure count.
78
+ Exit code = total failure count, capped at 255 so failures never wrap to success.
69
79
  `;
70
80
 
71
81
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
@@ -143,6 +153,7 @@ if (!PRESET_CANDIDATE) {
143
153
  }
144
154
  // Alias kept for downstream message text only; the resolution above is canonical.
145
155
  const PROFILE = PRESET;
156
+ const MATH_ENABLED = PROFILE === 'academic' || PROFILE === 'research-portfolio';
146
157
  const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
147
158
 
148
159
  // v4.6.0 (issue #76 Layer 3b): chapter-route shadow warning. Detect a
@@ -303,12 +314,11 @@ const validTopLevelRoutes = new Set([
303
314
  '/', '/chapters/', '/references/', '/search/', '/print/', '/convergence/',
304
315
  ]);
305
316
 
306
- // ===== Pattern helpers (regex-based; cheap, good enough for MDX) =====
317
+ // ===== Pattern helpers for component-specific checks =====
307
318
  const RE_CITE = /<Cite[^>]+key=["']([^"']+)["']/g;
308
319
  const RE_XREF = /<XRef[^>]+id=["']([^"']+)["']/g;
309
320
  const RE_FIGURE = /<Figure[^>]+src=["']([^"']+)["']/g;
310
321
  const RE_CODEREF = /<CodeRef[^>]+path=["']([^"']+)["'](?:[^>]*line=\{(\d+)\})?(?:[^>]*lineEnd=\{(\d+)\})?/g;
311
- const RE_MD_LINK = /\[(?:[^\]]*)\]\((\/[^)\s#]+)(?:#[^)]*)?\)/g;
312
322
  // #121: a <Theorem> opening tag — capture its attributes to assert a
313
323
  // resolvable kind= (or legacy type=) is present.
314
324
  const RE_THEOREM = /<Theorem\b([^>]*)>/g;
@@ -316,6 +326,7 @@ const RE_THEOREM = /<Theorem\b([^>]*)>/g;
316
326
  // regex stops at `>` inside a quoted value or expression and can mistake text
317
327
  // inside another prop for a real book=/to= assignment.
318
328
  const mdxParser = unified().use(remarkParse).use(remarkMdx);
329
+ if (MATH_ENABLED) mdxParser.use(remarkMath);
319
330
 
320
331
  async function fileExists(p) {
321
332
  try {
@@ -330,6 +341,62 @@ function lineOf(content, idx) {
330
341
  return content.slice(0, idx).split('\n').length;
331
342
  }
332
343
 
344
+ const ASTRO_BASE = normalizeAstroBase(TOOLING_CONFIG.base);
345
+
346
+ function authoredFormat(file) {
347
+ return extname(file).toLowerCase() === '.md' ? 'md' : 'mdx';
348
+ }
349
+
350
+ function collectAuthoredTargets(file, content) {
351
+ try {
352
+ return findAuthoredTargets(content, {
353
+ format: authoredFormat(file),
354
+ math: MATH_ENABLED,
355
+ });
356
+ } catch (error) {
357
+ const line = error?.line ?? error?.place?.start?.line ?? error?.position?.start?.line ?? 1;
358
+ const detail = String(error?.reason ?? error?.message ?? error).split('\n')[0];
359
+ fail(file, line, `Could not parse authored links as ${authoredFormat(file).toUpperCase()}: ${detail}`);
360
+ return [];
361
+ }
362
+ }
363
+
364
+ function validateAuthoredTargets(file, content, targets) {
365
+ for (const violation of targets) {
366
+ if (!rootTargetEscapesBase(violation.target, ASTRO_BASE)) continue;
367
+ const suggested = suggestBaseContainedTarget(violation.target, ASTRO_BASE);
368
+ fail(
369
+ file,
370
+ lineOf(content, violation.index),
371
+ `Authored ${violation.kind} ${JSON.stringify(violation.target)} escapes configured Astro base ` +
372
+ `${JSON.stringify(ASTRO_BASE)}. Use ${JSON.stringify(suggested)}, ` +
373
+ 'import.meta.env.BASE_URL in JSX, or a base-aware component. ' +
374
+ 'Validation does not rewrite authored URLs (#190).',
375
+ );
376
+ }
377
+ }
378
+
379
+ function validateInternalMarkdownLinks(file, content, targets) {
380
+ for (const authored of targets) {
381
+ if (authored.kind !== 'Markdown link destination') continue;
382
+ const pathname = rootTargetPathname(authored.target);
383
+ if (pathname === null) continue;
384
+ const baseRelativeTarget =
385
+ ASTRO_BASE !== '/' && (pathname === ASTRO_BASE || pathname.startsWith(`${ASTRO_BASE}/`))
386
+ ? pathname.slice(ASTRO_BASE.length) || '/'
387
+ : pathname;
388
+ const target = baseRelativeTarget.replace(/\/$/, '') || '/';
389
+ if (validTopLevelRoutes.has(`${target}/`) || validTopLevelRoutes.has(target)) continue;
390
+ const chMatch = target.match(/^\/chapters\/(.+)$/);
391
+ if (chMatch && validSlugs.has(chMatch[1])) continue;
392
+ warn(
393
+ file,
394
+ lineOf(content, authored.index),
395
+ `Internal link ${JSON.stringify(authored.target)} — target may not resolve (check spelling or route)`,
396
+ );
397
+ }
398
+ }
399
+
333
400
  /**
334
401
  * Return a statically knowable n= value. Quoted strings and braced
335
402
  * string/numeric literals are safe to compare; identifiers, calls,
@@ -441,6 +508,13 @@ function siblingTargetCandidates(index, fragment) {
441
508
  for (const rel of chapterFiles) {
442
509
  const abs = join(CHAPTERS_DIR, rel);
443
510
  const content = await readFile(abs, 'utf8');
511
+ const authoredTargets = collectAuthoredTargets(rel, content);
512
+
513
+ // 10. Root-absolute authored targets under a non-root Astro base (#190).
514
+ // Structural parsing covers Markdown link/image destinations, HTML
515
+ // href/src, and decoded static JSX strings while excluding code examples.
516
+ validateAuthoredTargets(rel, content, authoredTargets);
517
+
444
518
  let bookLinks = [];
445
519
  if (content.includes('<BookLink')) {
446
520
  try {
@@ -477,14 +551,9 @@ for (const rel of chapterFiles) {
477
551
  }
478
552
  }
479
553
 
480
- // 4. Internal markdown links resolve
481
- for (const m of content.matchAll(RE_MD_LINK)) {
482
- const target = m[1].replace(/\/$/, '');
483
- if (validTopLevelRoutes.has(target + '/') || validTopLevelRoutes.has(target)) continue;
484
- const chMatch = target.match(/^\/chapters\/(.+)$/);
485
- if (chMatch && validSlugs.has(chMatch[1])) continue;
486
- warn(rel, lineOf(content, m.index), `Internal link "${m[1]}" — target may not resolve (check spelling or route)`);
487
- }
554
+ // 4. Internal Markdown links resolve. Reuse the structural authored-target
555
+ // traversal so code examples and comments cannot create false warnings.
556
+ validateInternalMarkdownLinks(rel, content, authoredTargets);
488
557
 
489
558
  // 5. CodeRef path + line bounds (only when BOOK_REPO_ROOT set)
490
559
  if (REPO_ROOT) {
@@ -773,6 +842,11 @@ let questionsChecked = 0;
773
842
  const front = fm ? fm[1] : '';
774
843
  const qrel = `questions/${rel}`;
775
844
 
845
+ // Questions do not run the legacy link-resolution advisory, so keep the
846
+ // #190 root-base contract fully inert by parsing only for a non-root base.
847
+ const authoredTargets = ASTRO_BASE === '/' ? [] : collectAuthoredTargets(qrel, content);
848
+ validateAuthoredTargets(qrel, content, authoredTargets);
849
+
776
850
  const idMatch = front.match(/^id\s*:\s*["']?([^"'\n]+?)["']?\s*$/m);
777
851
  if (idMatch) {
778
852
  const id = idMatch[1].trim();
@@ -848,4 +922,4 @@ console.error(
848
922
  `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}):`,
849
923
  );
850
924
  errors.forEach((e) => console.error(format(e)));
851
- process.exit(errors.length);
925
+ process.exit(Math.min(errors.length, 255));