@brandon_m_behring/book-scaffold-astro 3.0.0-alpha.4 → 3.0.0-alpha.6

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.
@@ -1,13 +1,16 @@
1
1
  ---
2
2
  /**
3
3
  * ChapterHeader — renders the metadata block at the top of every chapter.
4
- * Surfaces provenance signals the LaTeX book conveyed implicitly:
5
- * - Part + chapter number (position in the book)
6
- * - Volatility class (freshness calibration for the reader)
7
- * - Tools compared (scope signal)
8
- * - Last verified date (how stale are the claims?)
9
4
  *
10
- * Driven entirely from the chapter's frontmatter (see content.config.ts).
5
+ * Schema-agnostic: the package supports two profile schemas (academic and
6
+ * tools), so this component renders only the fields that are present on
7
+ * the chapter data. Tools-profile metadata (volatility, last_verified,
8
+ * tools_compared) appears when defined; academic-profile metadata (week,
9
+ * status) appears in its place.
10
+ *
11
+ * Use a chapter card's metadata to calibrate how much trust to place in
12
+ * its specific claims — stable principles age slowly; feature surfaces
13
+ * age fast.
11
14
  */
12
15
  import type { CollectionEntry } from 'astro:content';
13
16
  import { getFreshness, freshnessLabel } from '../src/lib/freshness';
@@ -17,45 +20,97 @@ interface Props {
17
20
  }
18
21
  const { data } = Astro.props;
19
22
 
20
- function formatDate(d: Date): string {
21
- return d.toISOString().slice(0, 10);
23
+ // Widen for cross-schema field probing. The consumer's content.config.ts
24
+ // resolves `data` to exactly one of the two schemas at build time; this
25
+ // runtime probe handles either without crashing.
26
+ const d = data as Record<string, unknown>;
27
+
28
+ const hasToolsMeta =
29
+ typeof d.volatility === 'string' &&
30
+ d.last_verified instanceof Date &&
31
+ Array.isArray(d.tools_compared);
32
+
33
+ const hasAcademicMeta =
34
+ typeof d.week === 'number' && typeof d.status === 'string';
35
+
36
+ function formatDate(date: unknown): string {
37
+ return date instanceof Date ? date.toISOString().slice(0, 10) : '';
22
38
  }
23
39
 
24
- const freshness = getFreshness(data.last_verified, data.volatility);
25
- const freshnessText =
26
- freshness.status === 'fresh'
40
+ // Tools-profile freshness only when all inputs are present + typed.
41
+ const freshness =
42
+ hasToolsMeta && d.last_verified instanceof Date && typeof d.volatility === 'string'
43
+ ? getFreshness(d.last_verified, d.volatility as Parameters<typeof getFreshness>[1])
44
+ : null;
45
+
46
+ const freshnessText = freshness
47
+ ? freshness.status === 'fresh'
27
48
  ? 'Fresh'
28
49
  : freshness.status === 'verify-soon'
29
50
  ? 'Verify soon'
30
- : 'Stale';
51
+ : 'Stale'
52
+ : null;
53
+
54
+ // Display strings, profile-tagged for clarity in markup.
55
+ const partLabel = (() => {
56
+ const p = d.part;
57
+ if (typeof p === 'number') return `Part ${p}`;
58
+ if (typeof p === 'string' && p.length > 0) return `Part: ${p}`;
59
+ return null;
60
+ })();
61
+ const chapterNum =
62
+ typeof d.chapter === 'number' ? `Chapter ${d.chapter}` : null;
63
+ const weekNum = typeof d.week === 'number' ? `Week ${d.week}` : null;
64
+ const statusBadge =
65
+ typeof d.status === 'string' ? d.status.replace(/_/g, ' ') : null;
66
+ const title = typeof d.title === 'string' ? d.title : '(untitled)';
67
+ const description = typeof d.description === 'string' ? d.description : null;
68
+ const toolsCompared = Array.isArray(d.tools_compared)
69
+ ? (d.tools_compared as string[])
70
+ : [];
71
+ const lastVerified = d.last_verified instanceof Date ? d.last_verified : null;
72
+ const updated = d.updated instanceof Date ? d.updated : null;
73
+ const volatility = typeof d.volatility === 'string' ? d.volatility : null;
31
74
  ---
32
75
  <header class="chapter-header">
33
76
  <div class="chapter-meta">
34
- <span>Part {data.part}</span>
35
- <span>Chapter {data.chapter}</span>
36
- <span>
37
- Last verified {formatDate(data.last_verified)}
38
- <span
39
- class="freshness-badge"
40
- data-status={freshness.status}
41
- aria-label={freshnessLabel(freshness)}
42
- title={freshnessLabel(freshness)}
43
- >{freshnessText}</span>
44
- </span>
45
- {data.updated && <span>Updated {formatDate(data.updated)}</span>}
46
- </div>
47
- <h1>{data.title}</h1>
48
- {data.description && <p class="chapter-description">{data.description}</p>}
49
- <div class="chapter-badge-row">
50
- <span class="chapter-badge-row-label">Volatility:</span>
51
- <span class={`volatility-badge volatility-${data.volatility}`}>
52
- {data.volatility}
53
- </span>
77
+ {partLabel && <span>{partLabel}</span>}
78
+ {chapterNum && <span>{chapterNum}</span>}
79
+ {weekNum && <span>{weekNum}</span>}
80
+ {statusBadge && (
81
+ <span class="status-badge" data-status={d.status as string}>
82
+ {statusBadge}
83
+ </span>
84
+ )}
85
+ {lastVerified && (
86
+ <span>
87
+ Last verified {formatDate(lastVerified)}
88
+ {freshness && freshnessText && (
89
+ <span
90
+ class="freshness-badge"
91
+ data-status={freshness.status}
92
+ aria-label={freshnessLabel(freshness)}
93
+ title={freshnessLabel(freshness)}
94
+ >{freshnessText}</span>
95
+ )}
96
+ </span>
97
+ )}
98
+ {updated && <span>Updated {formatDate(updated)}</span>}
54
99
  </div>
55
- {data.tools_compared.length > 0 && (
100
+ <h1>{title}</h1>
101
+ {description && <p class="chapter-description">{description}</p>}
102
+ {hasToolsMeta && volatility && (
103
+ <div class="chapter-badge-row">
104
+ <span class="chapter-badge-row-label">Volatility:</span>
105
+ <span class={`volatility-badge volatility-${volatility}`}>
106
+ {volatility}
107
+ </span>
108
+ </div>
109
+ )}
110
+ {hasToolsMeta && toolsCompared.length > 0 && (
56
111
  <div class="chapter-badge-row">
57
112
  <span class="chapter-badge-row-label">Tools compared:</span>
58
- {data.tools_compared.map((t) => <span class="tool-badge">{t}</span>)}
113
+ {toolsCompared.map((t) => <span class="tool-badge">{t}</span>)}
59
114
  </div>
60
115
  )}
61
116
  </header>
package/dist/index.mjs CHANGED
@@ -153,11 +153,11 @@ var ALWAYS_ON_STYLES = [
153
153
  var TOOLS_ONLY_STYLES = ["convergence.css", "tool-filter.css"];
154
154
  var DEFAULT_ROUTES_ALL = [
155
155
  { pattern: "/references", file: "references.astro" },
156
- { pattern: "/search", file: "search.astro" }
156
+ { pattern: "/search", file: "search.astro" },
157
+ { pattern: "/print", file: "print.astro" }
157
158
  ];
158
159
  var DEFAULT_ROUTES_TOOLS = [
159
160
  { pattern: "/chapters", file: "chapters.astro" },
160
- { pattern: "/print", file: "print.astro" },
161
161
  { pattern: "/convergence", file: "convergence.astro" }
162
162
  ];
163
163
  function resolvePage(file) {
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",
4
+ "version": "3.0.0-alpha.6",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -40,6 +40,7 @@
40
40
  "types": "./dist/schemas.d.ts",
41
41
  "import": "./dist/schemas.mjs"
42
42
  },
43
+ "./package.json": "./package.json",
43
44
  "./components/CaseStudy.astro": "./components/CaseStudy.astro",
44
45
  "./components/ChapterHeader.astro": "./components/ChapterHeader.astro",
45
46
  "./components/ChapterNav.astro": "./components/ChapterNav.astro",
@@ -109,7 +110,7 @@
109
110
  "README.md"
110
111
  ],
111
112
  "scripts": {
112
- "build": "tsup",
113
+ "build": "tsup && rm -f dist/types-*.d.ts",
113
114
  "prepublishOnly": "npm run build"
114
115
  },
115
116
  "peerDependencies": {
@@ -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
+ });
@@ -1,61 +0,0 @@
1
- import { AstroIntegration, AstroUserConfig } from 'astro';
2
-
3
- /**
4
- * Shared types for @brandon_m_behring/book-scaffold-astro.
5
- *
6
- * Public types referenced from PACKAGE_DESIGN.md §4 / §5 / §6. Kept in
7
- * one place so consumer IntelliSense surfaces a coherent API.
8
- */
9
-
10
- type BookProfile = 'academic' | 'tools' | 'minimal';
11
- declare const BOOK_PROFILES: readonly ["academic", "tools", "minimal"];
12
- /**
13
- * Options for `defineBookConfig`. See PACKAGE_DESIGN.md §4.
14
- *
15
- * Note on the index signature: `AstroUserConfig` carries generic
16
- * parameters (`Locales`, `SessionDriverName`, fonts) that can't be
17
- * threaded cleanly through a wrapper. Instead we type the package-
18
- * specific fields strictly and allow arbitrary AstroUserConfig keys
19
- * via the index signature — consumer types will lint clean but lose
20
- * full IDE autocomplete on non-package fields. Acceptable trade.
21
- */
22
- interface BookConfigOptions {
23
- /** Required. Book's deployed origin (sitemap, canonical, Pagefind). */
24
- site: string;
25
- /**
26
- * Optional. Falls back to `process.env.BOOK_PROFILE`, then `'minimal'`.
27
- * Explicit param always wins over env.
28
- */
29
- profile?: BookProfile;
30
- /** Optional. Appended to the package-provided integration list. */
31
- extraIntegrations?: AstroIntegration[];
32
- /**
33
- * Optional. CSS basenames to inject in addition to the profile-resolved
34
- * set. Cross-profile escape hatch (e.g. an academic book using
35
- * `<Convergence>`). Example: `['convergence.css']`.
36
- */
37
- extraStyles?: string[];
38
- /** Optional. Spread-merged into the package-provided markdown config. */
39
- markdown?: AstroUserConfig['markdown'];
40
- /** Escape hatch for any other AstroUserConfig field. */
41
- [key: string]: unknown;
42
- }
43
- /** Options for `defineBookSchemas`. See PACKAGE_DESIGN.md §5. */
44
- interface BookSchemasOptions {
45
- profile?: BookProfile;
46
- /** Defaults to `'./src/content/chapters'`. */
47
- chaptersBase?: string;
48
- }
49
- /** Options for the internal `bookScaffoldIntegration`. See PACKAGE_DESIGN.md §6. */
50
- interface BookScaffoldIntegrationOptions {
51
- profile: BookProfile;
52
- extraStyles?: string[];
53
- }
54
- /** Raised when the resolved profile is not one of `BOOK_PROFILES`. */
55
- declare class BookConfigError extends Error {
56
- constructor(message: string);
57
- }
58
- /** Resolve profile from explicit param → env → default. Throws on invalid. */
59
- declare function resolveProfile(explicit?: BookProfile): BookProfile;
60
-
61
- export { BOOK_PROFILES as B, BookConfigError as a, type BookConfigOptions as b, type BookProfile as c, type BookScaffoldIntegrationOptions as d, type BookSchemasOptions as e, resolveProfile as r };