@brandon_m_behring/book-scaffold-astro 4.26.3 → 4.27.1
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/AGENTS.md +6 -0
- package/CLAUDE.md +35 -10
- package/LATEX_TO_MDX_MAPPING.md +1 -1
- package/LICENSE +27 -0
- package/LICENSE-CONTENT +19 -0
- package/README.md +33 -0
- package/components/AssessmentTest.astro +2 -1
- package/components/ChapterNav.astro +2 -2
- package/components/Cite.astro +2 -1
- package/components/NavContent.astro +2 -2
- package/components/PartReview.astro +2 -1
- package/components/Rationale.astro +3 -2
- package/components/Sidebar.astro +2 -1
- package/components/Term.astro +2 -1
- package/components/TipsCard.astro +2 -1
- package/components/VersionSelector.tsx +39 -36
- package/components/WeekRef.astro +2 -1
- package/components/XRef.astro +2 -1
- package/dist/components/VersionSelector.d.ts +16 -2
- package/dist/components/VersionSelector.mjs +18 -17
- package/dist/index.d.ts +24 -6
- package/dist/index.mjs +105 -5
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +8 -1
- package/dist/{types-CWXP1S4b.d.ts → types-CZrkqzpC.d.ts} +58 -26
- package/layouts/Base.astro +30 -19
- package/package.json +7 -4
- package/pages/answers.astro +2 -1
- package/pages/chapters.astro +2 -1
- package/pages/exercises.astro +2 -1
- package/pages/index.astro +4 -3
- package/pages/search.astro +2 -1
- package/pages/tips.astro +2 -1
- package/recipes/01-add-math.md +40 -28
- package/recipes/04-component-library.md +14 -4
- package/recipes/05-deploy-cloudflare.md +44 -0
- package/recipes/06-mobile-first-layout.md +25 -2
- package/recipes/09-validation.md +18 -9
- package/recipes/15-defining-styles.md +5 -2
- package/recipes/19-prevalidate-hook.md +29 -83
- package/scripts/build-bib.mjs +7 -3
- package/scripts/build-labels.mjs +33 -16
- package/scripts/read-env.mjs +25 -0
- package/scripts/resolve-book-config.mjs +96 -0
- package/scripts/validate.mjs +125 -84
- package/src/lib/define-style.ts +15 -6
- package/src/lib/nav-href.ts +24 -2
- package/styles/layout.css +14 -8
- package/styles/tokens.css +8 -10
package/scripts/build-labels.mjs
CHANGED
|
@@ -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`.
|
|
16
|
-
*
|
|
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
|
|
22
|
-
*
|
|
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
|
-
|
|
233
|
+
const resolvedLabel = theoremLabel({
|
|
231
234
|
kind: extractAttr(attrs, 'kind') ?? undefined,
|
|
232
235
|
type: extractAttr(attrs, 'type') ?? undefined,
|
|
233
|
-
})
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
+
}
|
package/scripts/validate.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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(
|
|
111
|
+
const dotenv = readEnvFile(ROOT);
|
|
130
112
|
const schemaConfig = await readBookSchemaConfig(ROOT);
|
|
131
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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);
|
package/src/lib/define-style.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
122
|
-
* -
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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) {
|
package/src/lib/nav-href.ts
CHANGED
|
@@ -29,11 +29,33 @@ export interface ChapterLike {
|
|
|
29
29
|
data: Record<string, unknown>;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
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 ≥
|
|
8
|
-
* pins it to the left edge; below
|
|
9
|
-
*
|
|
10
|
-
* prop — default true;
|
|
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 (
|
|
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 ≥
|
|
234
|
-
* ≥64rem gutter
|
|
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
|
}
|