@brandon_m_behring/book-scaffold-astro 4.28.0 → 4.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -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 +7 -4
- package/recipes/09-validation.md +51 -4
- package/recipes/22-responsive-nav-and-multibook-routing.md +5 -0
- package/scripts/authored-links.mjs +328 -0
- package/scripts/build-labels.mjs +88 -34
- package/scripts/resolve-book-config.mjs +90 -2
- package/scripts/validate.mjs +328 -44
- package/scripts/walk-mdx.mjs +122 -8
- package/src/lib/book-link.ts +25 -5
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural authored-link discovery for `book-scaffold validate` (#190).
|
|
3
|
+
*
|
|
4
|
+
* Markdown and MDX are parsed into their real syntax trees so code examples,
|
|
5
|
+
* comments, nested labels, JSX expressions, and literal decoding follow the
|
|
6
|
+
* same grammar as the rendered book. Raw HTML is parsed only from ranges that
|
|
7
|
+
* the Markdown AST identified as HTML. This module never rewrites content.
|
|
8
|
+
*/
|
|
9
|
+
import { parseFragment } from 'parse5';
|
|
10
|
+
import remarkMath from 'remark-math';
|
|
11
|
+
import remarkMdx from 'remark-mdx';
|
|
12
|
+
import remarkParse from 'remark-parse';
|
|
13
|
+
import { unified } from 'unified';
|
|
14
|
+
|
|
15
|
+
const ORIGIN = 'https://book-scaffold.invalid';
|
|
16
|
+
const processors = new Map();
|
|
17
|
+
|
|
18
|
+
/** Normalize the portion of Astro's `base` relevant to containment checks. */
|
|
19
|
+
export function normalizeAstroBase(base = '/') {
|
|
20
|
+
const value = typeof base === 'string' && base.length > 0 ? base : '/';
|
|
21
|
+
const withLeadingSlash = value.startsWith('/') ? value : `/${value}`;
|
|
22
|
+
const pathname = new URL(withLeadingSlash, ORIGIN).pathname;
|
|
23
|
+
return pathname.replace(/\/+$/, '') || '/';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a browser-rooted same-origin target. A single leading backslash is
|
|
28
|
+
* included because WHATWG HTTP URL parsing treats it as a root slash. True
|
|
29
|
+
* protocol-relative URLs and inputs that resolve onto another host are not
|
|
30
|
+
* internal authored targets.
|
|
31
|
+
*/
|
|
32
|
+
function rootTargetUrl(target) {
|
|
33
|
+
if (typeof target !== 'string') return null;
|
|
34
|
+
const value = target.trim();
|
|
35
|
+
if (!value.startsWith('/') && !value.startsWith('\\')) return null;
|
|
36
|
+
if (value.startsWith('//') || value.startsWith('\\\\')) return null;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(value, ORIGIN);
|
|
40
|
+
return url.origin === ORIGIN ? url : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Browser-normalized pathname for a same-origin root target, else null. */
|
|
47
|
+
export function rootTargetPathname(target) {
|
|
48
|
+
const url = rootTargetUrl(target);
|
|
49
|
+
return url ? url.pathname.replace(/\/+$/, '') || '/' : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True only for a same-origin root target outside `base`.
|
|
54
|
+
* Protocols, protocol-relative URLs, fragments, relative URLs, and targets
|
|
55
|
+
* already under the configured base all return false.
|
|
56
|
+
*/
|
|
57
|
+
export function rootTargetEscapesBase(target, base) {
|
|
58
|
+
const normalizedBase = normalizeAstroBase(base);
|
|
59
|
+
if (normalizedBase === '/') return false;
|
|
60
|
+
|
|
61
|
+
const pathname = rootTargetPathname(target);
|
|
62
|
+
if (pathname === null) return false;
|
|
63
|
+
return pathname !== normalizedBase && !pathname.startsWith(`${normalizedBase}/`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Return a browser-normalized, base-contained suggestion for a violating
|
|
68
|
+
* target. Normalizing before prefixing prevents `..` (including percent-
|
|
69
|
+
* encoded dot segments) from escaping the base a second time.
|
|
70
|
+
*/
|
|
71
|
+
export function suggestBaseContainedTarget(target, base) {
|
|
72
|
+
const normalizedBase = normalizeAstroBase(base);
|
|
73
|
+
const url = rootTargetUrl(target);
|
|
74
|
+
if (!url) return target;
|
|
75
|
+
const prefix = normalizedBase === '/' ? '' : normalizedBase;
|
|
76
|
+
return `${prefix}${url.pathname}${url.search}${url.hash}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function blankFrontmatter(content) {
|
|
80
|
+
return content.replace(
|
|
81
|
+
/^\uFEFF?---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/,
|
|
82
|
+
(match) => match.replace(/[^\r\n]/g, ' '),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function processorFor(format, math) {
|
|
87
|
+
const key = `${format}:${math ? 'math' : 'plain'}`;
|
|
88
|
+
if (!processors.has(key)) {
|
|
89
|
+
const processor = unified().use(remarkParse);
|
|
90
|
+
if (format === 'mdx') processor.use(remarkMdx);
|
|
91
|
+
// Math syntax is profile-dependent. Enabling it for every book would hide
|
|
92
|
+
// real links authored between dollar signs in non-math profiles.
|
|
93
|
+
if (math) processor.use(remarkMath);
|
|
94
|
+
processors.set(key, processor);
|
|
95
|
+
}
|
|
96
|
+
return processors.get(key);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function positionStart(node) {
|
|
100
|
+
return node?.position?.start?.offset ?? 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function attributeValueOffset(source, start, end) {
|
|
104
|
+
const attribute = source.slice(start, end);
|
|
105
|
+
const equals = attribute.indexOf('=');
|
|
106
|
+
if (equals < 0) return start;
|
|
107
|
+
|
|
108
|
+
let offset = equals + 1;
|
|
109
|
+
while (/\s/.test(attribute[offset] ?? '')) offset += 1;
|
|
110
|
+
if (attribute[offset] === '"' || attribute[offset] === "'") offset += 1;
|
|
111
|
+
return start + offset;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function markdownDestinationOffset(node, source) {
|
|
115
|
+
const start = positionStart(node);
|
|
116
|
+
const end = node?.position?.end?.offset ?? start;
|
|
117
|
+
const authored = source.slice(start, end);
|
|
118
|
+
const marker = node.type === 'definition' ? authored.lastIndexOf(']:') : authored.lastIndexOf('](');
|
|
119
|
+
if (marker < 0) return start;
|
|
120
|
+
|
|
121
|
+
let offset = marker + 2;
|
|
122
|
+
while (/\s/.test(authored[offset] ?? '')) offset += 1;
|
|
123
|
+
if (authored[offset] === '<') offset += 1;
|
|
124
|
+
return start + offset;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function literalMdxAttribute(attribute, source) {
|
|
128
|
+
if (typeof attribute.value === 'string') {
|
|
129
|
+
return {
|
|
130
|
+
target: attribute.value,
|
|
131
|
+
index: attributeValueOffset(
|
|
132
|
+
source,
|
|
133
|
+
positionStart(attribute),
|
|
134
|
+
attribute.position?.end?.offset ?? positionStart(attribute),
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (attribute.value?.type !== 'mdxJsxAttributeValueExpression') return null;
|
|
140
|
+
const program = attribute.value.data?.estree;
|
|
141
|
+
if (program?.body?.length !== 1 || program.body[0]?.type !== 'ExpressionStatement') return null;
|
|
142
|
+
const expression = program.body[0].expression;
|
|
143
|
+
|
|
144
|
+
if (expression?.type === 'Literal' && typeof expression.value === 'string') {
|
|
145
|
+
return { target: expression.value, index: expression.start ?? positionStart(attribute) };
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
expression?.type === 'TemplateLiteral' &&
|
|
149
|
+
expression.expressions?.length === 0 &&
|
|
150
|
+
expression.quasis?.length === 1 &&
|
|
151
|
+
typeof expression.quasis[0].value?.cooked === 'string'
|
|
152
|
+
) {
|
|
153
|
+
return {
|
|
154
|
+
target: expression.quasis[0].value.cooked,
|
|
155
|
+
index: expression.quasis[0].start ?? expression.start ?? positionStart(attribute),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve one JSX prop in source order. A later explicit literal overrides an
|
|
163
|
+
* earlier spread, while a later spread or dynamic expression makes the final
|
|
164
|
+
* runtime value unknowable. This mirrors JSX object-spread semantics.
|
|
165
|
+
*/
|
|
166
|
+
function literalMdxProp(element, name, source) {
|
|
167
|
+
let result = null;
|
|
168
|
+
for (const attribute of element.attributes ?? []) {
|
|
169
|
+
if (attribute.type === 'mdxJsxExpressionAttribute') {
|
|
170
|
+
result = null;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
attribute.type !== 'mdxJsxAttribute' ||
|
|
175
|
+
typeof attribute.name !== 'string' ||
|
|
176
|
+
attribute.name.toLowerCase() !== name
|
|
177
|
+
) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
result = literalMdxAttribute(attribute, source);
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function htmlSourceFromRanges(source, ranges) {
|
|
186
|
+
const chars = new Array(source.length);
|
|
187
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
188
|
+
chars[i] = source[i] === '\n' || source[i] === '\r' ? source[i] : ' ';
|
|
189
|
+
}
|
|
190
|
+
for (const [start, end] of ranges) {
|
|
191
|
+
for (let i = start; i < end; i += 1) chars[i] = source[i];
|
|
192
|
+
}
|
|
193
|
+
return chars.join('');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function collectParsedHtmlTargets(source, ranges, add) {
|
|
197
|
+
if (ranges.length === 0) return;
|
|
198
|
+
const htmlSource = htmlSourceFromRanges(source, ranges);
|
|
199
|
+
const tree = parseFragment(htmlSource, { sourceCodeLocationInfo: true });
|
|
200
|
+
|
|
201
|
+
const visit = (node, insideCode = false) => {
|
|
202
|
+
const tagName = typeof node.tagName === 'string' ? node.tagName.toLowerCase() : null;
|
|
203
|
+
const inCodeContainer = insideCode || tagName === 'pre' || tagName === 'code';
|
|
204
|
+
|
|
205
|
+
if (tagName && !inCodeContainer) {
|
|
206
|
+
for (const attribute of node.attrs ?? []) {
|
|
207
|
+
const name = attribute.name.toLowerCase();
|
|
208
|
+
if (name !== 'href' && name !== 'src') continue;
|
|
209
|
+
const location = node.sourceCodeLocation?.attrs?.[attribute.name];
|
|
210
|
+
const start = location?.startOffset ?? node.sourceCodeLocation?.startOffset ?? 0;
|
|
211
|
+
const end = location?.endOffset ?? start;
|
|
212
|
+
add(attribute.value, attributeValueOffset(source, start, end), `${name} attribute`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const child of node.childNodes ?? []) visit(child, inCodeContainer);
|
|
217
|
+
if (node.content) visit(node.content, inCodeContainer);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
visit(tree);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function formatFrom(options) {
|
|
224
|
+
const format = typeof options === 'string' ? options : options?.format ?? 'mdx';
|
|
225
|
+
if (format !== 'md' && format !== 'mdx') {
|
|
226
|
+
throw new TypeError(`authored link parser format must be "md" or "mdx" (got ${JSON.stringify(format)})`);
|
|
227
|
+
}
|
|
228
|
+
return format;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Return every statically knowable authored Markdown/HTML/MDX href/src target.
|
|
233
|
+
* Result indices use the original source's UTF-16 offsets and are intended for
|
|
234
|
+
* file/line diagnostics; decoded targets need not occur verbatim at that index.
|
|
235
|
+
*/
|
|
236
|
+
export function findAuthoredTargets(content, options = {}) {
|
|
237
|
+
if (typeof content !== 'string') throw new TypeError('authored link content must be a string');
|
|
238
|
+
const format = formatFrom(options);
|
|
239
|
+
const math = typeof options === 'object' && options?.math === true;
|
|
240
|
+
const source = blankFrontmatter(content);
|
|
241
|
+
const tree = processorFor(format, math).parse(source);
|
|
242
|
+
const targets = [];
|
|
243
|
+
const seen = new Set();
|
|
244
|
+
const rawHtmlRanges = [];
|
|
245
|
+
const referencedIdentifiers = new Set();
|
|
246
|
+
const definitions = new Map();
|
|
247
|
+
|
|
248
|
+
const add = (target, index, kind) => {
|
|
249
|
+
if (typeof target !== 'string') return;
|
|
250
|
+
const value = target.trim();
|
|
251
|
+
const key = `${index}:${kind}:${value}`;
|
|
252
|
+
if (seen.has(key)) return;
|
|
253
|
+
seen.add(key);
|
|
254
|
+
targets.push({ target: value, index, kind });
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const visit = (node) => {
|
|
258
|
+
if (!node || typeof node !== 'object') return;
|
|
259
|
+
|
|
260
|
+
if (node.type === 'link' || node.type === 'image') {
|
|
261
|
+
const kind = node.type === 'image' ? 'Markdown image destination' : 'Markdown link destination';
|
|
262
|
+
add(node.url, markdownDestinationOffset(node, source), kind);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (
|
|
266
|
+
(node.type === 'linkReference' || node.type === 'imageReference') &&
|
|
267
|
+
typeof node.identifier === 'string'
|
|
268
|
+
) {
|
|
269
|
+
referencedIdentifiers.add(node.identifier);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (node.type === 'definition' && typeof node.identifier === 'string') {
|
|
273
|
+
// CommonMark resolves duplicate labels to the first definition.
|
|
274
|
+
if (!definitions.has(node.identifier)) definitions.set(node.identifier, node);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (node.type === 'html') {
|
|
278
|
+
const start = positionStart(node);
|
|
279
|
+
rawHtmlRanges.push([start, node.position?.end?.offset ?? start]);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
|
|
284
|
+
const name = typeof node.name === 'string' ? node.name : '';
|
|
285
|
+
if (name === 'pre' || name === 'code') return;
|
|
286
|
+
for (const attributeName of ['href', 'src']) {
|
|
287
|
+
const literal = literalMdxProp(node, attributeName, source);
|
|
288
|
+
if (literal) add(literal.target, literal.index, `${attributeName} attribute`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Code nodes contain source text but no rendered links. MDX expressions and
|
|
293
|
+
// ESM nodes are also opaque unless they are a parsed JSX attribute above.
|
|
294
|
+
if (
|
|
295
|
+
node.type === 'code' ||
|
|
296
|
+
node.type === 'inlineCode' ||
|
|
297
|
+
node.type === 'mdxFlowExpression' ||
|
|
298
|
+
node.type === 'mdxTextExpression' ||
|
|
299
|
+
node.type === 'mdxjsEsm'
|
|
300
|
+
) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const child of node.children ?? []) visit(child);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
visit(tree);
|
|
308
|
+
for (const identifier of referencedIdentifiers) {
|
|
309
|
+
const definition = definitions.get(identifier);
|
|
310
|
+
if (definition) {
|
|
311
|
+
add(
|
|
312
|
+
definition.url,
|
|
313
|
+
markdownDestinationOffset(definition, source),
|
|
314
|
+
'Markdown reference destination',
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (format === 'md') collectParsedHtmlTargets(source, rawHtmlRanges, add);
|
|
319
|
+
return targets.sort((a, b) => a.index - b.index);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Find only literal targets that browser-resolve outside `base`. */
|
|
323
|
+
export function findEscapingAuthoredTargets(content, base, options = {}) {
|
|
324
|
+
if (normalizeAstroBase(base) === '/') return [];
|
|
325
|
+
return findAuthoredTargets(content, options).filter(({ target }) =>
|
|
326
|
+
rootTargetEscapesBase(target, base),
|
|
327
|
+
);
|
|
328
|
+
}
|
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,10 @@ 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',
|
|
11
|
+
base: '/',
|
|
8
12
|
integrationFound: false,
|
|
9
13
|
});
|
|
10
14
|
|
|
@@ -42,6 +46,75 @@ function assertPreset(value, configPath) {
|
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
function resolveNonEmptyString(value, fallback, field, configPath) {
|
|
50
|
+
if (value == null) return fallback;
|
|
51
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`book-scaffold tooling: ${configPath} resolved invalid ${field} ` +
|
|
54
|
+
`${JSON.stringify(value)}; expected a non-empty string.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveBase(value, configPath) {
|
|
61
|
+
if (value == null || value === '') return '/';
|
|
62
|
+
if (typeof value !== 'string') {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`book-scaffold tooling: ${configPath} resolved invalid Astro base ` +
|
|
65
|
+
`${JSON.stringify(value)}; expected a string.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveSiblingBooks(value, configPath) {
|
|
72
|
+
if (value == null) return {};
|
|
73
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`book-scaffold tooling: ${configPath} resolved invalid siblingBooks ` +
|
|
76
|
+
`${JSON.stringify(value)}; expected an object registry.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const resolved = [];
|
|
81
|
+
for (const [book, entry] of Object.entries(value)) {
|
|
82
|
+
if (typeof entry === 'string') {
|
|
83
|
+
if (entry.length === 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`book-scaffold tooling: ${configPath} resolved invalid siblingBooks.${book}; ` +
|
|
86
|
+
'URL strings must not be empty.',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
resolved.push([book, entry]);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (
|
|
94
|
+
entry === null ||
|
|
95
|
+
typeof entry !== 'object' ||
|
|
96
|
+
Array.isArray(entry) ||
|
|
97
|
+
typeof entry.url !== 'string' ||
|
|
98
|
+
entry.url.length === 0 ||
|
|
99
|
+
(entry.labels !== undefined &&
|
|
100
|
+
(typeof entry.labels !== 'string' || entry.labels.length === 0))
|
|
101
|
+
) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`book-scaffold tooling: ${configPath} resolved invalid siblingBooks.${book}; ` +
|
|
104
|
+
'expected a URL string or { url: string, labels?: string }.',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
resolved.push([
|
|
108
|
+
book,
|
|
109
|
+
{
|
|
110
|
+
url: entry.url,
|
|
111
|
+
...(entry.labels === undefined ? {} : { labels: entry.labels }),
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
return Object.fromEntries(resolved);
|
|
116
|
+
}
|
|
117
|
+
|
|
45
118
|
/**
|
|
46
119
|
* Evaluate the consumer's actual Astro config and read the scaffold
|
|
47
120
|
* integration's internal resolved metadata. Absence of a config/integration is
|
|
@@ -74,15 +147,16 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
|
|
|
74
147
|
const integrations = Array.isArray(loaded.config?.integrations)
|
|
75
148
|
? loaded.config.integrations.flat(Infinity)
|
|
76
149
|
: [];
|
|
150
|
+
const base = resolveBase(loaded.config?.base, configPath);
|
|
77
151
|
const integration = integrations.find((candidate) => candidate?.name === 'book-scaffold-astro');
|
|
78
|
-
if (!integration) return { ...DEFAULT_TOOLING_CONFIG };
|
|
152
|
+
if (!integration) return { ...DEFAULT_TOOLING_CONFIG, base };
|
|
79
153
|
|
|
80
154
|
const metadata = integration.__bookScaffoldResolvedConfig;
|
|
81
155
|
if (!metadata) {
|
|
82
156
|
// A config can contain an older scaffold integration with no metadata.
|
|
83
157
|
// Preserve the historical numbering default rather than treating upgrade
|
|
84
158
|
// sequencing as a config error.
|
|
85
|
-
return { ...DEFAULT_TOOLING_CONFIG, integrationFound: true };
|
|
159
|
+
return { ...DEFAULT_TOOLING_CONFIG, base, integrationFound: true };
|
|
86
160
|
}
|
|
87
161
|
|
|
88
162
|
const numberStyle = metadata.numberStyle ?? 'shared';
|
|
@@ -91,6 +165,20 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
|
|
|
91
165
|
return {
|
|
92
166
|
preset: metadata.preset ?? null,
|
|
93
167
|
numberStyle,
|
|
168
|
+
siblingBooks: resolveSiblingBooks(metadata.siblingBooks, configPath),
|
|
169
|
+
chapterRoute: resolveNonEmptyString(
|
|
170
|
+
metadata.chapterRoute,
|
|
171
|
+
DEFAULT_TOOLING_CONFIG.chapterRoute,
|
|
172
|
+
'chapterRoute',
|
|
173
|
+
configPath,
|
|
174
|
+
),
|
|
175
|
+
bookField: resolveNonEmptyString(
|
|
176
|
+
metadata.bookField,
|
|
177
|
+
DEFAULT_TOOLING_CONFIG.bookField,
|
|
178
|
+
'bookField',
|
|
179
|
+
configPath,
|
|
180
|
+
),
|
|
181
|
+
base,
|
|
94
182
|
integrationFound: true,
|
|
95
183
|
};
|
|
96
184
|
}
|