@brandon_m_behring/book-scaffold-astro 3.0.0-alpha.5 → 3.0.0-alpha.7

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "3.0.0-alpha.5",
4
+ "version": "3.0.0-alpha.7",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * build-labels.mjs — emit src/data/labels.json for <XRef> resolution.
4
+ *
5
+ * Walks the consumer's `src/content/chapters/**\/*.mdx`, extracts each
6
+ * labelable component invocation (Theorem, Figure, Section, … — see
7
+ * `LABELABLE_TYPES` below), and assigns it a display string of the form
8
+ * `<Type> <chapter>.<n>` (e.g. `Theorem 4.2`), matching the LaTeX `\cref`
9
+ * convention. The resulting map is consumed by XRef.astro via
10
+ * `import.meta.glob('/src/data/labels.json', { eager: true })`.
11
+ *
12
+ * Per-chapter, per-type counter: each chapter resets the counter, so two
13
+ * chapters can both have `Theorem 1` without colliding. The chapter
14
+ * number comes from frontmatter:
15
+ * - tools profile: `chapter` field (number).
16
+ * - academic profile: `week` field (number).
17
+ *
18
+ * Slug used for the href = filename minus `.mdx`. The href shape mirrors
19
+ * the consumer's pages router: `/chapters/<slug>#<id>`. Academic books
20
+ * using `[...slug].astro` get the same shape since Astro slugifies
21
+ * filenames identically.
22
+ *
23
+ * Optional override:
24
+ * <Theorem id="…" label="Custom display" />
25
+ * → labels.json uses "Custom display" instead of the auto-counter.
26
+ *
27
+ * Usage:
28
+ * node scripts/build-labels.mjs
29
+ * book-scaffold build-labels
30
+ *
31
+ * Reads from cwd (the consumer's project root); writes
32
+ * `src/data/labels.json`. Creates `src/data/` if missing.
33
+ *
34
+ * Designed to run in <2 s on a medium book.
35
+ */
36
+ import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
37
+ import { resolve, join, basename, dirname } from 'node:path';
38
+
39
+ const CHAPTERS_DIR = process.env.BOOK_CHAPTERS_DIR ?? 'src/content/chapters';
40
+ const OUTPUT_PATH = process.env.BOOK_LABELS_OUT ?? 'src/data/labels.json';
41
+
42
+ /** Component names that participate in cross-referencing. */
43
+ const LABELABLE_TYPES = [
44
+ 'Theorem',
45
+ 'Figure',
46
+ 'ExampleBox',
47
+ 'ResultBox',
48
+ 'NoteBox',
49
+ 'CaseStudy',
50
+ ];
51
+
52
+ /** Display-name prefix used when no `label` override is given. */
53
+ const TYPE_DISPLAY = {
54
+ Theorem: 'Theorem',
55
+ Figure: 'Figure',
56
+ ExampleBox: 'Example',
57
+ ResultBox: 'Result',
58
+ NoteBox: 'Note',
59
+ CaseStudy: 'Case study',
60
+ };
61
+
62
+ // ===== Frontmatter parsing =====
63
+
64
+ function parseFrontmatter(source) {
65
+ // Standard MDX/YAML frontmatter: `---\n…\n---`.
66
+ const m = source.match(/^---\n([\s\S]*?)\n---/);
67
+ if (!m) return {};
68
+ const fm = {};
69
+ for (const line of m[1].split('\n')) {
70
+ const kv = line.match(/^(\w+)\s*:\s*(.+?)\s*$/);
71
+ if (!kv) continue;
72
+ const [, key, raw] = kv;
73
+ // Strip quotes; coerce numeric scalars.
74
+ let val = raw.replace(/^["']|["']$/g, '');
75
+ if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
76
+ fm[key] = val;
77
+ }
78
+ return fm;
79
+ }
80
+
81
+ function chapterNumberOf(frontmatter) {
82
+ // Tools profile uses `chapter`; academic uses `week`. Prefer chapter.
83
+ if (typeof frontmatter.chapter === 'number') return frontmatter.chapter;
84
+ if (typeof frontmatter.week === 'number') return frontmatter.week;
85
+ return null;
86
+ }
87
+
88
+ // ===== Component-invocation parsing =====
89
+
90
+ /**
91
+ * Match opening tags of any labelable component, capturing the attrs blob.
92
+ * Conservative regex: only matches `<ComponentName ... />` or
93
+ * `<ComponentName ...>` (not closing tags, not self-references in prose).
94
+ */
95
+ function buildTagRegex() {
96
+ const names = LABELABLE_TYPES.join('|');
97
+ return new RegExp(`<(${names})\\b([^>]*?)\\/?>`, 'g');
98
+ }
99
+
100
+ function extractAttr(attrsBlob, name) {
101
+ // `name="value"` or `name='value'` or `name={value}`.
102
+ const dq = attrsBlob.match(new RegExp(`${name}="([^"]*)"`));
103
+ if (dq) return dq[1];
104
+ const sq = attrsBlob.match(new RegExp(`${name}='([^']*)'`));
105
+ if (sq) return sq[1];
106
+ const ex = attrsBlob.match(new RegExp(`${name}=\\{([^}]*)\\}`));
107
+ if (ex) return ex[1].trim().replace(/^["'`]|["'`]$/g, '');
108
+ return null;
109
+ }
110
+
111
+ // ===== Filesystem walk =====
112
+
113
+ async function walkChapters(dir) {
114
+ const out = [];
115
+ let entries;
116
+ try {
117
+ entries = await readdir(dir, { withFileTypes: true });
118
+ } catch (err) {
119
+ if (err.code === 'ENOENT') return out;
120
+ throw err;
121
+ }
122
+ for (const e of entries) {
123
+ const path = join(dir, e.name);
124
+ if (e.isDirectory()) {
125
+ out.push(...(await walkChapters(path)));
126
+ continue;
127
+ }
128
+ if (!e.isFile()) continue;
129
+ if (!/\.mdx?$/.test(e.name)) continue;
130
+ if (e.name.startsWith('_')) continue; // hidden by convention
131
+ out.push(path);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ // ===== Main =====
137
+
138
+ async function main() {
139
+ const cwd = process.cwd();
140
+ const chaptersDir = resolve(cwd, CHAPTERS_DIR);
141
+ const files = await walkChapters(chaptersDir);
142
+
143
+ const labels = {};
144
+ const tagRegex = buildTagRegex();
145
+ let totalIds = 0;
146
+ let chaptersWithIds = 0;
147
+
148
+ for (const file of files) {
149
+ const source = await readFile(file, 'utf8');
150
+ const fm = parseFrontmatter(source);
151
+ const chapterNum = chapterNumberOf(fm);
152
+ const slug = basename(file).replace(/\.mdx?$/, '');
153
+
154
+ // Per-chapter counters reset for each file.
155
+ const counters = {};
156
+ let foundInChapter = 0;
157
+
158
+ for (const match of source.matchAll(tagRegex)) {
159
+ const [, type, attrs] = match;
160
+ const id = extractAttr(attrs, 'id');
161
+ if (!id) continue;
162
+
163
+ counters[type] = (counters[type] ?? 0) + 1;
164
+ foundInChapter += 1;
165
+ totalIds += 1;
166
+
167
+ const labelOverride = extractAttr(attrs, 'label');
168
+ const display =
169
+ labelOverride ??
170
+ (chapterNum != null
171
+ ? `${TYPE_DISPLAY[type]} ${chapterNum}.${counters[type]}`
172
+ : `${TYPE_DISPLAY[type]} ${counters[type]}`);
173
+
174
+ if (labels[id]) {
175
+ // Duplicate id — surface but don't fail; consumer's validator
176
+ // catches collisions with full diagnostic context.
177
+ process.stderr.write(
178
+ `build-labels: WARN duplicate id "${id}" (first in ` +
179
+ `${labels[id].href.split('#')[0]}, now in ${slug})\n`,
180
+ );
181
+ }
182
+ labels[id] = {
183
+ href: `/chapters/${slug}#${id}`,
184
+ display,
185
+ };
186
+ }
187
+
188
+ if (foundInChapter > 0) chaptersWithIds += 1;
189
+ }
190
+
191
+ // Emit deterministic output: keys sorted alphabetically.
192
+ const sorted = {};
193
+ for (const k of Object.keys(labels).sort()) sorted[k] = labels[k];
194
+
195
+ const outputPath = resolve(cwd, OUTPUT_PATH);
196
+ await mkdir(dirname(outputPath), { recursive: true });
197
+ await writeFile(outputPath, JSON.stringify(sorted, null, 2) + '\n', 'utf8');
198
+
199
+ process.stdout.write(
200
+ `build-labels: ${totalIds} id${totalIds === 1 ? '' : 's'} across ` +
201
+ `${chaptersWithIds} chapter${chaptersWithIds === 1 ? '' : 's'} → ` +
202
+ `${OUTPUT_PATH}\n`,
203
+ );
204
+ }
205
+
206
+ main().catch((err) => {
207
+ process.stderr.write(`build-labels: fatal: ${err?.message ?? err}\n`);
208
+ process.exit(1);
209
+ });