@brandon_m_behring/book-scaffold-astro 4.29.0 → 4.31.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
+ }
@@ -25,21 +25,22 @@
25
25
  *
26
26
  * v4.11.0 (closes #84): each generated SVG gets a post-export rewrite
27
27
  * (recolorSvg) that injects role="img" + a CSS-variable theming layer so one
28
- * SVG serves light + dark. Neutral fills/strokes are remapped to
29
- * var(--diagram-ink|paper|grid, <original>) via injected attribute-selector
30
- * rules (the original attribute stays as the fallback); saturated accent colors
31
- * are left untouched. A `%! no-theme` line in the source .tex opts a figure out.
28
+ * SVG serves light + dark. Known Warm–Tol semantic and Okabe–Ito categorical
29
+ * colors are mapped to var(--fig-*|--series-*, <original>); neutral paints map
30
+ * to var(--diagram-ink|paper|grid, <original>). Unknown saturated colors stay
31
+ * authored. A `%! no-theme` line in the source .tex opts a figure out.
32
32
  * <Figure> inlines local SVGs so they track the in-page [data-theme] toggle.
33
33
  *
34
34
  * Idempotent: skips when the target SVG is newer than the source PDF (and the
35
35
  * recolor itself is a no-op on an already-themed SVG, so re-runs after an
36
36
  * upgrade safely theme pre-existing figures).
37
- * Run on `prebuild` so Astro always sees fresh figures.
37
+ * Run explicitly with `npm run build:figures`; optional system tools keep this
38
+ * authoring pipeline out of the generated project's `prebuild` hook.
38
39
  *
39
40
  * Graceful skip: when pdftocairo / pdftoppm aren't on PATH (e.g. Cloudflare
40
41
  * build container), the script warns and exits 0. Committed SVGs/PNGs under
41
- * public/figures/ are served as-is. Local devs with poppler-utils regenerate
42
- * from PDFs on every `npm run dev`.
42
+ * public/figures/ are served as-is. Local authors with poppler-utils regenerate
43
+ * from PDFs when source figures change.
43
44
  */
44
45
  import { readdir, stat, mkdir } from 'node:fs/promises';
45
46
  import { existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
@@ -56,8 +57,10 @@ Figure pipeline. PDF -> SVG via pdftocairo (PNG fallback via pdftoppm at
56
57
  Graceful-skip if pdftocairo / pdftoppm not on PATH.
57
58
 
58
59
  Each SVG is rewritten to be accessible + dark-mode-aware: role="img" plus
59
- var(--diagram-ink|paper|grid, orig) fills (a "%! no-theme" line in the
60
- source .tex opts out). <Figure> inlines local SVGs so they track the theme.
60
+ exact Warm–Tol/Okabe–Ito palette mappings and neutral
61
+ var(--diagram-ink|paper|grid, orig) mappings. Use base color + separate opacity,
62
+ not a pre-blended tint. A "%! no-theme" line in the source .tex opts out.
63
+ <Figure> inlines local SVGs so they track the theme.
61
64
 
62
65
  Env:
63
66
  BOOK_FIGURES_PATH Override figures source (default: figures/).
@@ -202,7 +205,7 @@ const NO_THEME_RE = /^\s*%!\s*no-theme\b/m;
202
205
  /**
203
206
  * v4.11.0 (#84): apply the theming rewrite to a generated SVG in place.
204
207
  * No-op (returns false) if the file is absent or recolorSvg leaves it unchanged
205
- * (already themed / nothing neutral to remap). Idempotent.
208
+ * (already themed / nothing themeable to remap). Idempotent.
206
209
  */
207
210
  function themeIfSvg(svgPath, optOut) {
208
211
  if (!existsSync(svgPath)) return false;
@@ -16,7 +16,8 @@
16
16
  * canonical; the notebook is a code companion.
17
17
  *
18
18
  * Idempotent: skips when the target HTML is newer than the source .ipynb.
19
- * Run on `prebuild` so Astro always sees fresh notebook HTML.
19
+ * Run explicitly with `npm run build:notebooks`; optional system tools keep
20
+ * this authoring pipeline out of the generated project's `prebuild` hook.
20
21
  *
21
22
  * Style scoping: nbconvert's `basic` template emits HTML without nbviewer
22
23
  * chrome, but with embedded <style> tags. To prevent CSS bleed, each
@@ -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
  }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ /** Regenerate or verify the manifest-owned block in styles/tokens.css. */
3
+ import { readFileSync, writeFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ FIGURE_TOKEN_BLOCK_END,
7
+ FIGURE_TOKEN_BLOCK_START,
8
+ renderFigureTokenCssBlock,
9
+ } from '../src/lib/figure-palette.mjs';
10
+
11
+ const TOKENS_PATH = fileURLToPath(new URL('../styles/tokens.css', import.meta.url));
12
+ const args = new Set(process.argv.slice(2));
13
+ const write = args.has('--write');
14
+ if (args.size > 1 || (args.size === 1 && !write && !args.has('--check'))) {
15
+ console.error('Usage: node scripts/sync-figure-tokens.mjs [--check|--write]');
16
+ process.exit(2);
17
+ }
18
+
19
+ const css = readFileSync(TOKENS_PATH, 'utf8');
20
+ const start = css.indexOf(FIGURE_TOKEN_BLOCK_START);
21
+ const endStart = css.indexOf(FIGURE_TOKEN_BLOCK_END, start);
22
+ if (start < 0 || endStart < 0) {
23
+ throw new Error('tokens.css is missing the generated figure-token block markers.');
24
+ }
25
+ const end = endStart + FIGURE_TOKEN_BLOCK_END.length;
26
+ const expected = renderFigureTokenCssBlock();
27
+ const actual = css.slice(start, end);
28
+
29
+ if (write) {
30
+ if (actual === expected) {
31
+ console.log('sync-figure-tokens: tokens.css already current');
32
+ } else {
33
+ writeFileSync(TOKENS_PATH, css.slice(0, start) + expected + css.slice(end), 'utf8');
34
+ console.log('sync-figure-tokens: updated styles/tokens.css');
35
+ }
36
+ } else if (actual !== expected) {
37
+ console.error(
38
+ 'sync-figure-tokens: styles/tokens.css drifted from src/lib/figure-palette.mjs; ' +
39
+ 'run npm run sync:figure-tokens --workspace package',
40
+ );
41
+ process.exit(1);
42
+ } else {
43
+ console.log('sync-figure-tokens: tokens.css matches the palette manifest');
44
+ }
@@ -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));