@brandon_m_behring/book-scaffold-astro 4.28.0 → 4.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -1
- package/components/BookLink.astro +3 -2
- package/dist/index.d.ts +8 -70
- package/dist/index.mjs +21 -4
- package/dist/lib/nav-href.d.ts +66 -0
- package/dist/lib/nav-href.mjs +44 -0
- package/dist/schemas.d.ts +1 -1
- package/dist/{types-CZrkqzpC.d.ts → types-DgSlAew3.d.ts} +32 -8
- package/package.json +5 -1
- package/recipes/09-validation.md +30 -2
- package/recipes/22-responsive-nav-and-multibook-routing.md +5 -0
- package/scripts/build-labels.mjs +88 -34
- package/scripts/resolve-book-config.mjs +74 -0
- package/scripts/validate.mjs +239 -29
- package/scripts/walk-mdx.mjs +122 -8
- package/src/lib/book-link.ts +25 -5
package/scripts/build-labels.mjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* build-labels.mjs — emit src/data/labels.json for <XRef> resolution.
|
|
4
4
|
*
|
|
5
|
-
* Walks the consumer's `src/content/chapters/**\/*.mdx`, extracts
|
|
6
|
-
* labelable component invocation (Theorem, Figure,
|
|
7
|
-
* `LABELABLE_TYPES` below), and assigns it an entry of the form
|
|
5
|
+
* Walks the consumer's `src/content/chapters/**\/*.mdx`, extracts every h2–h6
|
|
6
|
+
* Markdown heading plus each labelable component invocation (Theorem, Figure,
|
|
7
|
+
* Section, … — see `LABELABLE_TYPES` below), and assigns it an entry of the form
|
|
8
8
|
* { href, display: "Theorem 4.2", number: "4.2" }
|
|
9
9
|
* matching the LaTeX `\cref` convention. The map is consumed by XRef.astro
|
|
10
10
|
* (display + href) AND Theorem.astro (#126: `number`, so a heading auto-
|
|
@@ -25,11 +25,15 @@
|
|
|
25
25
|
* - tools profile: `chapter` field (number).
|
|
26
26
|
* - academic profile: `week` field (number).
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* Chapter IDs use frontmatter `slug:` when set, else the chapter-relative path
|
|
29
|
+
* minus `.md`/`.mdx` (so nested content IDs preserve their directory). Hrefs
|
|
30
|
+
* are resolved through the evaluated defineBookConfig `chapterRoute` and
|
|
31
|
+
* `bookField`; the default remains `chapters/<id>#<label>`.
|
|
32
|
+
*
|
|
33
|
+
* Heading anchors come from Astro's own Markdown processor, including its
|
|
34
|
+
* smartypants text normalization and GitHubSlugger duplicate suffixes. h1 is
|
|
35
|
+
* intentionally excluded because it is the chapter title, not an in-chapter
|
|
36
|
+
* BookLink target.
|
|
33
37
|
*
|
|
34
38
|
* Optional override:
|
|
35
39
|
* <Theorem id="…" label="Custom display" />
|
|
@@ -45,7 +49,9 @@
|
|
|
45
49
|
* Designed to run in <2 s on a medium book.
|
|
46
50
|
*/
|
|
47
51
|
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
48
|
-
import { resolve, relative, join,
|
|
52
|
+
import { resolve, relative, join, dirname, sep } from 'node:path';
|
|
53
|
+
import { pathToFileURL } from 'node:url';
|
|
54
|
+
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
|
|
49
55
|
import { readChaptersBase } from './walk-mdx.mjs';
|
|
50
56
|
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
51
57
|
// #126: reuse the ONE kind vocabulary (theorem-label.ts → its own lean tsup
|
|
@@ -54,13 +60,16 @@ import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
|
54
60
|
// throw as the render path, #121) one build step earlier. Requires `dist/`
|
|
55
61
|
// (run `npm run build` first; the published tarball ships dist + scripts).
|
|
56
62
|
import { theoremLabel } from '../dist/lib/theorem-label.mjs';
|
|
63
|
+
// #147: reuse the same route-token resolver as Sidebar/ChapterNav rather than
|
|
64
|
+
// maintaining a second, hardcoded `chapters/<slug>` interpretation here.
|
|
65
|
+
import { chapterHref } from '../dist/lib/nav-href.mjs';
|
|
57
66
|
|
|
58
67
|
// --help / -h: non-mutating (closes #14).
|
|
59
68
|
const USAGE = `Usage: book-scaffold build-labels
|
|
60
69
|
|
|
61
|
-
Emit src/data/labels.json for <XRef> resolution. Walks chapter
|
|
62
|
-
|
|
63
|
-
like "Theorem 4.2"
|
|
70
|
+
Emit src/data/labels.json for <XRef> and <BookLink> resolution. Walks chapter
|
|
71
|
+
Markdown/MDX files, indexes h2–h6 anchors, and extracts labelable components
|
|
72
|
+
(Theorem, Figure, ...) with display strings like "Theorem 4.2".
|
|
64
73
|
|
|
65
74
|
Env:
|
|
66
75
|
BOOK_CHAPTERS_DIR Override chapters dir (default: src/content/chapters).
|
|
@@ -69,8 +78,8 @@ Env:
|
|
|
69
78
|
Options:
|
|
70
79
|
--help, -h Print this message and exit (non-mutating).
|
|
71
80
|
|
|
72
|
-
Numbering
|
|
73
|
-
|
|
81
|
+
Numbering and chapter hrefs are read from evaluated defineBookConfig metadata.
|
|
82
|
+
Defaults are shared numbering and /chapters/:id/ when no integration resolves.
|
|
74
83
|
`;
|
|
75
84
|
|
|
76
85
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
@@ -111,12 +120,14 @@ const TYPE_DISPLAY = {
|
|
|
111
120
|
|
|
112
121
|
// ===== Frontmatter parsing =====
|
|
113
122
|
|
|
114
|
-
function
|
|
123
|
+
function splitFrontmatter(source) {
|
|
115
124
|
// Standard MDX/YAML frontmatter: `---\n…\n---`.
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
// Remove it before Markdown processing: YAML comments beginning with `#`
|
|
126
|
+
// must not become phantom headings.
|
|
127
|
+
const m = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
128
|
+
if (!m) return { frontmatter: {}, body: source };
|
|
118
129
|
const fm = {};
|
|
119
|
-
for (const line of m[1].split(
|
|
130
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
120
131
|
const kv = line.match(/^(\w+)\s*:\s*(.+?)\s*$/);
|
|
121
132
|
if (!kv) continue;
|
|
122
133
|
const [, key, raw] = kv;
|
|
@@ -125,7 +136,7 @@ function parseFrontmatter(source) {
|
|
|
125
136
|
if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
|
|
126
137
|
fm[key] = val;
|
|
127
138
|
}
|
|
128
|
-
return fm;
|
|
139
|
+
return { frontmatter: fm, body: source.slice(m[0].length) };
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
function chapterNumberOf(frontmatter) {
|
|
@@ -187,9 +198,13 @@ async function walkChapters(dir) {
|
|
|
187
198
|
|
|
188
199
|
async function main() {
|
|
189
200
|
const cwd = process.cwd();
|
|
190
|
-
const { numberStyle } = await loadResolvedBookConfig(cwd);
|
|
201
|
+
const { numberStyle, chapterRoute, bookField } = await loadResolvedBookConfig(cwd);
|
|
191
202
|
const chaptersDir = resolve(cwd, CHAPTERS_DIR);
|
|
192
203
|
const files = await walkChapters(chaptersDir);
|
|
204
|
+
// Syntax highlighting cannot affect heading metadata and is expensive to
|
|
205
|
+
// initialize. Everything that does affect Astro heading text/IDs (GFM,
|
|
206
|
+
// smartypants, rehypeHeadingIds/GitHubSlugger) retains Astro's defaults.
|
|
207
|
+
const headingProcessor = await createMarkdownProcessor({ syntaxHighlight: false });
|
|
193
208
|
|
|
194
209
|
const labels = {};
|
|
195
210
|
const tagRegex = buildTagRegex();
|
|
@@ -198,16 +213,63 @@ async function main() {
|
|
|
198
213
|
|
|
199
214
|
for (const file of files) {
|
|
200
215
|
const source = await readFile(file, 'utf8');
|
|
201
|
-
const fm =
|
|
216
|
+
const { frontmatter: fm, body } = splitFrontmatter(source);
|
|
202
217
|
const chapterNum = chapterNumberOf(fm);
|
|
203
|
-
const
|
|
218
|
+
const contentId = relative(chaptersDir, file)
|
|
219
|
+
.split(sep)
|
|
220
|
+
.join('/')
|
|
221
|
+
.replace(/\.mdx?$/, '');
|
|
222
|
+
const entryId = (typeof fm.slug === 'string' && fm.slug.length > 0)
|
|
204
223
|
? fm.slug
|
|
205
|
-
:
|
|
224
|
+
: contentId;
|
|
225
|
+
const chapterPath = chapterHref(
|
|
226
|
+
{ id: entryId, data: fm },
|
|
227
|
+
chapterRoute,
|
|
228
|
+
'/',
|
|
229
|
+
bookField,
|
|
230
|
+
).replace(/^\/+|\/+$/g, '');
|
|
231
|
+
|
|
232
|
+
const addLabel = (id, value) => {
|
|
233
|
+
if (labels[id]) {
|
|
234
|
+
// Duplicate id — surface but don't fail; consumer's validator catches
|
|
235
|
+
// collisions with full diagnostic context. Component IDs retain their
|
|
236
|
+
// historical precedence over an identically named heading anchor.
|
|
237
|
+
process.stderr.write(
|
|
238
|
+
`build-labels: WARN duplicate id "${id}" (first in ` +
|
|
239
|
+
`${labels[id].href.split('#')[0]}, now in ${entryId})\n`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
labels[id] = value;
|
|
243
|
+
};
|
|
206
244
|
|
|
207
245
|
// Per-chapter counters reset for each file.
|
|
208
246
|
const counters = {};
|
|
209
247
|
let foundInChapter = 0;
|
|
210
248
|
|
|
249
|
+
// #147: BookLink fragments normally target prose sections rather than
|
|
250
|
+
// labelable components. Astro owns both text extraction and GitHub-style
|
|
251
|
+
// slug collision behavior, so consume its heading metadata directly.
|
|
252
|
+
const rendered = await headingProcessor.render(body, {
|
|
253
|
+
fileURL: pathToFileURL(file),
|
|
254
|
+
frontmatter: fm,
|
|
255
|
+
});
|
|
256
|
+
for (const heading of rendered.metadata.headings) {
|
|
257
|
+
if (heading.depth < 2 || heading.depth > 6) continue;
|
|
258
|
+
foundInChapter += 1;
|
|
259
|
+
totalIds += 1;
|
|
260
|
+
const href = `${chapterPath}#${heading.slug}`;
|
|
261
|
+
// Heading fragments are only document-local: many chapters legitimately
|
|
262
|
+
// contain `#summary`. A path-qualified, opaque key preserves every one
|
|
263
|
+
// without colliding with component IDs or another chapter. BookLink
|
|
264
|
+
// validation resolves the exact href VALUE; consumers must not derive
|
|
265
|
+
// heading semantics from this internal key.
|
|
266
|
+
addLabel(`heading:${href}`, {
|
|
267
|
+
href,
|
|
268
|
+
display: heading.text,
|
|
269
|
+
number: null,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
211
273
|
for (const match of source.matchAll(tagRegex)) {
|
|
212
274
|
const [, componentName, attrs] = match;
|
|
213
275
|
const id = extractAttr(attrs, 'id');
|
|
@@ -267,21 +329,13 @@ async function main() {
|
|
|
267
329
|
: String(counters[counterKey]);
|
|
268
330
|
const display = labelOverride ?? `${word} ${number}`;
|
|
269
331
|
|
|
270
|
-
|
|
271
|
-
// Duplicate id — surface but don't fail; consumer's validator
|
|
272
|
-
// catches collisions with full diagnostic context.
|
|
273
|
-
process.stderr.write(
|
|
274
|
-
`build-labels: WARN duplicate id "${id}" (first in ` +
|
|
275
|
-
`${labels[id].href.split('#')[0]}, now in ${slug})\n`,
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
labels[id] = {
|
|
332
|
+
addLabel(id, {
|
|
279
333
|
// #142: base-less ref — XRef.astro prefixes BASE_URL at render so one
|
|
280
334
|
// labels.json serves any deploy base (root or path-proxied series).
|
|
281
|
-
href:
|
|
335
|
+
href: `${chapterPath}#${id}`,
|
|
282
336
|
display,
|
|
283
337
|
number,
|
|
284
|
-
};
|
|
338
|
+
});
|
|
285
339
|
}
|
|
286
340
|
|
|
287
341
|
if (foundInChapter > 0) chaptersWithIds += 1;
|
|
@@ -5,6 +5,9 @@ import { loadConfigFromFile } from 'vite';
|
|
|
5
5
|
export const DEFAULT_TOOLING_CONFIG = Object.freeze({
|
|
6
6
|
preset: null,
|
|
7
7
|
numberStyle: 'shared',
|
|
8
|
+
siblingBooks: Object.freeze({}),
|
|
9
|
+
chapterRoute: '/chapters/:id/',
|
|
10
|
+
bookField: 'book',
|
|
8
11
|
integrationFound: false,
|
|
9
12
|
});
|
|
10
13
|
|
|
@@ -42,6 +45,64 @@ function assertPreset(value, configPath) {
|
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
function resolveNonEmptyString(value, fallback, field, configPath) {
|
|
49
|
+
if (value == null) return fallback;
|
|
50
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`book-scaffold tooling: ${configPath} resolved invalid ${field} ` +
|
|
53
|
+
`${JSON.stringify(value)}; expected a non-empty string.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveSiblingBooks(value, configPath) {
|
|
60
|
+
if (value == null) return {};
|
|
61
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`book-scaffold tooling: ${configPath} resolved invalid siblingBooks ` +
|
|
64
|
+
`${JSON.stringify(value)}; expected an object registry.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const resolved = [];
|
|
69
|
+
for (const [book, entry] of Object.entries(value)) {
|
|
70
|
+
if (typeof entry === 'string') {
|
|
71
|
+
if (entry.length === 0) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`book-scaffold tooling: ${configPath} resolved invalid siblingBooks.${book}; ` +
|
|
74
|
+
'URL strings must not be empty.',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
resolved.push([book, entry]);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
entry === null ||
|
|
83
|
+
typeof entry !== 'object' ||
|
|
84
|
+
Array.isArray(entry) ||
|
|
85
|
+
typeof entry.url !== 'string' ||
|
|
86
|
+
entry.url.length === 0 ||
|
|
87
|
+
(entry.labels !== undefined &&
|
|
88
|
+
(typeof entry.labels !== 'string' || entry.labels.length === 0))
|
|
89
|
+
) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`book-scaffold tooling: ${configPath} resolved invalid siblingBooks.${book}; ` +
|
|
92
|
+
'expected a URL string or { url: string, labels?: string }.',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
resolved.push([
|
|
96
|
+
book,
|
|
97
|
+
{
|
|
98
|
+
url: entry.url,
|
|
99
|
+
...(entry.labels === undefined ? {} : { labels: entry.labels }),
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
return Object.fromEntries(resolved);
|
|
104
|
+
}
|
|
105
|
+
|
|
45
106
|
/**
|
|
46
107
|
* Evaluate the consumer's actual Astro config and read the scaffold
|
|
47
108
|
* integration's internal resolved metadata. Absence of a config/integration is
|
|
@@ -91,6 +152,19 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
|
|
|
91
152
|
return {
|
|
92
153
|
preset: metadata.preset ?? null,
|
|
93
154
|
numberStyle,
|
|
155
|
+
siblingBooks: resolveSiblingBooks(metadata.siblingBooks, configPath),
|
|
156
|
+
chapterRoute: resolveNonEmptyString(
|
|
157
|
+
metadata.chapterRoute,
|
|
158
|
+
DEFAULT_TOOLING_CONFIG.chapterRoute,
|
|
159
|
+
'chapterRoute',
|
|
160
|
+
configPath,
|
|
161
|
+
),
|
|
162
|
+
bookField: resolveNonEmptyString(
|
|
163
|
+
metadata.bookField,
|
|
164
|
+
DEFAULT_TOOLING_CONFIG.bookField,
|
|
165
|
+
'bookField',
|
|
166
|
+
configPath,
|
|
167
|
+
),
|
|
94
168
|
integrationFound: true,
|
|
95
169
|
};
|
|
96
170
|
}
|
package/scripts/validate.mjs
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* 6. <Theorem> — has a resolvable kind= (or legacy type=); else it would
|
|
17
17
|
* render an empty label and throw at build (#121). An id'd theorem must
|
|
18
18
|
* resolve in labels.json, and a literal n= must agree with that index.
|
|
19
|
-
* 7. <BookLink book="…" to="…"> (#96) — both props present,
|
|
20
|
-
*
|
|
19
|
+
* 7. <BookLink book="…" to="…"> (#96/#147) — both props present, book= is
|
|
20
|
+
* registered, and literal fragment targets resolve in the sibling's
|
|
21
|
+
* declared vendored labels index. Dynamic and URL-only entries warn/skip.
|
|
21
22
|
* 8. Questions collection (#112) — each question's frontmatter `domain` is a
|
|
22
23
|
* member of the consumer's examDomains registry (best-effort), and question
|
|
23
24
|
* `id`s are unique (the cross-ref key for the appendix / flashcards).
|
|
@@ -41,6 +42,9 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
41
42
|
import { resolve, dirname, join } from 'node:path';
|
|
42
43
|
import { fileURLToPath } from 'node:url';
|
|
43
44
|
import { spawnSync } from 'node:child_process';
|
|
45
|
+
import { unified } from 'unified';
|
|
46
|
+
import remarkParse from 'remark-parse';
|
|
47
|
+
import remarkMdx from 'remark-mdx';
|
|
44
48
|
import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
|
|
45
49
|
import { readEnvFile } from './read-env.mjs';
|
|
46
50
|
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
@@ -256,6 +260,32 @@ async function loadJson(path) {
|
|
|
256
260
|
const refs = await loadJson(join(DATA_DIR, 'references.json'));
|
|
257
261
|
const labels = await loadJson(join(DATA_DIR, 'labels.json'));
|
|
258
262
|
|
|
263
|
+
// #147: eagerly load every labels index explicitly declared by the evaluated
|
|
264
|
+
// siblingBooks registry. Unlike this book's generated labels.json, sibling
|
|
265
|
+
// indexes are vendored inputs: never self-heal or silently replace them. A
|
|
266
|
+
// missing, unreadable, malformed, or non-object index is a configuration error
|
|
267
|
+
// even when no current chapter happens to reference that sibling.
|
|
268
|
+
const siblingLabelIndexes = new Map();
|
|
269
|
+
for (const [book, entry] of Object.entries(TOOLING_CONFIG.siblingBooks)) {
|
|
270
|
+
if (typeof entry === 'string' || entry.labels === undefined) continue;
|
|
271
|
+
const indexPath = resolve(ROOT, entry.labels);
|
|
272
|
+
try {
|
|
273
|
+
const index = JSON.parse(await readFile(indexPath, 'utf8'));
|
|
274
|
+
if (index === null || typeof index !== 'object' || Array.isArray(index)) {
|
|
275
|
+
throw new Error('top-level JSON value must be an object');
|
|
276
|
+
}
|
|
277
|
+
siblingLabelIndexes.set(book, { index, configuredPath: entry.labels });
|
|
278
|
+
} catch (error) {
|
|
279
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
280
|
+
fail(
|
|
281
|
+
'astro.config',
|
|
282
|
+
1,
|
|
283
|
+
`siblingBooks.${book}.labels (${JSON.stringify(entry.labels)}) is missing, unreadable, or invalid: ${detail}. ` +
|
|
284
|
+
'Vendor a readable sibling labels.json at that path or remove labels to opt out with a warning.',
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
259
289
|
// ===== Collect chapter files =====
|
|
260
290
|
// v3.7.1 (closes #52): walkMdx (in ./walk-mdx.mjs) is a recursive readdir
|
|
261
291
|
// walker that replaces the previous `glob` import from `node:fs/promises`.
|
|
@@ -282,9 +312,10 @@ const RE_MD_LINK = /\[(?:[^\]]*)\]\((\/[^)\s#]+)(?:#[^)]*)?\)/g;
|
|
|
282
312
|
// #121: a <Theorem> opening tag — capture its attributes to assert a
|
|
283
313
|
// resolvable kind= (or legacy type=) is present.
|
|
284
314
|
const RE_THEOREM = /<Theorem\b([^>]*)>/g;
|
|
285
|
-
// #96:
|
|
286
|
-
//
|
|
287
|
-
|
|
315
|
+
// #96/#147: BookLink attributes must be read structurally. An opening-tag
|
|
316
|
+
// regex stops at `>` inside a quoted value or expression and can mistake text
|
|
317
|
+
// inside another prop for a real book=/to= assignment.
|
|
318
|
+
const mdxParser = unified().use(remarkParse).use(remarkMdx);
|
|
288
319
|
|
|
289
320
|
async function fileExists(p) {
|
|
290
321
|
try {
|
|
@@ -322,29 +353,107 @@ function literalTheoremNumber(attrs) {
|
|
|
322
353
|
return null;
|
|
323
354
|
}
|
|
324
355
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
356
|
+
/** Yield MDX JSX elements with an exact component name, excluding examples in
|
|
357
|
+
* fenced/inline code and strings in expressions by construction. */
|
|
358
|
+
function* mdxElements(node, name) {
|
|
359
|
+
if (
|
|
360
|
+
(node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
|
|
361
|
+
node.name === name
|
|
362
|
+
) {
|
|
363
|
+
yield node;
|
|
364
|
+
}
|
|
365
|
+
if (!Array.isArray(node.children)) return;
|
|
366
|
+
for (const child of node.children) yield* mdxElements(child, name);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function expressionString(value) {
|
|
370
|
+
const program = value?.data?.estree;
|
|
371
|
+
if (program?.body?.length !== 1 || program.body[0].type !== 'ExpressionStatement') {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
const expression = program.body[0].expression;
|
|
375
|
+
if (expression.type === 'Literal' && typeof expression.value === 'string') {
|
|
376
|
+
return expression.value;
|
|
377
|
+
}
|
|
378
|
+
if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
|
|
379
|
+
return expression.quasis[0]?.value?.cooked ?? expression.quasis[0]?.value?.raw ?? '';
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Evaluate one JSX prop in source order. Later explicit attributes override an
|
|
386
|
+
* earlier spread; a later spread makes the value dynamic, exactly as JSX does.
|
|
387
|
+
* Literal ESTree values are already decoded, so entities and JS escapes cannot
|
|
388
|
+
* disguise the href that the component receives at runtime.
|
|
389
|
+
*/
|
|
390
|
+
function mdxStringProp(element, name) {
|
|
391
|
+
let result = { present: false, literal: false, value: null, source: 'absent' };
|
|
392
|
+
for (const attribute of element.attributes) {
|
|
393
|
+
if (attribute.type === 'mdxJsxExpressionAttribute') {
|
|
394
|
+
result = { present: true, literal: false, value: null, source: 'spread' };
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (attribute.type !== 'mdxJsxAttribute' || attribute.name !== name) continue;
|
|
398
|
+
if (typeof attribute.value === 'string') {
|
|
399
|
+
result = { present: true, literal: true, value: attribute.value, source: 'attribute' };
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const value = expressionString(attribute.value);
|
|
403
|
+
result = value === null
|
|
404
|
+
? { present: true, literal: false, value: null, source: 'expression' }
|
|
405
|
+
: { present: true, literal: true, value, source: 'attribute' };
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Canonical comparison shape for a sibling labels href. The generated index
|
|
412
|
+
* intentionally omits the deployment base, so only normalize route seams:
|
|
413
|
+
* leading slashes and the optional trailing slash immediately before `#`.
|
|
414
|
+
*/
|
|
415
|
+
function normalizeSiblingTarget(value) {
|
|
416
|
+
const hash = value.indexOf('#');
|
|
417
|
+
if (hash < 0 || hash === value.length - 1) return null;
|
|
418
|
+
const fragment = value.slice(hash + 1);
|
|
419
|
+
const path = value.slice(0, hash).trim().replace(/^\/+/, '').replace(/\/+$/, '');
|
|
420
|
+
return { fragment, href: `${path}#${fragment}` };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Heading keys in labels.json are deliberately opaque and path-qualified so
|
|
425
|
+
* `#summary` can exist in every chapter. Resolve sibling targets from href
|
|
426
|
+
* values, while remaining compatible with historical component-id keys.
|
|
427
|
+
*/
|
|
428
|
+
function siblingTargetCandidates(index, fragment) {
|
|
429
|
+
const candidates = [];
|
|
430
|
+
for (const [key, entry] of Object.entries(index)) {
|
|
431
|
+
if (entry === null || typeof entry !== 'object' || typeof entry.href !== 'string') continue;
|
|
432
|
+
const normalized = normalizeSiblingTarget(entry.href);
|
|
433
|
+
if (normalized?.fragment === fragment) {
|
|
434
|
+
candidates.push({ key, href: entry.href, normalized });
|
|
340
435
|
}
|
|
341
436
|
}
|
|
437
|
+
return candidates;
|
|
342
438
|
}
|
|
343
439
|
|
|
344
440
|
// ===== Run all checks on each chapter =====
|
|
345
441
|
for (const rel of chapterFiles) {
|
|
346
442
|
const abs = join(CHAPTERS_DIR, rel);
|
|
347
443
|
const content = await readFile(abs, 'utf8');
|
|
444
|
+
let bookLinks = [];
|
|
445
|
+
if (content.includes('<BookLink')) {
|
|
446
|
+
try {
|
|
447
|
+
bookLinks = [...mdxElements(mdxParser.parse(content), 'BookLink')];
|
|
448
|
+
} catch (error) {
|
|
449
|
+
const line = error?.position?.start?.line ?? error?.line ?? 1;
|
|
450
|
+
fail(
|
|
451
|
+
rel,
|
|
452
|
+
line,
|
|
453
|
+
`Cannot structurally validate <BookLink>: ${error?.reason ?? error?.message ?? error}`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
348
457
|
|
|
349
458
|
// 1. Cite keys (academic profile only — tools profile uses YAML manifest)
|
|
350
459
|
if (PROFILE === 'academic') {
|
|
@@ -443,21 +552,122 @@ for (const rel of chapterFiles) {
|
|
|
443
552
|
}
|
|
444
553
|
}
|
|
445
554
|
|
|
446
|
-
// 7. BookLink (#96): structural
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
555
|
+
// 7. BookLink (#96/#147): structural props + evaluated registry membership,
|
|
556
|
+
// then literal path/fragment validation against a declared vendored index.
|
|
557
|
+
// Dynamic values and URL-only compatibility entries are explicit warnings
|
|
558
|
+
// rather than guessed validations.
|
|
559
|
+
for (const element of bookLinks) {
|
|
560
|
+
const line = element.position?.start?.line ?? 1;
|
|
561
|
+
const bookAttr = mdxStringProp(element, 'book');
|
|
562
|
+
const toAttr = mdxStringProp(element, 'to');
|
|
563
|
+
|
|
564
|
+
if (bookAttr.source === 'spread' || toAttr.source === 'spread') {
|
|
565
|
+
warn(
|
|
566
|
+
rel,
|
|
567
|
+
line,
|
|
568
|
+
'<BookLink> validation skipped: a dynamic prop spread may supply or override book=/to=, so the target cannot be proven statically.',
|
|
569
|
+
);
|
|
452
570
|
continue;
|
|
453
571
|
}
|
|
454
|
-
if (
|
|
572
|
+
if (!bookAttr.present || !toAttr.present) {
|
|
573
|
+
fail(rel, line, `<BookLink> requires both book="…" and to="…".`);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (!bookAttr.literal) {
|
|
578
|
+
warn(
|
|
579
|
+
rel,
|
|
580
|
+
line,
|
|
581
|
+
'<BookLink> target validation skipped: dynamic book= expression cannot be evaluated statically.',
|
|
582
|
+
);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const book = bookAttr.value;
|
|
587
|
+
const registered = Object.prototype.hasOwnProperty.call(TOOLING_CONFIG.siblingBooks, book);
|
|
588
|
+
if (!registered) {
|
|
455
589
|
fail(
|
|
456
590
|
rel,
|
|
457
|
-
|
|
458
|
-
`<BookLink book="${
|
|
591
|
+
line,
|
|
592
|
+
`<BookLink book="${book}"> — not in evaluated defineBookConfig siblingBooks (${Object.keys(TOOLING_CONFIG.siblingBooks).join(', ') || 'none'}). Register it or fix the key.`,
|
|
593
|
+
);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const siblingEntry = TOOLING_CONFIG.siblingBooks[book];
|
|
597
|
+
|
|
598
|
+
if (!toAttr.literal) {
|
|
599
|
+
warn(
|
|
600
|
+
rel,
|
|
601
|
+
line,
|
|
602
|
+
`<BookLink book="${book}"> target validation skipped: dynamic to= expression cannot be evaluated statically.`,
|
|
603
|
+
);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const target = normalizeSiblingTarget(toAttr.value);
|
|
608
|
+
if (!target) {
|
|
609
|
+
warn(
|
|
610
|
+
rel,
|
|
611
|
+
line,
|
|
612
|
+
`<BookLink book="${book}" to="${toAttr.value}"> has no static #fragment; sibling labels validation skipped.`,
|
|
459
613
|
);
|
|
614
|
+
continue;
|
|
460
615
|
}
|
|
616
|
+
|
|
617
|
+
if (typeof siblingEntry === 'string' || siblingEntry.labels === undefined) {
|
|
618
|
+
warn(
|
|
619
|
+
rel,
|
|
620
|
+
line,
|
|
621
|
+
`<BookLink book="${book}" to="${toAttr.value}"> target validation skipped: ` +
|
|
622
|
+
'the siblingBooks entry is URL-only. Use { url, labels } to enable vendored-label validation.',
|
|
623
|
+
);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const loaded = siblingLabelIndexes.get(book);
|
|
628
|
+
if (!loaded) continue; // A config-level error was already recorded above.
|
|
629
|
+
|
|
630
|
+
const candidates = siblingTargetCandidates(loaded.index, target.fragment);
|
|
631
|
+
if (candidates.some((candidate) => candidate.normalized.href === target.href)) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (candidates.length === 0) {
|
|
636
|
+
// Preserve the actionable diagnostic for a malformed historical entry
|
|
637
|
+
// whose key is the literal fragment. Opaque heading keys are found by
|
|
638
|
+
// valid href values and never need to be decoded here.
|
|
639
|
+
const legacyEntry = loaded.index[target.fragment];
|
|
640
|
+
if (
|
|
641
|
+
Object.prototype.hasOwnProperty.call(loaded.index, target.fragment) &&
|
|
642
|
+
(legacyEntry === null ||
|
|
643
|
+
typeof legacyEntry !== 'object' ||
|
|
644
|
+
typeof legacyEntry.href !== 'string')
|
|
645
|
+
) {
|
|
646
|
+
fail(
|
|
647
|
+
rel,
|
|
648
|
+
line,
|
|
649
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — ${loaded.configuredPath} entry ` +
|
|
650
|
+
`"${target.fragment}" has no string href; re-vendor a valid sibling labels.json.`,
|
|
651
|
+
);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
fail(
|
|
655
|
+
rel,
|
|
656
|
+
line,
|
|
657
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — fragment "${target.fragment}" is not in ` +
|
|
658
|
+
`${loaded.configuredPath}. Vendor the current sibling labels.json, or fix the target id.`,
|
|
659
|
+
);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const indexedHrefs = candidates.map((candidate) => `"${candidate.href}"`).join(', ');
|
|
664
|
+
fail(
|
|
665
|
+
rel,
|
|
666
|
+
line,
|
|
667
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — path/fragment does not match ` +
|
|
668
|
+
`${loaded.configuredPath}, which indexes "${target.fragment}" at ${indexedHrefs}. ` +
|
|
669
|
+
'Fix to= or refresh the vendored index.',
|
|
670
|
+
);
|
|
461
671
|
}
|
|
462
672
|
|
|
463
673
|
// <Rationale appendix> in CHAPTER bodies (v4.21.0, #114): same missing-for=
|