@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.
@@ -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 a `chapters` collection's `loader.base` string. Permissive
156
- // form: match the `chapters` identifier, then within the next 500
157
- // chars find `base: 'string'` or `base: "string"`. NOT template
158
- // literals (which use backticks and may contain ${} interpolation —
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 re = /\bchapters\b[\s\S]{0,500}?\bbase\s*:\s*'([^']+)'|\bchapters\b[\s\S]{0,500}?\bbase\s*:\s*"([^"]+)"/;
166
- const m = source.match(re);
167
- const captured = m && (m[1] || m[2]);
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
  }
@@ -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
- * Phase 1 (this): registry-backed href + fail-loud on unknown book. Phase 2
14
- * (deferred): validate the `to` id against a vendored sibling `labels.json`.
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: Record<string, string> | null | undefined,
25
+ siblingBooks: SiblingBooks | null | undefined,
18
26
  book: string,
19
27
  to: string,
20
28
  ): string {
21
- const base = siblingBooks?.[book];
22
- if (!base) {
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
  }