@brandon_m_behring/book-scaffold-astro 3.6.5 → 3.7.1
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/dist/index.d.ts +43 -3
- package/dist/index.mjs +225 -44
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +216 -5
- package/package.json +4 -1
- package/pages/chapters.astro +126 -125
- package/scripts/validate.mjs +7 -2
- package/scripts/walk-mdx.mjs +34 -0
- package/src/lib/chapters-renderer.ts +99 -0
- package/src/profile-kit.ts +100 -0
- package/src/profiles/academic.ts +30 -0
- package/src/profiles/course-notes.ts +32 -0
- package/src/profiles/index.ts +54 -0
- package/src/profiles/minimal.ts +27 -0
- package/src/profiles/renderers/academic-chapters.ts +102 -0
- package/src/profiles/renderers/fallback-chapters.ts +87 -0
- package/src/profiles/renderers/tools-chapters.ts +102 -0
- package/src/profiles/research-portfolio.ts +44 -0
- package/src/profiles/tools.ts +29 -0
- package/src/schemas.ts +291 -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.
|
|
4
|
+
"version": "3.7.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Brandon Behring",
|
|
@@ -107,6 +107,9 @@
|
|
|
107
107
|
"files": [
|
|
108
108
|
"dist",
|
|
109
109
|
"src/lib",
|
|
110
|
+
"src/profiles",
|
|
111
|
+
"src/profile-kit.ts",
|
|
112
|
+
"src/schemas.ts",
|
|
110
113
|
"components",
|
|
111
114
|
"styles",
|
|
112
115
|
"layouts",
|
package/pages/chapters.astro
CHANGED
|
@@ -2,59 +2,91 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* /chapters — book index page.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Route-level concerns kept here:
|
|
6
|
+
* - Data fetch (getAllChapters)
|
|
7
|
+
* - byPart Map grouping (insertion-order preserved across number + string keys)
|
|
8
|
+
* - ToolFilter island wiring (inline script, data-tools attribute on cards,
|
|
9
|
+
* part-group hide-when-empty, filter hint)
|
|
10
|
+
* - CSS, structural JSX, <Base> wrapping
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - tools
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Per-profile concerns dispatched via `PROFILES[BOOK_PROFILE].chaptersRenderer`:
|
|
13
|
+
* - Meta-row composition (numbering, badges)
|
|
14
|
+
* - Sort key strategy
|
|
15
|
+
* - data-tools value computation
|
|
16
|
+
*
|
|
17
|
+
* v3.7.0 (closes #35): replaces the field-presence discriminator that
|
|
18
|
+
* lived inline in v3.5.2. Each profile now ships a ChaptersRenderer
|
|
19
|
+
* implementing the strategy interface; consumers opting profiles without
|
|
20
|
+
* a dedicated renderer (e.g., minimal + routes.chapters: true) get the
|
|
21
|
+
* fallbackChaptersRenderer which dispatches via field presence — exactly
|
|
22
|
+
* the v3.5.2 behavior, preserved as a safety net.
|
|
16
23
|
*/
|
|
17
24
|
import Base from '../layouts/Base.astro';
|
|
18
25
|
import { getAllChapters, type Chapter } from '../src/lib/chapters';
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
import { PROFILES } from '../src/profiles/index';
|
|
27
|
+
import { fallbackChaptersRenderer } from '../src/profiles/renderers/fallback-chapters';
|
|
28
|
+
import type { ChaptersRenderer, PartKey } from '../src/lib/chapters-renderer';
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const key = (c.data as { part: PartKey }).part;
|
|
29
|
-
const list = byPart.get(key);
|
|
30
|
-
if (list) list.push(c);
|
|
31
|
-
else byPart.set(key, [c]);
|
|
32
|
-
}
|
|
30
|
+
const profileName = (import.meta.env.BOOK_PROFILE ?? 'minimal') as keyof typeof PROFILES;
|
|
31
|
+
const profileDef = PROFILES[profileName];
|
|
32
|
+
const renderer: ChaptersRenderer =
|
|
33
|
+
(profileDef as { chaptersRenderer?: ChaptersRenderer } | undefined)?.chaptersRenderer
|
|
34
|
+
?? fallbackChaptersRenderer;
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
return d.toISOString().slice(0, 10);
|
|
36
|
-
}
|
|
36
|
+
const chapters = await getAllChapters();
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
// Precompute each card's render-ready fields in the frontmatter block.
|
|
39
|
+
// This keeps TypeScript generics (Record<string, unknown> casts, the
|
|
40
|
+
// renderer return-type unions) out of the JSX template, where the
|
|
41
|
+
// Astro compiler treats `<` as a tag-start token and trips on type
|
|
42
|
+
// assertions inside expressions.
|
|
43
|
+
interface CardData {
|
|
44
|
+
c: Chapter;
|
|
45
|
+
toolsAttr: string;
|
|
46
|
+
number: string;
|
|
47
|
+
volatility: ReturnType<typeof renderer.getVolatilityData>;
|
|
48
|
+
status: ReturnType<typeof renderer.getStatusData>;
|
|
49
|
+
freshness: ReturnType<typeof renderer.getFreshnessData>;
|
|
50
|
+
freshnessText: string | null;
|
|
51
|
+
verifiedLabel: string | null;
|
|
52
|
+
toolsCompared: string[];
|
|
53
|
+
data: { title: unknown; description?: unknown };
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
function
|
|
51
|
-
|
|
56
|
+
function buildCard(c: Chapter, appendix: boolean): CardData {
|
|
57
|
+
const data = c.data as Record<string, unknown>;
|
|
58
|
+
const freshness = renderer.getFreshnessData(data);
|
|
59
|
+
const freshnessText = freshness
|
|
60
|
+
? freshness.status === 'fresh'
|
|
61
|
+
? 'Fresh'
|
|
62
|
+
: freshness.status === 'verify-soon'
|
|
63
|
+
? 'Verify soon'
|
|
64
|
+
: 'Stale'
|
|
65
|
+
: null;
|
|
66
|
+
return {
|
|
67
|
+
c,
|
|
68
|
+
toolsAttr: renderer.getToolsAttr(data),
|
|
69
|
+
number: renderer.formatChapterNumber(data, appendix),
|
|
70
|
+
volatility: renderer.getVolatilityData(data),
|
|
71
|
+
status: renderer.getStatusData(data),
|
|
72
|
+
freshness,
|
|
73
|
+
freshnessText,
|
|
74
|
+
verifiedLabel: renderer.getVerifiedDateLabel(data),
|
|
75
|
+
toolsCompared: renderer.getToolsCompared(data),
|
|
76
|
+
data: { title: data.title, description: data.description as string | undefined },
|
|
77
|
+
};
|
|
52
78
|
}
|
|
53
79
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
// Stable insertion-order grouping. Map preserves order for both number and
|
|
81
|
+
// string keys (academic uses string-enum parts; tools uses numeric parts).
|
|
82
|
+
const byPart = new Map<PartKey, CardData[]>();
|
|
83
|
+
for (const c of chapters) {
|
|
84
|
+
const key = renderer.partKey(c.data as Record<string, unknown>);
|
|
85
|
+
const appendix = renderer.isAppendix(key);
|
|
86
|
+
const card = buildCard(c, appendix);
|
|
87
|
+
const list = byPart.get(key);
|
|
88
|
+
if (list) list.push(card);
|
|
89
|
+
else byPart.set(key, [card]);
|
|
58
90
|
}
|
|
59
91
|
---
|
|
60
92
|
<Base
|
|
@@ -72,89 +104,58 @@ function isToolsShape(data: Record<string, unknown>): boolean {
|
|
|
72
104
|
|
|
73
105
|
<p class="chapters-filter-hint" id="filter-hint" aria-live="polite"></p>
|
|
74
106
|
|
|
75
|
-
{Array.from(byPart.entries()).map(([part,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
class="freshness-badge"
|
|
128
|
-
data-status={freshness.status}
|
|
129
|
-
aria-label={freshnessLabel(freshness)}
|
|
130
|
-
title={freshnessLabel(freshness)}
|
|
131
|
-
>{freshnessText}</span>
|
|
132
|
-
)}
|
|
133
|
-
{tools && data.last_verified && (
|
|
134
|
-
<span class="chapter-card-verified">
|
|
135
|
-
verified {formatDate(data.last_verified as Date)}
|
|
136
|
-
</span>
|
|
137
|
-
)}
|
|
138
|
-
</div>
|
|
139
|
-
<h3 class="chapter-card-title">{data.title}</h3>
|
|
140
|
-
{data.description && (
|
|
141
|
-
<p class="chapter-card-description">{data.description as string}</p>
|
|
142
|
-
)}
|
|
143
|
-
{tools && (
|
|
144
|
-
<div class="chapter-card-tools">
|
|
145
|
-
{(data.tools_compared as string[]).map((t) => (
|
|
146
|
-
<span class="tool-badge">{t}</span>
|
|
147
|
-
))}
|
|
148
|
-
</div>
|
|
149
|
-
)}
|
|
150
|
-
</a>
|
|
151
|
-
</li>
|
|
152
|
-
);
|
|
153
|
-
})}
|
|
154
|
-
</ol>
|
|
155
|
-
</section>
|
|
156
|
-
);
|
|
157
|
-
})}
|
|
107
|
+
{Array.from(byPart.entries()).map(([part, cards]) => (
|
|
108
|
+
<section class="part-group">
|
|
109
|
+
<h2 class="part-heading">
|
|
110
|
+
<span class="part-label">{renderer.formatPartLabel(part)}</span>
|
|
111
|
+
</h2>
|
|
112
|
+
<ol class="chapter-list">
|
|
113
|
+
{cards.map((card) => (
|
|
114
|
+
<li class="chapter-card" data-tools={card.toolsAttr}>
|
|
115
|
+
<a href={`/${card.c.id}/`} class="chapter-card-link">
|
|
116
|
+
<div class="chapter-card-meta">
|
|
117
|
+
<span class="chapter-card-number">{card.number}</span>
|
|
118
|
+
{card.volatility && (
|
|
119
|
+
<span
|
|
120
|
+
class={`volatility-badge volatility-${card.volatility.level}`}
|
|
121
|
+
title={`Volatility: ${card.volatility.label}`}
|
|
122
|
+
>{card.volatility.label}</span>
|
|
123
|
+
)}
|
|
124
|
+
{card.status && (
|
|
125
|
+
<span
|
|
126
|
+
class={`status-badge status-${card.status.status}`}
|
|
127
|
+
title={`Status: ${card.status.label}`}
|
|
128
|
+
>{card.status.label}</span>
|
|
129
|
+
)}
|
|
130
|
+
{card.freshness && card.freshnessText && (
|
|
131
|
+
<span
|
|
132
|
+
class="freshness-badge"
|
|
133
|
+
data-status={card.freshness.status}
|
|
134
|
+
aria-label={card.freshness.label}
|
|
135
|
+
title={card.freshness.label}
|
|
136
|
+
>{card.freshnessText}</span>
|
|
137
|
+
)}
|
|
138
|
+
{card.verifiedLabel && (
|
|
139
|
+
<span class="chapter-card-verified">{card.verifiedLabel}</span>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
<h3 class="chapter-card-title">{card.data.title}</h3>
|
|
143
|
+
{card.data.description && (
|
|
144
|
+
<p class="chapter-card-description">{card.data.description as string}</p>
|
|
145
|
+
)}
|
|
146
|
+
{card.toolsCompared.length > 0 && (
|
|
147
|
+
<div class="chapter-card-tools">
|
|
148
|
+
{card.toolsCompared.map((t) => (
|
|
149
|
+
<span class="tool-badge">{t}</span>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</a>
|
|
154
|
+
</li>
|
|
155
|
+
))}
|
|
156
|
+
</ol>
|
|
157
|
+
</section>
|
|
158
|
+
))}
|
|
158
159
|
</article>
|
|
159
160
|
</Base>
|
|
160
161
|
|
package/scripts/validate.mjs
CHANGED
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
* Exit code = total failure count (0 = pass, >=1 = errors).
|
|
27
27
|
*/
|
|
28
28
|
import { readFile, access } from 'node:fs/promises';
|
|
29
|
-
import { glob } from 'node:fs/promises';
|
|
30
29
|
import { existsSync, readFileSync } from 'node:fs';
|
|
31
30
|
import { resolve, dirname, join } from 'node:path';
|
|
31
|
+
import { walkMdx } from './walk-mdx.mjs';
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Best-effort .env reader. Mirrors `readEnvFile` in src/types.ts; kept inline
|
|
@@ -134,8 +134,13 @@ const refs = await loadJson(join(DATA_DIR, 'references.json'));
|
|
|
134
134
|
const labels = await loadJson(join(DATA_DIR, 'labels.json'));
|
|
135
135
|
|
|
136
136
|
// ===== Collect chapter files =====
|
|
137
|
+
// v3.7.1 (closes #52): walkMdx (in ./walk-mdx.mjs) is a recursive readdir
|
|
138
|
+
// walker that replaces the previous `glob` import from `node:fs/promises`.
|
|
139
|
+
// The `glob` API was added in Node 22 but consumer CI templates ship
|
|
140
|
+
// Node 20 — `npm run validate` crashed on every consumer's prebuild hook.
|
|
141
|
+
// Walker uses readdir + path only; works on Node 18+.
|
|
137
142
|
const chapterFiles = [];
|
|
138
|
-
for await (const f of
|
|
143
|
+
for await (const f of walkMdx(CHAPTERS_DIR)) {
|
|
139
144
|
if (!f.split('/').pop().startsWith('_')) chapterFiles.push(f);
|
|
140
145
|
}
|
|
141
146
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/walk-mdx.mjs — recursive .md/.mdx file walker for content trees.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from scripts/validate.mjs in v3.7.1 (closes #52) so it can be
|
|
5
|
+
* unit-tested without running validate's side-effectful top-level await.
|
|
6
|
+
*
|
|
7
|
+
* Replaces the previous `glob` import from `node:fs/promises` (Node 22+
|
|
8
|
+
* only). The walker below uses `readdir` only — works on Node 18+ so
|
|
9
|
+
* consumer CIs running `node-version: '20'` no longer crash on the
|
|
10
|
+
* scaffold's prebuild validate hook.
|
|
11
|
+
*
|
|
12
|
+
* Output: relative paths in POSIX form ("subdir/file.mdx"), matching what
|
|
13
|
+
* the previous `glob('**\/*.{md,mdx}', { cwd })` produced.
|
|
14
|
+
*/
|
|
15
|
+
import { readdir } from 'node:fs/promises';
|
|
16
|
+
import { join, relative } from 'node:path';
|
|
17
|
+
|
|
18
|
+
export async function* walkMdx(dir, baseDir = dir) {
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
22
|
+
} catch {
|
|
23
|
+
return; // dir missing or unreadable — treat as zero chapters
|
|
24
|
+
}
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const full = join(dir, entry.name);
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
yield* walkMdx(full, baseDir);
|
|
29
|
+
} else if (/\.(md|mdx)$/.test(entry.name)) {
|
|
30
|
+
// Normalize to forward slashes for cross-platform stability.
|
|
31
|
+
yield relative(baseDir, full).split(/[\\/]/).join('/');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/lib/chapters-renderer.ts — per-profile strategy interface for the
|
|
3
|
+
* /chapters route. Pure-function strategy; no Astro imports here or in
|
|
4
|
+
* implementations (kept that way so profile modules can re-export renderers
|
|
5
|
+
* without dragging Astro virtual modules into tsup's DTS bundle — same
|
|
6
|
+
* constraint that motivated the chapter-sort.ts split in v3.5.2).
|
|
7
|
+
*
|
|
8
|
+
* v3.7.0 (closes #35): replaces the field-presence discriminator that
|
|
9
|
+
* v3.5.2 put inside pages/chapters.astro. Each profile module now owns
|
|
10
|
+
* its chapters-page rendering semantics via this interface, and the
|
|
11
|
+
* route file dispatches via PROFILES[BOOK_PROFILE].chaptersRenderer.
|
|
12
|
+
*
|
|
13
|
+
* Why pure data, not Astro components: profile modules are bundled by
|
|
14
|
+
* tsup into dist/, but Astro components (.astro files) cannot be processed
|
|
15
|
+
* by tsup's DTS bundler. Keeping the renderer surface as plain data
|
|
16
|
+
* (strings, numbers, plain objects, null) lets the route file own all
|
|
17
|
+
* JSX rendering while each profile owns the per-shape semantics.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type PartKey = string | number;
|
|
21
|
+
|
|
22
|
+
export type FreshnessStatus = 'fresh' | 'verify-soon' | 'stale';
|
|
23
|
+
|
|
24
|
+
/** Volatility badge metadata for tools-profile rendering. */
|
|
25
|
+
export interface VolatilityBadge {
|
|
26
|
+
/** CSS modifier (`stable-principle | architectural-pattern | feature-surface`). */
|
|
27
|
+
level: string;
|
|
28
|
+
/** Visible chip text + tooltip body. */
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Academic status badge metadata. */
|
|
33
|
+
export interface StatusBadge {
|
|
34
|
+
/** CSS modifier (`implemented | scaffolded | planned | ...`). */
|
|
35
|
+
status: string;
|
|
36
|
+
/** Visible chip text + tooltip body. */
|
|
37
|
+
label: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Freshness affordance with status band + ARIA-friendly label. */
|
|
41
|
+
export interface FreshnessAffordance {
|
|
42
|
+
status: FreshnessStatus;
|
|
43
|
+
label: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A renderer owns the per-shape semantics for the /chapters route. The
|
|
48
|
+
* route file orchestrates: data fetching, byPart grouping, ToolFilter
|
|
49
|
+
* island wiring, CSS, structural JSX. The renderer answers:
|
|
50
|
+
*
|
|
51
|
+
* - How is this chapter labelled?
|
|
52
|
+
* - Which badges apply?
|
|
53
|
+
* - What's the ToolFilter attribute?
|
|
54
|
+
* - How do these chapters sort?
|
|
55
|
+
*
|
|
56
|
+
* All methods are pure: same input → same output, no side effects.
|
|
57
|
+
*/
|
|
58
|
+
export interface ChaptersRenderer {
|
|
59
|
+
/** Group key for the byPart Map. Tools = number, academic = string-enum. */
|
|
60
|
+
partKey(data: Record<string, unknown>): PartKey;
|
|
61
|
+
|
|
62
|
+
/** Human-facing heading label for a Part group ("Part 1", "Foundations"). */
|
|
63
|
+
formatPartLabel(part: PartKey): string;
|
|
64
|
+
|
|
65
|
+
/** Whether the group renders as an appendix (tools profile only). */
|
|
66
|
+
isAppendix(part: PartKey): boolean;
|
|
67
|
+
|
|
68
|
+
/** Chapter card heading text. Examples:
|
|
69
|
+
* tools → "Chapter 1" or "Appendix a"
|
|
70
|
+
* academic → "Week 3"
|
|
71
|
+
* fallback → best-effort from available fields
|
|
72
|
+
*/
|
|
73
|
+
formatChapterNumber(data: Record<string, unknown>, appendix: boolean): string;
|
|
74
|
+
|
|
75
|
+
/** Value for the `data-tools` attribute (ToolFilter island wiring).
|
|
76
|
+
* Tools = space-joined slugs from `tools_compared`.
|
|
77
|
+
* Non-tools = "cross-tool" (always-visible regardless of filter). */
|
|
78
|
+
getToolsAttr(data: Record<string, unknown>): string;
|
|
79
|
+
|
|
80
|
+
/** Volatility badge metadata, or null if the profile doesn't use it. */
|
|
81
|
+
getVolatilityData(data: Record<string, unknown>): VolatilityBadge | null;
|
|
82
|
+
|
|
83
|
+
/** Academic status badge metadata, or null. */
|
|
84
|
+
getStatusData(data: Record<string, unknown>): StatusBadge | null;
|
|
85
|
+
|
|
86
|
+
/** Freshness affordance, or null if the profile doesn't track `last_verified`.
|
|
87
|
+
* `now` is injectable for deterministic tests. */
|
|
88
|
+
getFreshnessData(data: Record<string, unknown>, now?: Date): FreshnessAffordance | null;
|
|
89
|
+
|
|
90
|
+
/** "verified YYYY-MM-DD" meta-row text, or null. */
|
|
91
|
+
getVerifiedDateLabel(data: Record<string, unknown>): string | null;
|
|
92
|
+
|
|
93
|
+
/** Tools-compared tag list for the bottom card row, or empty array. */
|
|
94
|
+
getToolsCompared(data: Record<string, unknown>): string[];
|
|
95
|
+
|
|
96
|
+
/** Sort key — profile-aware. Tools = `part * 1000 + chapter`;
|
|
97
|
+
* academic = `partOrdinal * 1000 + week`. */
|
|
98
|
+
sortKey(data: Record<string, unknown>): number;
|
|
99
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile-kit — internal helper for declaring book profiles.
|
|
3
|
+
*
|
|
4
|
+
* Each profile (academic, tools, minimal, course-notes, future paper-review,
|
|
5
|
+
* etc.) lives in its own self-contained module under src/profiles/ and uses
|
|
6
|
+
* defineProfile() to declare its schema + auto-injected routes + auto-loaded
|
|
7
|
+
* styles. The PROFILES registry in src/profiles/index.ts wires them together;
|
|
8
|
+
* bookScaffoldIntegration consumes the registry.
|
|
9
|
+
*
|
|
10
|
+
* defineProfile() is an identity function — same pattern as Vite's
|
|
11
|
+
* defineConfig, Astro's defineConfig, Zod's z.object. Currently no generic
|
|
12
|
+
* constraint on the schema parameter: per-profile inferred chapter types
|
|
13
|
+
* are exported separately (AcademicChapter, ToolsChapter, etc.) via
|
|
14
|
+
* `z.infer<typeof schema>`, so the registry doesn't need to track each
|
|
15
|
+
* schema's exact shape. Keeping the schema typed as `unknown` here also
|
|
16
|
+
* avoids tsup's DTS bundler dragging deep Zod internals into the .d.ts
|
|
17
|
+
* (rollup-plugin-dts can't always resolve Zod's `default` export shape).
|
|
18
|
+
*
|
|
19
|
+
* Adding a new profile is a single-file change:
|
|
20
|
+
* 1. Create src/profiles/<name>.ts (define schema + type + profile config).
|
|
21
|
+
* 2. Register it in src/profiles/index.ts (one line in PROFILES + one line
|
|
22
|
+
* in ChapterFor<P>).
|
|
23
|
+
* 3. (Optional) ship a default chapter route page under package/pages/.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ChaptersRenderer } from './lib/chapters-renderer.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The set of routes the toolkit can auto-inject. Per-profile defaults are
|
|
30
|
+
* declared in each profile module; consumers override via
|
|
31
|
+
* defineBookConfig({ routes: { … } }).
|
|
32
|
+
*
|
|
33
|
+
* The shape is fixed — adding a new auto-injected route requires updating
|
|
34
|
+
* this type AND adding a default to every profile module. The trade-off is
|
|
35
|
+
* worth it: consumers get TS autocomplete on the route names and TS errors
|
|
36
|
+
* on typos like `convergance: false`.
|
|
37
|
+
*/
|
|
38
|
+
export interface RouteToggles {
|
|
39
|
+
references: boolean;
|
|
40
|
+
search: boolean;
|
|
41
|
+
print: boolean;
|
|
42
|
+
chapters: boolean;
|
|
43
|
+
convergence: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* v3.4.0 (closes #7): auto-inject `/frontmatter/[slug]/` rendering a
|
|
46
|
+
* consumer-defined `frontmatter` content collection. Default `false` per
|
|
47
|
+
* profile — opt in via defineBookConfig({ routes: { frontmatter: true } })
|
|
48
|
+
* AND define the collection via `frontmatterCollection()` in content.config.ts.
|
|
49
|
+
* If enabled without defining the collection, Astro errors clearly at build.
|
|
50
|
+
*/
|
|
51
|
+
frontmatter: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Profile definition — declarative shape for one book profile. */
|
|
55
|
+
export interface ProfileDefinition {
|
|
56
|
+
/** Stable name; must match the key in PROFILES + the BOOK_PROFILE env value. */
|
|
57
|
+
name: string;
|
|
58
|
+
/**
|
|
59
|
+
* The Zod schema used as the chapter collection schema. Typed as
|
|
60
|
+
* `unknown` here on purpose — per-profile inferred chapter types
|
|
61
|
+
* (AcademicChapter, ToolsChapter, …) are exported separately and give
|
|
62
|
+
* consumers the narrow typing where it matters. defineCollection
|
|
63
|
+
* (in src/schemas-entry.ts) accepts the schema runtime-style.
|
|
64
|
+
*/
|
|
65
|
+
schema: unknown;
|
|
66
|
+
/** Auto-injected routes; consumers override via defineBookConfig({ routes }). */
|
|
67
|
+
routes: RouteToggles;
|
|
68
|
+
/** CSS basenames loaded for this profile (resolved from package/styles/). */
|
|
69
|
+
styles: string[];
|
|
70
|
+
/** Whether KaTeX should be wired in (academic profile only currently). */
|
|
71
|
+
katex?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* v3.7.0 (closes #35): per-profile renderer for the /chapters route.
|
|
74
|
+
* Owns the chapter-card meta-row composition, numbering format, sort key,
|
|
75
|
+
* and ToolFilter wiring for this profile's data shape. Pure-function
|
|
76
|
+
* strategy (no Astro imports — see src/lib/chapters-renderer.ts header).
|
|
77
|
+
*
|
|
78
|
+
* Optional: profiles that don't ship a dedicated renderer get the
|
|
79
|
+
* fallbackChaptersRenderer (field-presence dispatch) at route render time.
|
|
80
|
+
*/
|
|
81
|
+
chaptersRenderer?: ChaptersRenderer;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Identity helper for declaring a profile module.
|
|
86
|
+
*
|
|
87
|
+
* export const courseNotesProfile = defineProfile({
|
|
88
|
+
* name: 'course-notes',
|
|
89
|
+
* schema: courseNotesChapterSchema,
|
|
90
|
+
* routes: { references: true, search: true, print: true, chapters: false, convergence: false },
|
|
91
|
+
* styles: ['tokens.css', ...],
|
|
92
|
+
* });
|
|
93
|
+
*
|
|
94
|
+
* No runtime work; the value goes through unchanged. Useful as a typed
|
|
95
|
+
* "this is a profile" marker that catches missing required fields at
|
|
96
|
+
* authoring time.
|
|
97
|
+
*/
|
|
98
|
+
export function defineProfile(p: ProfileDefinition): ProfileDefinition {
|
|
99
|
+
return p;
|
|
100
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Academic profile — weekly curriculum with 7-state status taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* Reference consumer: post_transformers. Schema definition + inferred
|
|
5
|
+
* chapter type live in src/schemas.ts (consolidated to keep all Zod-using
|
|
6
|
+
* code in one file — see schemas.ts header for the DTS-bundler rationale).
|
|
7
|
+
* This module composes the schema with routes + styles via defineProfile.
|
|
8
|
+
*/
|
|
9
|
+
import { defineProfile } from '../profile-kit.js';
|
|
10
|
+
import { academicChapterSchema } from '../schemas.js';
|
|
11
|
+
import { academicChaptersRenderer } from './renderers/academic-chapters.js';
|
|
12
|
+
|
|
13
|
+
// Re-export for consumer ergonomics (`import { AcademicChapter } from '@brandon_m_behring/book-scaffold-astro'`).
|
|
14
|
+
export type { AcademicChapter } from '../schemas.js';
|
|
15
|
+
|
|
16
|
+
export const academicProfile = defineProfile({
|
|
17
|
+
name: 'academic',
|
|
18
|
+
schema: academicChapterSchema,
|
|
19
|
+
routes: {
|
|
20
|
+
references: true,
|
|
21
|
+
search: true,
|
|
22
|
+
print: true,
|
|
23
|
+
chapters: false, // academic consumers ship their own week-based /chapters listing
|
|
24
|
+
convergence: false, // tools-profile-specific
|
|
25
|
+
frontmatter: false, // opt-in per book; see #7
|
|
26
|
+
},
|
|
27
|
+
styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
|
|
28
|
+
katex: true,
|
|
29
|
+
chaptersRenderer: academicChaptersRenderer, // v3.7.0 (#35) — owns /chapters semantics if consumer opts in via routes.chapters
|
|
30
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Course-notes profile — chapters derived from a video course / MOOC / book.
|
|
3
|
+
*
|
|
4
|
+
* Reference consumer (forthcoming): DLAI knowledge-graphs-rag pilot. Schema
|
|
5
|
+
* + inferred type live in src/schemas.ts; this module composes with routes
|
|
6
|
+
* + styles. Multi-book corpus pattern is supported by consumer-side schema
|
|
7
|
+
* extension via Zod .extend() with a `book` discriminator.
|
|
8
|
+
*
|
|
9
|
+
* Distinct from the tools profile (which has tools_compared as an enum of
|
|
10
|
+
* AI CLIs) and academic profile (which is week-based). Don't reuse either.
|
|
11
|
+
*/
|
|
12
|
+
import { defineProfile } from '../profile-kit.js';
|
|
13
|
+
import { courseNotesChapterSchema } from '../schemas.js';
|
|
14
|
+
import { fallbackChaptersRenderer } from './renderers/fallback-chapters.js';
|
|
15
|
+
|
|
16
|
+
export type { CourseNotesChapter } from '../schemas.js';
|
|
17
|
+
|
|
18
|
+
export const courseNotesProfile = defineProfile({
|
|
19
|
+
name: 'course-notes',
|
|
20
|
+
schema: courseNotesChapterSchema,
|
|
21
|
+
routes: {
|
|
22
|
+
references: true,
|
|
23
|
+
search: true,
|
|
24
|
+
print: true,
|
|
25
|
+
chapters: false, // multi-book consumers route via [book]/[slug] themselves
|
|
26
|
+
convergence: false,
|
|
27
|
+
frontmatter: false, // opt-in per book; see #7
|
|
28
|
+
},
|
|
29
|
+
styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
|
|
30
|
+
// v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
|
|
31
|
+
chaptersRenderer: fallbackChaptersRenderer,
|
|
32
|
+
});
|