@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/walk-mdx.mjs
CHANGED
|
@@ -16,6 +16,111 @@ import { readFile, readdir } from 'node:fs/promises';
|
|
|
16
16
|
import { existsSync } from 'node:fs';
|
|
17
17
|
import { join, relative, resolve } from 'node:path';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Mask JS/TS comments and string/template contents with spaces while
|
|
21
|
+
* preserving offsets and newlines. Collection detection runs against this
|
|
22
|
+
* mask so prose such as "default chapters" and code examples in comments
|
|
23
|
+
* cannot bind to a later, unrelated collection's `base:` property.
|
|
24
|
+
*/
|
|
25
|
+
function codeMask(source) {
|
|
26
|
+
// split('') preserves UTF-16 code-unit offsets used by RegExp#index even
|
|
27
|
+
// when a preceding comment contains an emoji/non-BMP character.
|
|
28
|
+
const out = source.split('');
|
|
29
|
+
let state = 'code';
|
|
30
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
31
|
+
const char = source[i];
|
|
32
|
+
const next = source[i + 1];
|
|
33
|
+
|
|
34
|
+
if (state === 'line-comment') {
|
|
35
|
+
if (char === '\n') state = 'code';
|
|
36
|
+
else out[i] = ' ';
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (state === 'block-comment') {
|
|
40
|
+
if (char === '*' && next === '/') {
|
|
41
|
+
out[i] = ' ';
|
|
42
|
+
out[i + 1] = ' ';
|
|
43
|
+
i += 1;
|
|
44
|
+
state = 'code';
|
|
45
|
+
} else if (char !== '\n') {
|
|
46
|
+
out[i] = ' ';
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (state === 'single' || state === 'double' || state === 'template') {
|
|
51
|
+
const closing = state === 'single' ? "'" : state === 'double' ? '"' : '`';
|
|
52
|
+
if (char === '\\') {
|
|
53
|
+
out[i] = ' ';
|
|
54
|
+
if (i + 1 < source.length) {
|
|
55
|
+
if (source[i + 1] !== '\n') out[i + 1] = ' ';
|
|
56
|
+
i += 1;
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
if (char === closing) state = 'code';
|
|
60
|
+
if (char !== '\n') out[i] = ' ';
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (char === '/' && next === '/') {
|
|
66
|
+
out[i] = ' ';
|
|
67
|
+
out[i + 1] = ' ';
|
|
68
|
+
i += 1;
|
|
69
|
+
state = 'line-comment';
|
|
70
|
+
} else if (char === '/' && next === '*') {
|
|
71
|
+
out[i] = ' ';
|
|
72
|
+
out[i + 1] = ' ';
|
|
73
|
+
i += 1;
|
|
74
|
+
state = 'block-comment';
|
|
75
|
+
} else if (char === "'") {
|
|
76
|
+
out[i] = ' ';
|
|
77
|
+
state = 'single';
|
|
78
|
+
} else if (char === '"') {
|
|
79
|
+
out[i] = ' ';
|
|
80
|
+
state = 'double';
|
|
81
|
+
} else if (char === '`') {
|
|
82
|
+
out[i] = ' ';
|
|
83
|
+
state = 'template';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out.join('');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Find an actual `chapters = defineCollection(...)` call and return its body
|
|
90
|
+
* plus an offset-preserving code mask. Supports declaration and object-property
|
|
91
|
+
* forms without relying on a distance from any arbitrary `chapters` word. */
|
|
92
|
+
function findChaptersCollectionCall(source) {
|
|
93
|
+
const mask = codeMask(source);
|
|
94
|
+
const starts = [
|
|
95
|
+
/\b(?:const|let|var)\s+chapters\s*=\s*defineCollection\s*\(/g,
|
|
96
|
+
/(?:^|[,{])\s*chapters\s*:\s*defineCollection\s*\(/gm,
|
|
97
|
+
];
|
|
98
|
+
const matches = [];
|
|
99
|
+
for (const pattern of starts) {
|
|
100
|
+
for (const match of mask.matchAll(pattern)) {
|
|
101
|
+
matches.push({ index: match.index, open: match.index + match[0].lastIndexOf('(') });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
matches.sort((a, b) => a.index - b.index);
|
|
105
|
+
|
|
106
|
+
for (const match of matches) {
|
|
107
|
+
let depth = 0;
|
|
108
|
+
for (let i = match.open; i < mask.length; i += 1) {
|
|
109
|
+
if (mask[i] === '(') depth += 1;
|
|
110
|
+
else if (mask[i] === ')') {
|
|
111
|
+
depth -= 1;
|
|
112
|
+
if (depth === 0) {
|
|
113
|
+
return {
|
|
114
|
+
body: source.slice(match.open + 1, i),
|
|
115
|
+
mask: mask.slice(match.open + 1, i),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
19
124
|
export async function* walkMdx(dir, baseDir = dir) {
|
|
20
125
|
let entries;
|
|
21
126
|
try {
|
|
@@ -152,19 +257,28 @@ export async function readChaptersBase(projectRoot) {
|
|
|
152
257
|
} catch {
|
|
153
258
|
return DEFAULT_BASE;
|
|
154
259
|
}
|
|
155
|
-
// Look for
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// those fall back to the default since the value is dynamic).
|
|
260
|
+
// Look for an actual `chapters` collection's `loader.base` string. The
|
|
261
|
+
// older "chapters word, then any base within 500 chars" regex could start
|
|
262
|
+
// in a comment or `...scaffold.collections` and steal the following
|
|
263
|
+
// supplements collection's base (#147 handbook dogfood).
|
|
160
264
|
//
|
|
161
265
|
// Forms matched:
|
|
162
266
|
// - `const chapters = defineCollection({ loader: glob({ base: './foo' }) })`
|
|
163
267
|
// - `export const collections = { chapters: defineCollection({ loader: glob({ base: './foo' }) }) }`
|
|
164
268
|
// - any indentation / line break style
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
269
|
+
const collectionCall = findChaptersCollectionCall(source);
|
|
270
|
+
let captured = null;
|
|
271
|
+
if (collectionCall) {
|
|
272
|
+
const loader = /\bloader\s*:/.exec(collectionCall.mask);
|
|
273
|
+
const base = loader
|
|
274
|
+
? /\bbase\s*:/.exec(collectionCall.mask.slice(loader.index + loader[0].length))
|
|
275
|
+
: null;
|
|
276
|
+
if (base) {
|
|
277
|
+
const valueOffset = loader.index + loader[0].length + base.index + base[0].length;
|
|
278
|
+
const literal = collectionCall.body.slice(valueOffset).match(/^\s*(?:'([^']+)'|"([^"]+)")/);
|
|
279
|
+
captured = literal && (literal[1] || literal[2]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
168
282
|
if (captured) {
|
|
169
283
|
return resolve(projectRoot, captured);
|
|
170
284
|
}
|
package/src/lib/book-link.ts
CHANGED
|
@@ -10,16 +10,27 @@
|
|
|
10
10
|
* sibling redeploys or extracts to its own repo. An unknown `book` THROWS
|
|
11
11
|
* rather than emitting a dead cross-origin link (fail-loud, like #109).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* #147 extends each registry entry from a URL string to the backward-compatible
|
|
14
|
+
* `{ url, labels? }` descriptor. Runtime href resolution uses `url`; the
|
|
15
|
+
* validator uses `labels` to check literal sibling path/fragment targets.
|
|
15
16
|
*/
|
|
17
|
+
import type { SiblingBookEntry, SiblingBooks } from '../types.js';
|
|
18
|
+
|
|
19
|
+
function entryUrl(entry: SiblingBookEntry | undefined): string | undefined {
|
|
20
|
+
if (typeof entry === 'string') return entry;
|
|
21
|
+
return entry?.url;
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export function resolveBookHref(
|
|
17
|
-
siblingBooks:
|
|
25
|
+
siblingBooks: SiblingBooks | null | undefined,
|
|
18
26
|
book: string,
|
|
19
27
|
to: string,
|
|
20
28
|
): string {
|
|
21
|
-
const
|
|
22
|
-
|
|
29
|
+
const registered =
|
|
30
|
+
siblingBooks !== null &&
|
|
31
|
+
siblingBooks !== undefined &&
|
|
32
|
+
Object.prototype.hasOwnProperty.call(siblingBooks, book);
|
|
33
|
+
if (!registered) {
|
|
23
34
|
const known = siblingBooks ? Object.keys(siblingBooks) : [];
|
|
24
35
|
throw new Error(
|
|
25
36
|
`<BookLink book="${book}">: unknown sibling book. Register it in ` +
|
|
@@ -28,5 +39,14 @@ export function resolveBookHref(
|
|
|
28
39
|
'.',
|
|
29
40
|
);
|
|
30
41
|
}
|
|
42
|
+
|
|
43
|
+
const entry = siblingBooks![book];
|
|
44
|
+
const base = entryUrl(entry);
|
|
45
|
+
if (typeof base !== 'string' || base.length === 0) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`<BookLink book="${book}">: invalid siblingBooks entry. Expected a URL ` +
|
|
48
|
+
'string or { url: "https://…", labels?: "./path/to/labels.json" }.',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
31
51
|
return `${base.replace(/\/+$/, '')}/${to.replace(/^\/+/, '')}`;
|
|
32
52
|
}
|