@brandon_m_behring/book-scaffold-astro 4.29.0 → 4.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -1
- package/bin/book-scaffold.mjs +1 -1
- package/components/Figure.astro +7 -6
- package/package.json +5 -4
- package/recipes/03-asset-pipelines.md +12 -4
- package/recipes/09-validation.md +21 -2
- package/recipes/16-tikz-figures.md +13 -2
- package/recipes/24-figure-authoring-standard.md +241 -0
- package/recipes/README.md +1 -0
- package/scripts/authored-links.mjs +328 -0
- package/scripts/build-figures.mjs +13 -10
- package/scripts/render-notebooks.mjs +2 -1
- package/scripts/resolve-book-config.mjs +16 -2
- package/scripts/sync-figure-tokens.mjs +44 -0
- package/scripts/validate.mjs +89 -15
- package/src/lib/figure-palette.mjs +162 -0
- package/src/lib/figure.mjs +113 -37
- package/styles/tokens.css +73 -9
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural authored-link discovery for `book-scaffold validate` (#190).
|
|
3
|
+
*
|
|
4
|
+
* Markdown and MDX are parsed into their real syntax trees so code examples,
|
|
5
|
+
* comments, nested labels, JSX expressions, and literal decoding follow the
|
|
6
|
+
* same grammar as the rendered book. Raw HTML is parsed only from ranges that
|
|
7
|
+
* the Markdown AST identified as HTML. This module never rewrites content.
|
|
8
|
+
*/
|
|
9
|
+
import { parseFragment } from 'parse5';
|
|
10
|
+
import remarkMath from 'remark-math';
|
|
11
|
+
import remarkMdx from 'remark-mdx';
|
|
12
|
+
import remarkParse from 'remark-parse';
|
|
13
|
+
import { unified } from 'unified';
|
|
14
|
+
|
|
15
|
+
const ORIGIN = 'https://book-scaffold.invalid';
|
|
16
|
+
const processors = new Map();
|
|
17
|
+
|
|
18
|
+
/** Normalize the portion of Astro's `base` relevant to containment checks. */
|
|
19
|
+
export function normalizeAstroBase(base = '/') {
|
|
20
|
+
const value = typeof base === 'string' && base.length > 0 ? base : '/';
|
|
21
|
+
const withLeadingSlash = value.startsWith('/') ? value : `/${value}`;
|
|
22
|
+
const pathname = new URL(withLeadingSlash, ORIGIN).pathname;
|
|
23
|
+
return pathname.replace(/\/+$/, '') || '/';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a browser-rooted same-origin target. A single leading backslash is
|
|
28
|
+
* included because WHATWG HTTP URL parsing treats it as a root slash. True
|
|
29
|
+
* protocol-relative URLs and inputs that resolve onto another host are not
|
|
30
|
+
* internal authored targets.
|
|
31
|
+
*/
|
|
32
|
+
function rootTargetUrl(target) {
|
|
33
|
+
if (typeof target !== 'string') return null;
|
|
34
|
+
const value = target.trim();
|
|
35
|
+
if (!value.startsWith('/') && !value.startsWith('\\')) return null;
|
|
36
|
+
if (value.startsWith('//') || value.startsWith('\\\\')) return null;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(value, ORIGIN);
|
|
40
|
+
return url.origin === ORIGIN ? url : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Browser-normalized pathname for a same-origin root target, else null. */
|
|
47
|
+
export function rootTargetPathname(target) {
|
|
48
|
+
const url = rootTargetUrl(target);
|
|
49
|
+
return url ? url.pathname.replace(/\/+$/, '') || '/' : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True only for a same-origin root target outside `base`.
|
|
54
|
+
* Protocols, protocol-relative URLs, fragments, relative URLs, and targets
|
|
55
|
+
* already under the configured base all return false.
|
|
56
|
+
*/
|
|
57
|
+
export function rootTargetEscapesBase(target, base) {
|
|
58
|
+
const normalizedBase = normalizeAstroBase(base);
|
|
59
|
+
if (normalizedBase === '/') return false;
|
|
60
|
+
|
|
61
|
+
const pathname = rootTargetPathname(target);
|
|
62
|
+
if (pathname === null) return false;
|
|
63
|
+
return pathname !== normalizedBase && !pathname.startsWith(`${normalizedBase}/`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Return a browser-normalized, base-contained suggestion for a violating
|
|
68
|
+
* target. Normalizing before prefixing prevents `..` (including percent-
|
|
69
|
+
* encoded dot segments) from escaping the base a second time.
|
|
70
|
+
*/
|
|
71
|
+
export function suggestBaseContainedTarget(target, base) {
|
|
72
|
+
const normalizedBase = normalizeAstroBase(base);
|
|
73
|
+
const url = rootTargetUrl(target);
|
|
74
|
+
if (!url) return target;
|
|
75
|
+
const prefix = normalizedBase === '/' ? '' : normalizedBase;
|
|
76
|
+
return `${prefix}${url.pathname}${url.search}${url.hash}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function blankFrontmatter(content) {
|
|
80
|
+
return content.replace(
|
|
81
|
+
/^\uFEFF?---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/,
|
|
82
|
+
(match) => match.replace(/[^\r\n]/g, ' '),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function processorFor(format, math) {
|
|
87
|
+
const key = `${format}:${math ? 'math' : 'plain'}`;
|
|
88
|
+
if (!processors.has(key)) {
|
|
89
|
+
const processor = unified().use(remarkParse);
|
|
90
|
+
if (format === 'mdx') processor.use(remarkMdx);
|
|
91
|
+
// Math syntax is profile-dependent. Enabling it for every book would hide
|
|
92
|
+
// real links authored between dollar signs in non-math profiles.
|
|
93
|
+
if (math) processor.use(remarkMath);
|
|
94
|
+
processors.set(key, processor);
|
|
95
|
+
}
|
|
96
|
+
return processors.get(key);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function positionStart(node) {
|
|
100
|
+
return node?.position?.start?.offset ?? 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function attributeValueOffset(source, start, end) {
|
|
104
|
+
const attribute = source.slice(start, end);
|
|
105
|
+
const equals = attribute.indexOf('=');
|
|
106
|
+
if (equals < 0) return start;
|
|
107
|
+
|
|
108
|
+
let offset = equals + 1;
|
|
109
|
+
while (/\s/.test(attribute[offset] ?? '')) offset += 1;
|
|
110
|
+
if (attribute[offset] === '"' || attribute[offset] === "'") offset += 1;
|
|
111
|
+
return start + offset;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function markdownDestinationOffset(node, source) {
|
|
115
|
+
const start = positionStart(node);
|
|
116
|
+
const end = node?.position?.end?.offset ?? start;
|
|
117
|
+
const authored = source.slice(start, end);
|
|
118
|
+
const marker = node.type === 'definition' ? authored.lastIndexOf(']:') : authored.lastIndexOf('](');
|
|
119
|
+
if (marker < 0) return start;
|
|
120
|
+
|
|
121
|
+
let offset = marker + 2;
|
|
122
|
+
while (/\s/.test(authored[offset] ?? '')) offset += 1;
|
|
123
|
+
if (authored[offset] === '<') offset += 1;
|
|
124
|
+
return start + offset;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function literalMdxAttribute(attribute, source) {
|
|
128
|
+
if (typeof attribute.value === 'string') {
|
|
129
|
+
return {
|
|
130
|
+
target: attribute.value,
|
|
131
|
+
index: attributeValueOffset(
|
|
132
|
+
source,
|
|
133
|
+
positionStart(attribute),
|
|
134
|
+
attribute.position?.end?.offset ?? positionStart(attribute),
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (attribute.value?.type !== 'mdxJsxAttributeValueExpression') return null;
|
|
140
|
+
const program = attribute.value.data?.estree;
|
|
141
|
+
if (program?.body?.length !== 1 || program.body[0]?.type !== 'ExpressionStatement') return null;
|
|
142
|
+
const expression = program.body[0].expression;
|
|
143
|
+
|
|
144
|
+
if (expression?.type === 'Literal' && typeof expression.value === 'string') {
|
|
145
|
+
return { target: expression.value, index: expression.start ?? positionStart(attribute) };
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
expression?.type === 'TemplateLiteral' &&
|
|
149
|
+
expression.expressions?.length === 0 &&
|
|
150
|
+
expression.quasis?.length === 1 &&
|
|
151
|
+
typeof expression.quasis[0].value?.cooked === 'string'
|
|
152
|
+
) {
|
|
153
|
+
return {
|
|
154
|
+
target: expression.quasis[0].value.cooked,
|
|
155
|
+
index: expression.quasis[0].start ?? expression.start ?? positionStart(attribute),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve one JSX prop in source order. A later explicit literal overrides an
|
|
163
|
+
* earlier spread, while a later spread or dynamic expression makes the final
|
|
164
|
+
* runtime value unknowable. This mirrors JSX object-spread semantics.
|
|
165
|
+
*/
|
|
166
|
+
function literalMdxProp(element, name, source) {
|
|
167
|
+
let result = null;
|
|
168
|
+
for (const attribute of element.attributes ?? []) {
|
|
169
|
+
if (attribute.type === 'mdxJsxExpressionAttribute') {
|
|
170
|
+
result = null;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
attribute.type !== 'mdxJsxAttribute' ||
|
|
175
|
+
typeof attribute.name !== 'string' ||
|
|
176
|
+
attribute.name.toLowerCase() !== name
|
|
177
|
+
) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
result = literalMdxAttribute(attribute, source);
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function htmlSourceFromRanges(source, ranges) {
|
|
186
|
+
const chars = new Array(source.length);
|
|
187
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
188
|
+
chars[i] = source[i] === '\n' || source[i] === '\r' ? source[i] : ' ';
|
|
189
|
+
}
|
|
190
|
+
for (const [start, end] of ranges) {
|
|
191
|
+
for (let i = start; i < end; i += 1) chars[i] = source[i];
|
|
192
|
+
}
|
|
193
|
+
return chars.join('');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function collectParsedHtmlTargets(source, ranges, add) {
|
|
197
|
+
if (ranges.length === 0) return;
|
|
198
|
+
const htmlSource = htmlSourceFromRanges(source, ranges);
|
|
199
|
+
const tree = parseFragment(htmlSource, { sourceCodeLocationInfo: true });
|
|
200
|
+
|
|
201
|
+
const visit = (node, insideCode = false) => {
|
|
202
|
+
const tagName = typeof node.tagName === 'string' ? node.tagName.toLowerCase() : null;
|
|
203
|
+
const inCodeContainer = insideCode || tagName === 'pre' || tagName === 'code';
|
|
204
|
+
|
|
205
|
+
if (tagName && !inCodeContainer) {
|
|
206
|
+
for (const attribute of node.attrs ?? []) {
|
|
207
|
+
const name = attribute.name.toLowerCase();
|
|
208
|
+
if (name !== 'href' && name !== 'src') continue;
|
|
209
|
+
const location = node.sourceCodeLocation?.attrs?.[attribute.name];
|
|
210
|
+
const start = location?.startOffset ?? node.sourceCodeLocation?.startOffset ?? 0;
|
|
211
|
+
const end = location?.endOffset ?? start;
|
|
212
|
+
add(attribute.value, attributeValueOffset(source, start, end), `${name} attribute`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const child of node.childNodes ?? []) visit(child, inCodeContainer);
|
|
217
|
+
if (node.content) visit(node.content, inCodeContainer);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
visit(tree);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function formatFrom(options) {
|
|
224
|
+
const format = typeof options === 'string' ? options : options?.format ?? 'mdx';
|
|
225
|
+
if (format !== 'md' && format !== 'mdx') {
|
|
226
|
+
throw new TypeError(`authored link parser format must be "md" or "mdx" (got ${JSON.stringify(format)})`);
|
|
227
|
+
}
|
|
228
|
+
return format;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Return every statically knowable authored Markdown/HTML/MDX href/src target.
|
|
233
|
+
* Result indices use the original source's UTF-16 offsets and are intended for
|
|
234
|
+
* file/line diagnostics; decoded targets need not occur verbatim at that index.
|
|
235
|
+
*/
|
|
236
|
+
export function findAuthoredTargets(content, options = {}) {
|
|
237
|
+
if (typeof content !== 'string') throw new TypeError('authored link content must be a string');
|
|
238
|
+
const format = formatFrom(options);
|
|
239
|
+
const math = typeof options === 'object' && options?.math === true;
|
|
240
|
+
const source = blankFrontmatter(content);
|
|
241
|
+
const tree = processorFor(format, math).parse(source);
|
|
242
|
+
const targets = [];
|
|
243
|
+
const seen = new Set();
|
|
244
|
+
const rawHtmlRanges = [];
|
|
245
|
+
const referencedIdentifiers = new Set();
|
|
246
|
+
const definitions = new Map();
|
|
247
|
+
|
|
248
|
+
const add = (target, index, kind) => {
|
|
249
|
+
if (typeof target !== 'string') return;
|
|
250
|
+
const value = target.trim();
|
|
251
|
+
const key = `${index}:${kind}:${value}`;
|
|
252
|
+
if (seen.has(key)) return;
|
|
253
|
+
seen.add(key);
|
|
254
|
+
targets.push({ target: value, index, kind });
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const visit = (node) => {
|
|
258
|
+
if (!node || typeof node !== 'object') return;
|
|
259
|
+
|
|
260
|
+
if (node.type === 'link' || node.type === 'image') {
|
|
261
|
+
const kind = node.type === 'image' ? 'Markdown image destination' : 'Markdown link destination';
|
|
262
|
+
add(node.url, markdownDestinationOffset(node, source), kind);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (
|
|
266
|
+
(node.type === 'linkReference' || node.type === 'imageReference') &&
|
|
267
|
+
typeof node.identifier === 'string'
|
|
268
|
+
) {
|
|
269
|
+
referencedIdentifiers.add(node.identifier);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (node.type === 'definition' && typeof node.identifier === 'string') {
|
|
273
|
+
// CommonMark resolves duplicate labels to the first definition.
|
|
274
|
+
if (!definitions.has(node.identifier)) definitions.set(node.identifier, node);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (node.type === 'html') {
|
|
278
|
+
const start = positionStart(node);
|
|
279
|
+
rawHtmlRanges.push([start, node.position?.end?.offset ?? start]);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
|
|
284
|
+
const name = typeof node.name === 'string' ? node.name : '';
|
|
285
|
+
if (name === 'pre' || name === 'code') return;
|
|
286
|
+
for (const attributeName of ['href', 'src']) {
|
|
287
|
+
const literal = literalMdxProp(node, attributeName, source);
|
|
288
|
+
if (literal) add(literal.target, literal.index, `${attributeName} attribute`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Code nodes contain source text but no rendered links. MDX expressions and
|
|
293
|
+
// ESM nodes are also opaque unless they are a parsed JSX attribute above.
|
|
294
|
+
if (
|
|
295
|
+
node.type === 'code' ||
|
|
296
|
+
node.type === 'inlineCode' ||
|
|
297
|
+
node.type === 'mdxFlowExpression' ||
|
|
298
|
+
node.type === 'mdxTextExpression' ||
|
|
299
|
+
node.type === 'mdxjsEsm'
|
|
300
|
+
) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const child of node.children ?? []) visit(child);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
visit(tree);
|
|
308
|
+
for (const identifier of referencedIdentifiers) {
|
|
309
|
+
const definition = definitions.get(identifier);
|
|
310
|
+
if (definition) {
|
|
311
|
+
add(
|
|
312
|
+
definition.url,
|
|
313
|
+
markdownDestinationOffset(definition, source),
|
|
314
|
+
'Markdown reference destination',
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (format === 'md') collectParsedHtmlTargets(source, rawHtmlRanges, add);
|
|
319
|
+
return targets.sort((a, b) => a.index - b.index);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Find only literal targets that browser-resolve outside `base`. */
|
|
323
|
+
export function findEscapingAuthoredTargets(content, base, options = {}) {
|
|
324
|
+
if (normalizeAstroBase(base) === '/') return [];
|
|
325
|
+
return findAuthoredTargets(content, options).filter(({ target }) =>
|
|
326
|
+
rootTargetEscapesBase(target, base),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
@@ -25,21 +25,22 @@
|
|
|
25
25
|
*
|
|
26
26
|
* v4.11.0 (closes #84): each generated SVG gets a post-export rewrite
|
|
27
27
|
* (recolorSvg) that injects role="img" + a CSS-variable theming layer so one
|
|
28
|
-
* SVG serves light + dark.
|
|
29
|
-
* var(--
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* SVG serves light + dark. Known Warm–Tol semantic and Okabe–Ito categorical
|
|
29
|
+
* colors are mapped to var(--fig-*|--series-*, <original>); neutral paints map
|
|
30
|
+
* to var(--diagram-ink|paper|grid, <original>). Unknown saturated colors stay
|
|
31
|
+
* authored. A `%! no-theme` line in the source .tex opts a figure out.
|
|
32
32
|
* <Figure> inlines local SVGs so they track the in-page [data-theme] toggle.
|
|
33
33
|
*
|
|
34
34
|
* Idempotent: skips when the target SVG is newer than the source PDF (and the
|
|
35
35
|
* recolor itself is a no-op on an already-themed SVG, so re-runs after an
|
|
36
36
|
* upgrade safely theme pre-existing figures).
|
|
37
|
-
* Run
|
|
37
|
+
* Run explicitly with `npm run build:figures`; optional system tools keep this
|
|
38
|
+
* authoring pipeline out of the generated project's `prebuild` hook.
|
|
38
39
|
*
|
|
39
40
|
* Graceful skip: when pdftocairo / pdftoppm aren't on PATH (e.g. Cloudflare
|
|
40
41
|
* build container), the script warns and exits 0. Committed SVGs/PNGs under
|
|
41
|
-
* public/figures/ are served as-is. Local
|
|
42
|
-
* from PDFs
|
|
42
|
+
* public/figures/ are served as-is. Local authors with poppler-utils regenerate
|
|
43
|
+
* from PDFs when source figures change.
|
|
43
44
|
*/
|
|
44
45
|
import { readdir, stat, mkdir } from 'node:fs/promises';
|
|
45
46
|
import { existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -56,8 +57,10 @@ Figure pipeline. PDF -> SVG via pdftocairo (PNG fallback via pdftoppm at
|
|
|
56
57
|
Graceful-skip if pdftocairo / pdftoppm not on PATH.
|
|
57
58
|
|
|
58
59
|
Each SVG is rewritten to be accessible + dark-mode-aware: role="img" plus
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
exact Warm–Tol/Okabe–Ito palette mappings and neutral
|
|
61
|
+
var(--diagram-ink|paper|grid, orig) mappings. Use base color + separate opacity,
|
|
62
|
+
not a pre-blended tint. A "%! no-theme" line in the source .tex opts out.
|
|
63
|
+
<Figure> inlines local SVGs so they track the theme.
|
|
61
64
|
|
|
62
65
|
Env:
|
|
63
66
|
BOOK_FIGURES_PATH Override figures source (default: figures/).
|
|
@@ -202,7 +205,7 @@ const NO_THEME_RE = /^\s*%!\s*no-theme\b/m;
|
|
|
202
205
|
/**
|
|
203
206
|
* v4.11.0 (#84): apply the theming rewrite to a generated SVG in place.
|
|
204
207
|
* No-op (returns false) if the file is absent or recolorSvg leaves it unchanged
|
|
205
|
-
* (already themed / nothing
|
|
208
|
+
* (already themed / nothing themeable to remap). Idempotent.
|
|
206
209
|
*/
|
|
207
210
|
function themeIfSvg(svgPath, optOut) {
|
|
208
211
|
if (!existsSync(svgPath)) return false;
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* canonical; the notebook is a code companion.
|
|
17
17
|
*
|
|
18
18
|
* Idempotent: skips when the target HTML is newer than the source .ipynb.
|
|
19
|
-
* Run
|
|
19
|
+
* Run explicitly with `npm run build:notebooks`; optional system tools keep
|
|
20
|
+
* this authoring pipeline out of the generated project's `prebuild` hook.
|
|
20
21
|
*
|
|
21
22
|
* Style scoping: nbconvert's `basic` template emits HTML without nbviewer
|
|
22
23
|
* chrome, but with embedded <style> tags. To prevent CSS bleed, each
|
|
@@ -8,6 +8,7 @@ export const DEFAULT_TOOLING_CONFIG = Object.freeze({
|
|
|
8
8
|
siblingBooks: Object.freeze({}),
|
|
9
9
|
chapterRoute: '/chapters/:id/',
|
|
10
10
|
bookField: 'book',
|
|
11
|
+
base: '/',
|
|
11
12
|
integrationFound: false,
|
|
12
13
|
});
|
|
13
14
|
|
|
@@ -56,6 +57,17 @@ function resolveNonEmptyString(value, fallback, field, configPath) {
|
|
|
56
57
|
return value;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
function resolveBase(value, configPath) {
|
|
61
|
+
if (value == null || value === '') return '/';
|
|
62
|
+
if (typeof value !== 'string') {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`book-scaffold tooling: ${configPath} resolved invalid Astro base ` +
|
|
65
|
+
`${JSON.stringify(value)}; expected a string.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
function resolveSiblingBooks(value, configPath) {
|
|
60
72
|
if (value == null) return {};
|
|
61
73
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
@@ -135,15 +147,16 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
|
|
|
135
147
|
const integrations = Array.isArray(loaded.config?.integrations)
|
|
136
148
|
? loaded.config.integrations.flat(Infinity)
|
|
137
149
|
: [];
|
|
150
|
+
const base = resolveBase(loaded.config?.base, configPath);
|
|
138
151
|
const integration = integrations.find((candidate) => candidate?.name === 'book-scaffold-astro');
|
|
139
|
-
if (!integration) return { ...DEFAULT_TOOLING_CONFIG };
|
|
152
|
+
if (!integration) return { ...DEFAULT_TOOLING_CONFIG, base };
|
|
140
153
|
|
|
141
154
|
const metadata = integration.__bookScaffoldResolvedConfig;
|
|
142
155
|
if (!metadata) {
|
|
143
156
|
// A config can contain an older scaffold integration with no metadata.
|
|
144
157
|
// Preserve the historical numbering default rather than treating upgrade
|
|
145
158
|
// sequencing as a config error.
|
|
146
|
-
return { ...DEFAULT_TOOLING_CONFIG, integrationFound: true };
|
|
159
|
+
return { ...DEFAULT_TOOLING_CONFIG, base, integrationFound: true };
|
|
147
160
|
}
|
|
148
161
|
|
|
149
162
|
const numberStyle = metadata.numberStyle ?? 'shared';
|
|
@@ -165,6 +178,7 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
|
|
|
165
178
|
'bookField',
|
|
166
179
|
configPath,
|
|
167
180
|
),
|
|
181
|
+
base,
|
|
168
182
|
integrationFound: true,
|
|
169
183
|
};
|
|
170
184
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Regenerate or verify the manifest-owned block in styles/tokens.css. */
|
|
3
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
FIGURE_TOKEN_BLOCK_END,
|
|
7
|
+
FIGURE_TOKEN_BLOCK_START,
|
|
8
|
+
renderFigureTokenCssBlock,
|
|
9
|
+
} from '../src/lib/figure-palette.mjs';
|
|
10
|
+
|
|
11
|
+
const TOKENS_PATH = fileURLToPath(new URL('../styles/tokens.css', import.meta.url));
|
|
12
|
+
const args = new Set(process.argv.slice(2));
|
|
13
|
+
const write = args.has('--write');
|
|
14
|
+
if (args.size > 1 || (args.size === 1 && !write && !args.has('--check'))) {
|
|
15
|
+
console.error('Usage: node scripts/sync-figure-tokens.mjs [--check|--write]');
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const css = readFileSync(TOKENS_PATH, 'utf8');
|
|
20
|
+
const start = css.indexOf(FIGURE_TOKEN_BLOCK_START);
|
|
21
|
+
const endStart = css.indexOf(FIGURE_TOKEN_BLOCK_END, start);
|
|
22
|
+
if (start < 0 || endStart < 0) {
|
|
23
|
+
throw new Error('tokens.css is missing the generated figure-token block markers.');
|
|
24
|
+
}
|
|
25
|
+
const end = endStart + FIGURE_TOKEN_BLOCK_END.length;
|
|
26
|
+
const expected = renderFigureTokenCssBlock();
|
|
27
|
+
const actual = css.slice(start, end);
|
|
28
|
+
|
|
29
|
+
if (write) {
|
|
30
|
+
if (actual === expected) {
|
|
31
|
+
console.log('sync-figure-tokens: tokens.css already current');
|
|
32
|
+
} else {
|
|
33
|
+
writeFileSync(TOKENS_PATH, css.slice(0, start) + expected + css.slice(end), 'utf8');
|
|
34
|
+
console.log('sync-figure-tokens: updated styles/tokens.css');
|
|
35
|
+
}
|
|
36
|
+
} else if (actual !== expected) {
|
|
37
|
+
console.error(
|
|
38
|
+
'sync-figure-tokens: styles/tokens.css drifted from src/lib/figure-palette.mjs; ' +
|
|
39
|
+
'run npm run sync:figure-tokens --workspace package',
|
|
40
|
+
);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
} else {
|
|
43
|
+
console.log('sync-figure-tokens: tokens.css matches the palette manifest');
|
|
44
|
+
}
|
package/scripts/validate.mjs
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* 9. Learning-objective anchors (#130) — when a chapter declares frontmatter
|
|
26
26
|
* `los:` entries with `anchor:` slugs, the declared set and the prose's
|
|
27
27
|
* MDX anchor-comment marker set must agree in both directions.
|
|
28
|
+
* 10. Authored root-absolute href/src targets (#190) — under a non-root Astro
|
|
29
|
+
* base, literal Markdown, HTML, and JSX targets must stay inside that base.
|
|
28
30
|
*
|
|
29
31
|
* Run from the consumer's project root. Closes #8 (was resolving paths
|
|
30
32
|
* from the package's own directory inside node_modules — false negatives
|
|
@@ -35,25 +37,33 @@
|
|
|
35
37
|
* book-scaffold validate --preset academic
|
|
36
38
|
* BOOK_REPO_ROOT=/abs/path npx book-scaffold validate
|
|
37
39
|
*
|
|
38
|
-
* Exit code = total failure count (0 = pass, >=1 = errors).
|
|
40
|
+
* Exit code = total failure count capped at 255 (0 = pass, >=1 = errors).
|
|
39
41
|
*/
|
|
40
42
|
import { readFile, access } from 'node:fs/promises';
|
|
41
43
|
import { existsSync, readFileSync } from 'node:fs';
|
|
42
|
-
import { resolve, dirname, join } from 'node:path';
|
|
44
|
+
import { resolve, dirname, extname, join } from 'node:path';
|
|
43
45
|
import { fileURLToPath } from 'node:url';
|
|
44
46
|
import { spawnSync } from 'node:child_process';
|
|
45
47
|
import { unified } from 'unified';
|
|
46
48
|
import remarkParse from 'remark-parse';
|
|
49
|
+
import remarkMath from 'remark-math';
|
|
47
50
|
import remarkMdx from 'remark-mdx';
|
|
48
51
|
import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
|
|
49
52
|
import { readEnvFile } from './read-env.mjs';
|
|
50
53
|
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
54
|
+
import {
|
|
55
|
+
findAuthoredTargets,
|
|
56
|
+
normalizeAstroBase,
|
|
57
|
+
rootTargetEscapesBase,
|
|
58
|
+
rootTargetPathname,
|
|
59
|
+
suggestBaseContainedTarget,
|
|
60
|
+
} from './authored-links.mjs';
|
|
51
61
|
|
|
52
62
|
// --help / -h: non-mutating (closes #14).
|
|
53
63
|
const USAGE = `Usage: book-scaffold validate [--preset <name>]
|
|
54
64
|
|
|
55
65
|
Pre-flight content validator. Checks Cite keys, XRef ids, Figure srcs,
|
|
56
|
-
internal
|
|
66
|
+
internal authored links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
|
|
57
67
|
|
|
58
68
|
Options:
|
|
59
69
|
--preset <name> academic | tools | minimal | course-notes | research-portfolio
|
|
@@ -65,7 +75,7 @@ Env:
|
|
|
65
75
|
BOOK_PROFILE Backward-compat alias for BOOK_PRESET.
|
|
66
76
|
BOOK_REPO_ROOT Absolute path to a sibling code repo for CodeRef checks.
|
|
67
77
|
|
|
68
|
-
Exit code = total failure count.
|
|
78
|
+
Exit code = total failure count, capped at 255 so failures never wrap to success.
|
|
69
79
|
`;
|
|
70
80
|
|
|
71
81
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
@@ -143,6 +153,7 @@ if (!PRESET_CANDIDATE) {
|
|
|
143
153
|
}
|
|
144
154
|
// Alias kept for downstream message text only; the resolution above is canonical.
|
|
145
155
|
const PROFILE = PRESET;
|
|
156
|
+
const MATH_ENABLED = PROFILE === 'academic' || PROFILE === 'research-portfolio';
|
|
146
157
|
const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
|
|
147
158
|
|
|
148
159
|
// v4.6.0 (issue #76 Layer 3b): chapter-route shadow warning. Detect a
|
|
@@ -303,12 +314,11 @@ const validTopLevelRoutes = new Set([
|
|
|
303
314
|
'/', '/chapters/', '/references/', '/search/', '/print/', '/convergence/',
|
|
304
315
|
]);
|
|
305
316
|
|
|
306
|
-
// ===== Pattern helpers
|
|
317
|
+
// ===== Pattern helpers for component-specific checks =====
|
|
307
318
|
const RE_CITE = /<Cite[^>]+key=["']([^"']+)["']/g;
|
|
308
319
|
const RE_XREF = /<XRef[^>]+id=["']([^"']+)["']/g;
|
|
309
320
|
const RE_FIGURE = /<Figure[^>]+src=["']([^"']+)["']/g;
|
|
310
321
|
const RE_CODEREF = /<CodeRef[^>]+path=["']([^"']+)["'](?:[^>]*line=\{(\d+)\})?(?:[^>]*lineEnd=\{(\d+)\})?/g;
|
|
311
|
-
const RE_MD_LINK = /\[(?:[^\]]*)\]\((\/[^)\s#]+)(?:#[^)]*)?\)/g;
|
|
312
322
|
// #121: a <Theorem> opening tag — capture its attributes to assert a
|
|
313
323
|
// resolvable kind= (or legacy type=) is present.
|
|
314
324
|
const RE_THEOREM = /<Theorem\b([^>]*)>/g;
|
|
@@ -316,6 +326,7 @@ const RE_THEOREM = /<Theorem\b([^>]*)>/g;
|
|
|
316
326
|
// regex stops at `>` inside a quoted value or expression and can mistake text
|
|
317
327
|
// inside another prop for a real book=/to= assignment.
|
|
318
328
|
const mdxParser = unified().use(remarkParse).use(remarkMdx);
|
|
329
|
+
if (MATH_ENABLED) mdxParser.use(remarkMath);
|
|
319
330
|
|
|
320
331
|
async function fileExists(p) {
|
|
321
332
|
try {
|
|
@@ -330,6 +341,62 @@ function lineOf(content, idx) {
|
|
|
330
341
|
return content.slice(0, idx).split('\n').length;
|
|
331
342
|
}
|
|
332
343
|
|
|
344
|
+
const ASTRO_BASE = normalizeAstroBase(TOOLING_CONFIG.base);
|
|
345
|
+
|
|
346
|
+
function authoredFormat(file) {
|
|
347
|
+
return extname(file).toLowerCase() === '.md' ? 'md' : 'mdx';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function collectAuthoredTargets(file, content) {
|
|
351
|
+
try {
|
|
352
|
+
return findAuthoredTargets(content, {
|
|
353
|
+
format: authoredFormat(file),
|
|
354
|
+
math: MATH_ENABLED,
|
|
355
|
+
});
|
|
356
|
+
} catch (error) {
|
|
357
|
+
const line = error?.line ?? error?.place?.start?.line ?? error?.position?.start?.line ?? 1;
|
|
358
|
+
const detail = String(error?.reason ?? error?.message ?? error).split('\n')[0];
|
|
359
|
+
fail(file, line, `Could not parse authored links as ${authoredFormat(file).toUpperCase()}: ${detail}`);
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validateAuthoredTargets(file, content, targets) {
|
|
365
|
+
for (const violation of targets) {
|
|
366
|
+
if (!rootTargetEscapesBase(violation.target, ASTRO_BASE)) continue;
|
|
367
|
+
const suggested = suggestBaseContainedTarget(violation.target, ASTRO_BASE);
|
|
368
|
+
fail(
|
|
369
|
+
file,
|
|
370
|
+
lineOf(content, violation.index),
|
|
371
|
+
`Authored ${violation.kind} ${JSON.stringify(violation.target)} escapes configured Astro base ` +
|
|
372
|
+
`${JSON.stringify(ASTRO_BASE)}. Use ${JSON.stringify(suggested)}, ` +
|
|
373
|
+
'import.meta.env.BASE_URL in JSX, or a base-aware component. ' +
|
|
374
|
+
'Validation does not rewrite authored URLs (#190).',
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function validateInternalMarkdownLinks(file, content, targets) {
|
|
380
|
+
for (const authored of targets) {
|
|
381
|
+
if (authored.kind !== 'Markdown link destination') continue;
|
|
382
|
+
const pathname = rootTargetPathname(authored.target);
|
|
383
|
+
if (pathname === null) continue;
|
|
384
|
+
const baseRelativeTarget =
|
|
385
|
+
ASTRO_BASE !== '/' && (pathname === ASTRO_BASE || pathname.startsWith(`${ASTRO_BASE}/`))
|
|
386
|
+
? pathname.slice(ASTRO_BASE.length) || '/'
|
|
387
|
+
: pathname;
|
|
388
|
+
const target = baseRelativeTarget.replace(/\/$/, '') || '/';
|
|
389
|
+
if (validTopLevelRoutes.has(`${target}/`) || validTopLevelRoutes.has(target)) continue;
|
|
390
|
+
const chMatch = target.match(/^\/chapters\/(.+)$/);
|
|
391
|
+
if (chMatch && validSlugs.has(chMatch[1])) continue;
|
|
392
|
+
warn(
|
|
393
|
+
file,
|
|
394
|
+
lineOf(content, authored.index),
|
|
395
|
+
`Internal link ${JSON.stringify(authored.target)} — target may not resolve (check spelling or route)`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
333
400
|
/**
|
|
334
401
|
* Return a statically knowable n= value. Quoted strings and braced
|
|
335
402
|
* string/numeric literals are safe to compare; identifiers, calls,
|
|
@@ -441,6 +508,13 @@ function siblingTargetCandidates(index, fragment) {
|
|
|
441
508
|
for (const rel of chapterFiles) {
|
|
442
509
|
const abs = join(CHAPTERS_DIR, rel);
|
|
443
510
|
const content = await readFile(abs, 'utf8');
|
|
511
|
+
const authoredTargets = collectAuthoredTargets(rel, content);
|
|
512
|
+
|
|
513
|
+
// 10. Root-absolute authored targets under a non-root Astro base (#190).
|
|
514
|
+
// Structural parsing covers Markdown link/image destinations, HTML
|
|
515
|
+
// href/src, and decoded static JSX strings while excluding code examples.
|
|
516
|
+
validateAuthoredTargets(rel, content, authoredTargets);
|
|
517
|
+
|
|
444
518
|
let bookLinks = [];
|
|
445
519
|
if (content.includes('<BookLink')) {
|
|
446
520
|
try {
|
|
@@ -477,14 +551,9 @@ for (const rel of chapterFiles) {
|
|
|
477
551
|
}
|
|
478
552
|
}
|
|
479
553
|
|
|
480
|
-
// 4. Internal
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
if (validTopLevelRoutes.has(target + '/') || validTopLevelRoutes.has(target)) continue;
|
|
484
|
-
const chMatch = target.match(/^\/chapters\/(.+)$/);
|
|
485
|
-
if (chMatch && validSlugs.has(chMatch[1])) continue;
|
|
486
|
-
warn(rel, lineOf(content, m.index), `Internal link "${m[1]}" — target may not resolve (check spelling or route)`);
|
|
487
|
-
}
|
|
554
|
+
// 4. Internal Markdown links resolve. Reuse the structural authored-target
|
|
555
|
+
// traversal so code examples and comments cannot create false warnings.
|
|
556
|
+
validateInternalMarkdownLinks(rel, content, authoredTargets);
|
|
488
557
|
|
|
489
558
|
// 5. CodeRef path + line bounds (only when BOOK_REPO_ROOT set)
|
|
490
559
|
if (REPO_ROOT) {
|
|
@@ -773,6 +842,11 @@ let questionsChecked = 0;
|
|
|
773
842
|
const front = fm ? fm[1] : '';
|
|
774
843
|
const qrel = `questions/${rel}`;
|
|
775
844
|
|
|
845
|
+
// Questions do not run the legacy link-resolution advisory, so keep the
|
|
846
|
+
// #190 root-base contract fully inert by parsing only for a non-root base.
|
|
847
|
+
const authoredTargets = ASTRO_BASE === '/' ? [] : collectAuthoredTargets(qrel, content);
|
|
848
|
+
validateAuthoredTargets(qrel, content, authoredTargets);
|
|
849
|
+
|
|
776
850
|
const idMatch = front.match(/^id\s*:\s*["']?([^"'\n]+?)["']?\s*$/m);
|
|
777
851
|
if (idMatch) {
|
|
778
852
|
const id = idMatch[1].trim();
|
|
@@ -848,4 +922,4 @@ console.error(
|
|
|
848
922
|
`(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}):`,
|
|
849
923
|
);
|
|
850
924
|
errors.forEach((e) => console.error(format(e)));
|
|
851
|
-
process.exit(errors.length);
|
|
925
|
+
process.exit(Math.min(errors.length, 255));
|