@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 +1 -1
- package/scripts/build-labels.mjs +209 -0
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.
|
|
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
|
+
});
|