@brandon_m_behring/book-scaffold-astro 4.26.3 → 4.27.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.
Files changed (49) hide show
  1. package/AGENTS.md +6 -0
  2. package/CLAUDE.md +35 -10
  3. package/LATEX_TO_MDX_MAPPING.md +1 -1
  4. package/LICENSE +27 -0
  5. package/LICENSE-CONTENT +19 -0
  6. package/README.md +33 -0
  7. package/components/AssessmentTest.astro +2 -1
  8. package/components/ChapterNav.astro +2 -2
  9. package/components/Cite.astro +2 -1
  10. package/components/NavContent.astro +2 -2
  11. package/components/PartReview.astro +2 -1
  12. package/components/Rationale.astro +3 -2
  13. package/components/Sidebar.astro +2 -1
  14. package/components/Term.astro +2 -1
  15. package/components/TipsCard.astro +2 -1
  16. package/components/VersionSelector.tsx +39 -36
  17. package/components/WeekRef.astro +2 -1
  18. package/components/XRef.astro +2 -1
  19. package/dist/components/VersionSelector.d.ts +16 -2
  20. package/dist/components/VersionSelector.mjs +18 -17
  21. package/dist/index.d.ts +24 -6
  22. package/dist/index.mjs +89 -5
  23. package/dist/schemas.d.ts +1 -1
  24. package/dist/schemas.mjs +8 -1
  25. package/dist/{types-CWXP1S4b.d.ts → types-CZrkqzpC.d.ts} +58 -26
  26. package/layouts/Base.astro +20 -19
  27. package/package.json +7 -4
  28. package/pages/answers.astro +2 -1
  29. package/pages/chapters.astro +2 -1
  30. package/pages/exercises.astro +2 -1
  31. package/pages/index.astro +4 -3
  32. package/pages/search.astro +2 -1
  33. package/pages/tips.astro +2 -1
  34. package/recipes/01-add-math.md +40 -28
  35. package/recipes/04-component-library.md +14 -4
  36. package/recipes/05-deploy-cloudflare.md +44 -0
  37. package/recipes/06-mobile-first-layout.md +25 -2
  38. package/recipes/09-validation.md +18 -9
  39. package/recipes/15-defining-styles.md +5 -2
  40. package/recipes/19-prevalidate-hook.md +29 -83
  41. package/scripts/build-bib.mjs +7 -3
  42. package/scripts/build-labels.mjs +33 -16
  43. package/scripts/read-env.mjs +25 -0
  44. package/scripts/resolve-book-config.mjs +96 -0
  45. package/scripts/validate.mjs +125 -84
  46. package/src/lib/define-style.ts +15 -6
  47. package/src/lib/nav-href.ts +24 -2
  48. package/styles/layout.css +14 -8
  49. package/styles/tokens.css +8 -10
@@ -12,14 +12,15 @@
12
12
  *
13
13
  * Kind-aware (#126): a `<Theorem kind="proposition">` resolves its display
14
14
  * WORD through the shared theorem-label vocabulary (`Proposition 8.1`), not a
15
- * kind-blind `Theorem 8.1`. The theorem family shares one counter (keyed by
16
- * the JSX component, as amsthm shares its counter), so numbers are unchanged.
15
+ * kind-blind `Theorem 8.1`. v4.27.0 (#175) lets defineBookConfig / defineStyle
16
+ * select shared (the historical default) or independent per-kind counters.
17
17
  *
18
18
  * The resulting map is consumed by XRef.astro via
19
19
  * `import.meta.glob('/src/data/labels.json', { eager: true })`.
20
20
  *
21
- * Per-chapter, per-type counter: each chapter resets the counter, so two
22
- * chapters can both have `Theorem 1` without colliding. The chapter
21
+ * Per-chapter counters: each chapter resets the sequence, so two chapters can
22
+ * both have `Theorem 1` without colliding. Labelable component families always
23
+ * have independent counters; theorem kinds share or split per numberStyle. The chapter
23
24
  * number comes from frontmatter:
24
25
  * - tools profile: `chapter` field (number).
25
26
  * - academic profile: `week` field (number).
@@ -46,6 +47,7 @@
46
47
  import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
47
48
  import { resolve, relative, join, basename, dirname } from 'node:path';
48
49
  import { readChaptersBase } from './walk-mdx.mjs';
50
+ import { loadResolvedBookConfig } from './resolve-book-config.mjs';
49
51
  // #126: reuse the ONE kind vocabulary (theorem-label.ts → its own lean tsup
50
52
  // entry) so a <Theorem kind="proposition"> xref reads "Proposition N.M", not a
51
53
  // kind-blind "Theorem N.M" — and so an unknown/absent kind FAILS HERE (same
@@ -66,6 +68,9 @@ Env:
66
68
 
67
69
  Options:
68
70
  --help, -h Print this message and exit (non-mutating).
71
+
72
+ Numbering is read from defineBookConfig / defineStyle numberStyle. It defaults
73
+ to shared when no scaffold integration can be resolved.
69
74
  `;
70
75
 
71
76
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
@@ -182,6 +187,7 @@ async function walkChapters(dir) {
182
187
 
183
188
  async function main() {
184
189
  const cwd = process.cwd();
190
+ const { numberStyle } = await loadResolvedBookConfig(cwd);
185
191
  const chaptersDir = resolve(cwd, CHAPTERS_DIR);
186
192
  const files = await walkChapters(chaptersDir);
187
193
 
@@ -207,10 +213,6 @@ async function main() {
207
213
  const id = extractAttr(attrs, 'id');
208
214
  if (!id) continue;
209
215
 
210
- // One shared counter per component (keyed by the JSX name, NOT the
211
- // amsthm kind) — so theorem/proposition/lemma share a sequence exactly
212
- // as they do under amsthm, and existing numbers never shift (#126).
213
- counters[componentName] = (counters[componentName] ?? 0) + 1;
214
216
  foundInChapter += 1;
215
217
  totalIds += 1;
216
218
 
@@ -224,13 +226,16 @@ async function main() {
224
226
  // "no kind=" rather than the misleading kind="null".
225
227
  const labelOverride = extractAttr(attrs, 'label');
226
228
  let word;
229
+ let theoremKind;
227
230
  if (labelOverride == null) {
228
231
  if (componentName === 'Theorem') {
229
232
  try {
230
- word = theoremLabel({
233
+ const resolvedLabel = theoremLabel({
231
234
  kind: extractAttr(attrs, 'kind') ?? undefined,
232
235
  type: extractAttr(attrs, 'type') ?? undefined,
233
- }).fullLabel;
236
+ });
237
+ word = resolvedLabel.fullLabel;
238
+ theoremKind = resolvedLabel.kind;
234
239
  } catch (err) {
235
240
  throw new Error(
236
241
  `<Theorem id="${id}"> in ${relative(cwd, file)}: ${err.message}`,
@@ -241,13 +246,25 @@ async function main() {
241
246
  }
242
247
  }
243
248
 
249
+ // label= is a custom, unnumbered display and therefore does not consume
250
+ // either the historical shared sequence or a per-kind sequence. This
251
+ // keeps later auto-numbered entries stable when prose-only labels move.
252
+ const counterKey =
253
+ numberStyle === 'per-kind' && componentName === 'Theorem'
254
+ ? `Theorem/${theoremKind}`
255
+ : componentName;
256
+ if (labelOverride == null) {
257
+ counters[counterKey] = (counters[counterKey] ?? 0) + 1;
258
+ }
259
+
244
260
  // The bare counter string the heading reuses: Theorem.astro reads
245
261
  // `number` by id and renders it, so heading == xref by construction.
246
262
  // A `label=` override opts out of auto-numbering → number is null.
247
- const number =
248
- chapterNum != null
249
- ? `${chapterNum}.${counters[componentName]}`
250
- : String(counters[componentName]);
263
+ const number = labelOverride != null
264
+ ? null
265
+ : chapterNum != null
266
+ ? `${chapterNum}.${counters[counterKey]}`
267
+ : String(counters[counterKey]);
251
268
  const display = labelOverride ?? `${word} ${number}`;
252
269
 
253
270
  if (labels[id]) {
@@ -263,7 +280,7 @@ async function main() {
263
280
  // labels.json serves any deploy base (root or path-proxied series).
264
281
  href: `chapters/${slug}#${id}`,
265
282
  display,
266
- number: labelOverride ? null : number,
283
+ number,
267
284
  };
268
285
  }
269
286
 
@@ -281,7 +298,7 @@ async function main() {
281
298
  process.stdout.write(
282
299
  `build-labels: ${totalIds} id${totalIds === 1 ? '' : 's'} across ` +
283
300
  `${chaptersWithIds} chapter${chaptersWithIds === 1 ? '' : 's'} → ` +
284
- `${OUTPUT_PATH}\n`,
301
+ `${OUTPUT_PATH} (number-style=${numberStyle})\n`,
285
302
  );
286
303
  }
287
304
 
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ /** Read a small dotenv-style file without mutating process.env. */
5
+ export function readEnvFile(projectRoot = process.cwd()) {
6
+ try {
7
+ const out = {};
8
+ const source = readFileSync(resolve(projectRoot, '.env'), 'utf8');
9
+ for (const line of source.split(/\r?\n/)) {
10
+ const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
11
+ if (!match) continue;
12
+ let value = match[2] ?? '';
13
+ if (
14
+ (value.startsWith('"') && value.endsWith('"')) ||
15
+ (value.startsWith("'") && value.endsWith("'"))
16
+ ) {
17
+ value = value.slice(1, -1);
18
+ }
19
+ out[match[1]] = value;
20
+ }
21
+ return out;
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
@@ -0,0 +1,96 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { loadConfigFromFile } from 'vite';
4
+
5
+ export const DEFAULT_TOOLING_CONFIG = Object.freeze({
6
+ preset: null,
7
+ numberStyle: 'shared',
8
+ integrationFound: false,
9
+ });
10
+
11
+ const ASTRO_CONFIG_NAMES = [
12
+ 'astro.config.mjs',
13
+ 'astro.config.ts',
14
+ 'astro.config.js',
15
+ 'astro.config.cjs',
16
+ ];
17
+ const PRESETS = ['academic', 'tools', 'minimal', 'course-notes', 'research-portfolio'];
18
+
19
+ function findAstroConfig(projectRoot) {
20
+ for (const name of ASTRO_CONFIG_NAMES) {
21
+ const path = resolve(projectRoot, name);
22
+ if (existsSync(path)) return path;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function assertNumberStyle(value, configPath) {
28
+ if (value !== 'shared' && value !== 'per-kind') {
29
+ throw new Error(
30
+ `book-scaffold tooling: ${configPath} resolved invalid numberStyle ` +
31
+ `${JSON.stringify(value)}; expected shared | per-kind.`,
32
+ );
33
+ }
34
+ }
35
+
36
+ function assertPreset(value, configPath) {
37
+ if (value != null && !PRESETS.includes(value)) {
38
+ throw new Error(
39
+ `book-scaffold tooling: ${configPath} resolved invalid preset ` +
40
+ `${JSON.stringify(value)}; expected ${PRESETS.join(' | ')}.`,
41
+ );
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Evaluate the consumer's actual Astro config and read the scaffold
47
+ * integration's internal resolved metadata. Absence of a config/integration is
48
+ * a supported legacy shape and preserves shared numbering. Evaluation errors
49
+ * are deliberately not swallowed: tooling must not silently use wrong config.
50
+ */
51
+ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
52
+ const configPath = findAstroConfig(projectRoot);
53
+ if (!configPath) return { ...DEFAULT_TOOLING_CONFIG };
54
+
55
+ let loaded;
56
+ try {
57
+ loaded = await loadConfigFromFile(
58
+ { command: 'build', mode: 'production', isSsrBuild: true, isPreview: false },
59
+ configPath,
60
+ projectRoot,
61
+ 'silent',
62
+ );
63
+ } catch (error) {
64
+ const detail = error instanceof Error ? error.message : String(error);
65
+ throw new Error(`book-scaffold tooling: failed to evaluate ${configPath}: ${detail}`, {
66
+ cause: error,
67
+ });
68
+ }
69
+
70
+ if (!loaded) {
71
+ throw new Error(`book-scaffold tooling: Vite did not return config for ${configPath}.`);
72
+ }
73
+
74
+ const integrations = Array.isArray(loaded.config?.integrations)
75
+ ? loaded.config.integrations.flat(Infinity)
76
+ : [];
77
+ const integration = integrations.find((candidate) => candidate?.name === 'book-scaffold-astro');
78
+ if (!integration) return { ...DEFAULT_TOOLING_CONFIG };
79
+
80
+ const metadata = integration.__bookScaffoldResolvedConfig;
81
+ if (!metadata) {
82
+ // A config can contain an older scaffold integration with no metadata.
83
+ // Preserve the historical numbering default rather than treating upgrade
84
+ // sequencing as a config error.
85
+ return { ...DEFAULT_TOOLING_CONFIG, integrationFound: true };
86
+ }
87
+
88
+ const numberStyle = metadata.numberStyle ?? 'shared';
89
+ assertNumberStyle(numberStyle, configPath);
90
+ assertPreset(metadata.preset, configPath);
91
+ return {
92
+ preset: metadata.preset ?? null,
93
+ numberStyle,
94
+ integrationFound: true,
95
+ };
96
+ }
@@ -14,7 +14,8 @@
14
14
  * 5. <CodeRef path="..." line={N} /> — when BOOK_REPO_ROOT set,
15
15
  * path exists + line in bounds.
16
16
  * 6. <Theorem> — has a resolvable kind= (or legacy type=); else it would
17
- * render an empty label and throw at build (#121).
17
+ * render an empty label and throw at build (#121). An id'd theorem must
18
+ * resolve in labels.json, and a literal n= must agree with that index.
18
19
  * 7. <BookLink book="…" to="…"> (#96) — both props present, and book= is a
19
20
  * key in the consumer's siblingBooks registry (best-effort).
20
21
  * 8. Questions collection (#112) — each question's frontmatter `domain` is a
@@ -38,38 +39,11 @@
38
39
  import { readFile, access } from 'node:fs/promises';
39
40
  import { existsSync, readFileSync } from 'node:fs';
40
41
  import { resolve, dirname, join } from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { spawnSync } from 'node:child_process';
41
44
  import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
42
-
43
- /**
44
- * Best-effort .env reader. Mirrors `readEnvFile` in src/types.ts; kept inline
45
- * here because scripts/ is shipped as plain JS without compiling src/.
46
- *
47
- * Closes #20 — validate.mjs previously skipped the .env fallback that
48
- * `resolveProfileWithSource` honors, so consumers who set BOOK_PROFILE in
49
- * .env (per the SKILL.md and scaffold's create-book defaults) saw the CLI
50
- * silently default to minimal, masking academic-profile errors.
51
- */
52
- function readEnvFile(path = '.env') {
53
- try {
54
- if (!existsSync(path)) return {};
55
- const out = {};
56
- for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
57
- const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
58
- if (!m) continue;
59
- let val = m[2] ?? '';
60
- if (
61
- (val.startsWith('"') && val.endsWith('"')) ||
62
- (val.startsWith("'") && val.endsWith("'"))
63
- ) {
64
- val = val.slice(1, -1);
65
- }
66
- out[m[1]] = val;
67
- }
68
- return out;
69
- } catch {
70
- return {};
71
- }
72
- }
45
+ import { readEnvFile } from './read-env.mjs';
46
+ import { loadResolvedBookConfig } from './resolve-book-config.mjs';
73
47
 
74
48
  // --help / -h: non-mutating (closes #14).
75
49
  const USAGE = `Usage: book-scaffold validate [--preset <name>]
@@ -78,8 +52,8 @@ Pre-flight content validator. Checks Cite keys, XRef ids, Figure srcs,
78
52
  internal markdown links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
79
53
 
80
54
  Options:
81
- --preset <name> academic | tools | minimal | course-notes
82
- (overrides BOOK_PRESET / BOOK_PROFILE env)
55
+ --preset <name> academic | tools | minimal | course-notes | research-portfolio
56
+ Legacy override when no scaffold integration is resolved.
83
57
  --help, -h Print this message and exit (non-mutating).
84
58
 
85
59
  Env:
@@ -113,12 +87,20 @@ const CHAPTERS_DIR = await readChaptersBase(ROOT);
113
87
  const PUBLIC_DIR = resolve(ROOT, 'public');
114
88
  const DATA_DIR = resolve(ROOT, 'src/data');
115
89
 
116
- // Preset resolution (matches resolvePreset in src/types.ts):
117
- // --preset flag > BOOK_PRESET env > BOOK_PROFILE env >
90
+ let TOOLING_CONFIG;
91
+ try {
92
+ TOOLING_CONFIG = await loadResolvedBookConfig(ROOT);
93
+ } catch (error) {
94
+ process.stderr.write(`validate: fatal: ${error?.message ?? error}\n`);
95
+ process.exit(1);
96
+ }
97
+
98
+ // Preset resolution:
99
+ // composed Astro-config preset > --preset flag > BOOK_PRESET env > BOOK_PROFILE env >
118
100
  // .env BOOK_PRESET > .env BOOK_PROFILE >
119
101
  // defineBookSchemas({ preset }) in content.config.ts >
120
102
  // defineBookSchemas({ profile }) in content.config.ts (alias) >
121
- // 'minimal'.
103
+ // warned v4 compatibility fallback 'minimal'.
122
104
  // .env fallback closes #20 — without it, consumers who set BOOK_PROFILE in
123
105
  // .env (the documented convenience in SKILL.md + create-book defaults) saw
124
106
  // the CLI silently default to minimal, hiding academic-profile errors.
@@ -126,16 +108,35 @@ const DATA_DIR = resolve(ROOT, 'src/data');
126
108
  // canonical v4.5+ defineBookSchemas({ preset, chaptersBase }) form had the
127
109
  // CLI silently default to minimal, hiding research-portfolio (and any
128
110
  // non-env-set) profile errors while astro build applied the correct settings.
129
- const dotenv = readEnvFile(resolve(ROOT, '.env'));
111
+ const dotenv = readEnvFile(ROOT);
130
112
  const schemaConfig = await readBookSchemaConfig(ROOT);
131
- const PRESET =
113
+ const PRESET_CANDIDATE =
114
+ TOOLING_CONFIG.preset ??
132
115
  presetFromFlag ??
133
116
  process.env.BOOK_PRESET ??
134
117
  process.env.BOOK_PROFILE ??
135
118
  dotenv.BOOK_PRESET ??
136
119
  dotenv.BOOK_PROFILE ??
137
- schemaConfig.preset ??
138
- 'minimal';
120
+ schemaConfig.preset;
121
+ const PRESETS = ['academic', 'tools', 'minimal', 'course-notes', 'research-portfolio'];
122
+ if (presetFlagIdx >= 0 && !presetFromFlag) {
123
+ process.stderr.write('validate: --preset requires a value.\n');
124
+ process.exit(2);
125
+ }
126
+ if (PRESET_CANDIDATE && !PRESETS.includes(PRESET_CANDIDATE)) {
127
+ process.stderr.write(
128
+ `validate: preset must be one of ${PRESETS.join(' | ')} ` +
129
+ `(got ${JSON.stringify(PRESET_CANDIDATE)}).\n`,
130
+ );
131
+ process.exit(1);
132
+ }
133
+ const PRESET = PRESET_CANDIDATE ?? 'minimal';
134
+ if (!PRESET_CANDIDATE) {
135
+ process.stderr.write(
136
+ "validate: no preset resolved; falling back to 'minimal' for v4 compatibility. " +
137
+ 'This fallback will be removed in v5; configure a built-in style or BOOK_PRESET.\n',
138
+ );
139
+ }
139
140
  // Alias kept for downstream message text only; the resolution above is canonical.
140
141
  const PROFILE = PRESET;
141
142
  const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
@@ -213,6 +214,37 @@ const warnings = [];
213
214
  const fail = (file, line, msg) => errors.push({ file, line, msg });
214
215
  const warn = (file, line, msg) => warnings.push({ file, line, msg });
215
216
 
217
+ // ===== Self-heal missing generated artifacts (#186) =====
218
+ // These files are intentionally gitignored. Direct `book-scaffold validate`
219
+ // bypasses npm's prevalidate lifecycle, so rebuild each missing artifact before
220
+ // loading it. Existing files remain untouched; child diagnostics and failures
221
+ // are propagated verbatim instead of becoming downstream unknown-id noise.
222
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
223
+ function regenerate(scriptName, artifact) {
224
+ process.stdout.write(`validate: ${artifact} is missing — regenerating via ${scriptName} (#186)\n`);
225
+ const result = spawnSync(process.execPath, [join(scriptDir, scriptName)], {
226
+ cwd: ROOT,
227
+ env: process.env,
228
+ encoding: 'utf8',
229
+ });
230
+ if (result.stdout) process.stdout.write(result.stdout);
231
+ if (result.stderr) process.stderr.write(result.stderr);
232
+ if (result.error || result.status !== 0) {
233
+ const detail = result.error ? `: ${result.error.message}` : '';
234
+ process.stderr.write(
235
+ `validate: ${scriptName} failed (exit ${result.status ?? 1})${detail} — cannot self-heal.\n`,
236
+ );
237
+ process.exit(result.status ?? 1);
238
+ }
239
+ }
240
+
241
+ if (!existsSync(join(DATA_DIR, 'labels.json'))) {
242
+ regenerate('build-labels.mjs', 'src/data/labels.json');
243
+ }
244
+ if (!existsSync(join(DATA_DIR, 'references.json'))) {
245
+ regenerate('build-bib.mjs', 'src/data/references.json');
246
+ }
247
+
216
248
  // ===== Load reference data (graceful when missing) =====
217
249
  async function loadJson(path) {
218
250
  try {
@@ -267,6 +299,29 @@ function lineOf(content, idx) {
267
299
  return content.slice(0, idx).split('\n').length;
268
300
  }
269
301
 
302
+ /**
303
+ * Return a statically knowable n= value. Quoted strings and braced
304
+ * string/numeric literals are safe to compare; identifiers, calls,
305
+ * interpolation, and all other expressions are deliberately skipped.
306
+ */
307
+ function literalTheoremNumber(attrs) {
308
+ const quoted = attrs.match(/\bn\s*=\s*(["'])(.*?)\1/);
309
+ if (quoted) return quoted[2];
310
+
311
+ const braced = attrs.match(/\bn\s*=\s*\{\s*([^}]*?)\s*\}/);
312
+ if (!braced) return null;
313
+ const expression = braced[1].trim();
314
+ const stringLiteral = expression.match(/^(["'`])([\s\S]*)\1$/);
315
+ if (stringLiteral) {
316
+ if (stringLiteral[1] === '`' && stringLiteral[2].includes('${')) return null;
317
+ return stringLiteral[2];
318
+ }
319
+ if (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(expression)) {
320
+ return String(Number(expression));
321
+ }
322
+ return null;
323
+ }
324
+
270
325
  // #96: best-effort siblingBooks registry keys from astro.config.mjs, so the
271
326
  // <BookLink> check can flag an unknown book= earlier than the component's
272
327
  // build-time throw. null = couldn't determine → membership not checked (the
@@ -359,13 +414,33 @@ for (const rel of chapterFiles) {
359
414
  );
360
415
  }
361
416
  const thmId = attrs.match(/\bid=["']([^"']+)["']/);
362
- if (thmId && !/\blabel\s*=\s*["']/.test(attrs) && !labels[thmId[1]]) {
417
+ const hasLabelOverride = /\blabel\s*=/.test(attrs);
418
+ if (thmId && !hasLabelOverride && !labels[thmId[1]]) {
363
419
  fail(
364
420
  rel,
365
421
  lineOf(content, m.index),
366
422
  `<Theorem id="${thmId[1]}"> — not in labels.json; heading silently renders unnumbered. Run build:labels, or fix the id.`,
367
423
  );
368
424
  }
425
+ // #176: compare only literal n= values. Dynamic expressions cannot be
426
+ // evaluated reliably by this intentionally regex-based validator, and a
427
+ // label= override explicitly opts out of labels.json auto-numbering.
428
+ const explicitNumber = hasLabelOverride ? null : literalTheoremNumber(attrs);
429
+ const indexedNumber = thmId ? labels[thmId[1]]?.number : null;
430
+ if (
431
+ thmId &&
432
+ explicitNumber !== null &&
433
+ indexedNumber != null &&
434
+ explicitNumber !== String(indexedNumber)
435
+ ) {
436
+ fail(
437
+ rel,
438
+ lineOf(content, m.index),
439
+ `<Theorem id="${thmId[1]}" n="${explicitNumber}"> — labels.json numbers it ` +
440
+ `${indexedNumber}, and the rendered heading + every XRef use the index. ` +
441
+ 'Drop the stale n= (auto-numbering wins) or re-run build:labels.',
442
+ );
443
+ }
369
444
  }
370
445
 
371
446
  // 7. BookLink (#96): structural (book= + to=) + best-effort registry membership.
@@ -544,46 +619,6 @@ let questionsChecked = 0;
544
619
  }
545
620
  }
546
621
 
547
- // ===== v4.6.0 (issue #77): missing-prereq re-framing =====
548
- //
549
- // When errors are downstream symptoms of a missing artifact (references.json
550
- // or labels.json), abort with ONE leading error pointing at the prereq
551
- // instead of printing 25 "Unknown bibkey" / "Unknown XRef" symptoms. Single
552
- // clean signal: fix the prereq. Per D12 of the v4.6.0 plan.
553
- {
554
- if (PROFILE === 'academic') {
555
- const refsPath = join(DATA_DIR, 'references.json');
556
- const hasBibkeyErrors = errors.some((e) => /Unknown bibkey/.test(e.msg));
557
- if (hasBibkeyErrors && !existsSync(refsPath)) {
558
- console.error(
559
- `\n✗ Validate cannot run: src/data/references.json is missing.\n\n` +
560
- `This file is generated from bibliography.bib by 'npm run build:bib'.\n` +
561
- `Run that first, OR adopt the prevalidate npm hook convention so\n` +
562
- `'npm run validate' regenerates it automatically:\n\n` +
563
- ` "prevalidate": "npm run build:bib && npm run build:labels --if-present"\n` +
564
- ` "validate": "book-scaffold validate"\n\n` +
565
- `See package/recipes/19-prevalidate-hook.md.\n`,
566
- );
567
- process.exit(1);
568
- }
569
- }
570
- const labelsPath = join(DATA_DIR, 'labels.json');
571
- // #126: also collapse <Theorem id> not-in-labels errors (they share the
572
- // "not in labels.json" phrase) under the same missing-prereq message.
573
- const hasXrefErrors = errors.some((e) => /not in labels\.json/.test(e.msg));
574
- if (hasXrefErrors && !existsSync(labelsPath)) {
575
- console.error(
576
- `\n✗ Validate cannot run: src/data/labels.json is missing.\n\n` +
577
- `This file is generated from <Theorem id="..."> and <Figure id="..."> markers\n` +
578
- `in chapter MDX by 'npm run build:labels'. Run that first, OR adopt the\n` +
579
- `prevalidate npm hook convention so 'npm run validate' regenerates it:\n\n` +
580
- ` "prevalidate": "npm run build:bib && npm run build:labels --if-present"\n\n` +
581
- `See package/recipes/19-prevalidate-hook.md.\n`,
582
- );
583
- process.exit(1);
584
- }
585
- }
586
-
587
622
  // ===== Report =====
588
623
  const format = ({ file, line, msg }) => ` ${file}:${line} ${msg}`;
589
624
  if (warnings.length > 0) {
@@ -592,9 +627,15 @@ if (warnings.length > 0) {
592
627
  }
593
628
  if (errors.length === 0) {
594
629
  const qNote = questionsChecked > 0 ? ` + ${questionsChecked} question(s)` : '';
595
- console.log(`validate: ✓ ${chapterFiles.length} chapter(s)${qNote} checked (profile=${PROFILE}); no errors.`);
630
+ console.log(
631
+ `validate: ✓ ${chapterFiles.length} chapter(s)${qNote} checked ` +
632
+ `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}); no errors.`,
633
+ );
596
634
  process.exit(0);
597
635
  }
598
- console.error(`validate: ✗ ${errors.length} error(s) in ${chapterFiles.length} chapter(s) (profile=${PROFILE}):`);
636
+ console.error(
637
+ `validate: ✗ ${errors.length} error(s) in ${chapterFiles.length} chapter(s) ` +
638
+ `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}):`,
639
+ );
599
640
  errors.forEach((e) => console.error(format(e)));
600
641
  process.exit(errors.length);
@@ -27,7 +27,12 @@
27
27
  * for migration from the v3 `preset:` shorthand.
28
28
  */
29
29
  import type { AstroIntegration, AstroUserConfig } from 'astro';
30
- import type { BookPreset, ReleaseStatusConfig, RouteToggles } from '../types.js';
30
+ import type {
31
+ BookPreset,
32
+ NumberStyle,
33
+ ReleaseStatusConfig,
34
+ RouteToggles,
35
+ } from '../types.js';
31
36
 
32
37
  // ===== Branded nominal type =====
33
38
 
@@ -90,6 +95,9 @@ export interface Style {
90
95
  /** Profile that backs this style — determines schema + default routes + styles + KaTeX wiring. */
91
96
  readonly preset?: BookPreset;
92
97
 
98
+ /** Theorem-family numbering strategy; shallow override (last wins). */
99
+ readonly numberStyle?: NumberStyle;
100
+
93
101
  /** Book's deployed origin (sitemap, canonical, Pagefind). Required at composition end;
94
102
  * optional inside a Style (so styles can omit it and consumers can provide per-book). */
95
103
  readonly site?: string;
@@ -118,10 +126,10 @@ export interface Style {
118
126
  * scalar fields override. */
119
127
  readonly markdown?: AstroUserConfig['markdown'];
120
128
 
121
- /** Deploy target drives create-book's wrangler.toml shape.
122
- * - `'workers'`: Cloudflare Workers + Static Assets (default for academic/tools/minimal)
123
- * - `'pages'`: Cloudflare Pages (default for research-portfolio/course-notes)
124
- * Closes #50. */
129
+ /** Reserved legacy metadata. It is composed but has no runtime or
130
+ * create-book effect: create-book chooses wrangler.toml from its CLI
131
+ * preset before a consumer Style exists (#180).
132
+ * @deprecated Inert in v4; scheduled for removal in v5. */
125
133
  readonly deploy?: 'pages' | 'workers';
126
134
  /**
127
135
  * v4.26.2 (#149; style inheritance fixed in v4.26.3): release-state
@@ -199,7 +207,7 @@ export function defineStyle(opts: StyleInput): Style {
199
207
  * - Top-level `defineBookConfig` fields beat any style (handled in config.ts)
200
208
  *
201
209
  * Per-key merge strategy:
202
- * - `preset`, `site`, `deploy`, `mdxComponentsModule`, `name`, `releaseStatus`
210
+ * - `preset`, `numberStyle`, `site`, `deploy`, `mdxComponentsModule`, `name`, `releaseStatus`
203
211
  * → shallow override (last defined wins; `releaseStatus: false` suppresses)
204
212
  * - `routes` → per-route spread (each route key independently overridable)
205
213
  * - `katexMacros` → per-macro spread (each macro key independently overridable)
@@ -221,6 +229,7 @@ export function composeStyles(styles: readonly Style[]): Style {
221
229
  // Shallow override for primitives + readonly-scalar fields.
222
230
  if (style.name !== undefined) merged.name = style.name;
223
231
  if (style.preset !== undefined) merged.preset = style.preset;
232
+ if (style.numberStyle !== undefined) merged.numberStyle = style.numberStyle;
224
233
  if (style.site !== undefined) merged.site = style.site;
225
234
  if (style.deploy !== undefined) merged.deploy = style.deploy;
226
235
  if (style.releaseStatus !== undefined) {
@@ -29,11 +29,33 @@ export interface ChapterLike {
29
29
  data: Record<string, unknown>;
30
30
  }
31
31
 
32
- /** Normalize a base URL to exactly one trailing slash (`''` → `'/'`). */
33
- function normBase(baseUrl: string): string {
32
+ /**
33
+ * Normalize a base URL to exactly one trailing slash (`''`/`undefined` → `'/'`).
34
+ *
35
+ * v4.27.0 (#182): promoted from this module's private helper to THE shared
36
+ * normalizer — 18 `.astro` files previously inlined the same normalization in
37
+ * three regex idioms. Astro does not guarantee a trailing slash on `base`
38
+ * (`'/foo'` is a documented form), so every `import.meta.env.BASE_URL` read
39
+ * must pass through here before href composition: `${normalizeBase(...)}chapters/`.
40
+ * Takes the base as a PARAMETER because src/lib ships pre-compiled in dist/,
41
+ * where Vite's import.meta.env replacement cannot reach (see exam-manifest.ts).
42
+ */
43
+ export function normalizeBase(baseUrl: string | undefined): string {
34
44
  return (baseUrl || '/').replace(/\/*$/, '/');
35
45
  }
36
46
 
47
+ /**
48
+ * The composing variant (#182): strip ALL trailing slashes (`'/'` → `''`,
49
+ * `'/foo/'` → `'/foo'`) for `${baseNoSlash(...)}/answers`-style templates
50
+ * where the literal supplies the slash (Rationale.astro's route detection).
51
+ */
52
+ export function baseNoSlash(baseUrl: string | undefined): string {
53
+ return (baseUrl || '/').replace(/\/+$/, '');
54
+ }
55
+
56
+ /** Internal alias for the pre-#182 name. */
57
+ const normBase = normalizeBase;
58
+
37
59
  /** Replace `:book` / `:slug` / `:route` / `:id` tokens; `\b` keeps each token
38
60
  * whole, so order is irrelevant and `:id` never matches inside `:identifier`. */
39
61
  function fillTokens(pattern: string, tokens: Record<string, string>): string {
package/styles/layout.css CHANGED
@@ -4,10 +4,10 @@
4
4
  *
5
5
  * 1. Page chrome at very top: theme toggle (top-right, position: fixed).
6
6
  *
7
- * 2. Optional left sidebar (Sidebar.astro). At ≥64rem (1024px), grid
8
- * pins it to the left edge; below 1024px it's hidden (mobile pages
9
- * don't carry the nav). Toggle per-page via Base.astro's `showSidebar`
10
- * prop — default true; the landing page sets false.
7
+ * 2. Optional left sidebar (Sidebar.astro). At ≥80rem (1280px), grid
8
+ * pins it to the left edge; below 1280px it's hidden and the mobile
9
+ * nav drawer is the chapter nav (v4.26.0, #80). Toggle per-page via
10
+ * Base.astro's `showSidebar` prop — default true; landing sets false.
11
11
  *
12
12
  * 3. Main content (.prose) — Tufte 2-column with sidenotes in right
13
13
  * margin on desktop (≥48rem), inline on mobile (<48rem). See
@@ -225,15 +225,21 @@
225
225
  }
226
226
 
227
227
  /* ===== Mobile/tablet nav drawer (v4.26.0, #80) =====
228
- * Below the sidebar breakpoint (64rem) the left Sidebar is display:none — this
228
+ * Below the sidebar breakpoint (80rem matching .layout-with-sidebar above and
229
+ * the controller's desktop auto-close) the left Sidebar is display:none — this
229
230
  * is the navigation in that range: a hamburger in the chrome row toggles a
230
231
  * slide-in drawer reusing NavContent (the same book-scoped nav source as the
231
232
  * sidebar). No-JS baseline: `:target` opens it (the toggle is <a href="#nav-drawer">);
232
233
  * the inline controller in Base.astro enhances it with focus-trap + ESC + body
233
- * scroll-lock. Hidden at ≥64rem, where the persistent sidebar is the nav. The
234
- * ≥64rem gutter-fit (sidenote/section-map overflow) is a separate fix. */
234
+ * scroll-lock. Hidden at ≥80rem, where the persistent sidebar is the nav. The
235
+ * ≥64rem gutter tier (sidenote/section-map floats) is a separate, lower breakpoint. */
235
236
  @media (min-width: 80rem) {
236
- .nav-toggle,
237
+ /* #183: `.chrome-buttons .nav-toggle` (0,2,0), not bare `.nav-toggle` —
238
+ * Base.astro's global `.chrome-button { display: inline-flex }` ties a
239
+ * (0,1,0) selector and wins on cascade order, leaving the hamburger
240
+ * visible at desktop where it opens an invisible drawer and strands the
241
+ * body scroll-lock. Caught by drawer-interaction.spec.ts. */
242
+ .chrome-buttons .nav-toggle,
237
243
  .nav-drawer {
238
244
  display: none;
239
245
  }