@brandon_m_behring/book-scaffold-astro 4.2.0 → 4.4.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.
- package/bin/book-scaffold.mjs +5 -1
- package/components/Exercise.astro +24 -0
- package/components/ExerciseSolutions.astro +97 -0
- package/components/Practice.astro +30 -0
- package/components/Solution.astro +27 -0
- package/components/Tip.astro +28 -0
- package/components/TipsCard.astro +44 -0
- package/dist/index.d.ts +52 -3
- package/dist/index.mjs +48 -5
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +25 -5
- package/package.json +7 -1
- package/pages/chapters/[...slug].astro +40 -0
- package/pages/chapters.astro +1 -1
- package/pages/exercises.astro +65 -0
- package/pages/tips.astro +57 -0
- package/recipes/17-draft-chapter-workflow.md +88 -0
- package/scripts/build-exercises.mjs +125 -0
- package/scripts/build-tips.mjs +143 -0
- package/src/lib/define-tips.ts +56 -0
- package/src/profile-kit.ts +16 -0
- package/src/profiles/academic.ts +2 -0
- package/src/profiles/course-notes.ts +2 -0
- package/src/profiles/minimal.ts +2 -0
- package/src/profiles/research-portfolio.ts +2 -0
- package/src/profiles/tools.ts +2 -0
- package/styles/callouts.css +145 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Auto-injected /exercises route (v4.4.0).
|
|
4
|
+
*
|
|
5
|
+
* Gated on `routes.exercises: true`. Reads `src/data/exercises.json`
|
|
6
|
+
* (emitted by `book-scaffold build-exercises`); renders ordered list
|
|
7
|
+
* grouped by chapter with deep links into the chapter routes.
|
|
8
|
+
*
|
|
9
|
+
* Graceful skip: if exercises.json doesn't exist, renders an instructions
|
|
10
|
+
* page (doesn't fail the build).
|
|
11
|
+
*/
|
|
12
|
+
import Base from '../layouts/Base.astro';
|
|
13
|
+
|
|
14
|
+
// v4.4.0 fix: use Vite's import.meta.glob with a project-root-relative
|
|
15
|
+
// path (same lesson as tips.astro). `/src/data/...` is consumer-project-
|
|
16
|
+
// rooted in Vite's resolver and works across all consumer build contexts.
|
|
17
|
+
const exModules = import.meta.glob<{ default: Record<string, Array<{ id: string; problem: string }>> }>(
|
|
18
|
+
'/src/data/exercises.json',
|
|
19
|
+
{ eager: true },
|
|
20
|
+
);
|
|
21
|
+
const exEntry = exModules['/src/data/exercises.json'];
|
|
22
|
+
const byChapter = exEntry?.default ?? {};
|
|
23
|
+
const loadError = exEntry
|
|
24
|
+
? null
|
|
25
|
+
: 'src/data/exercises.json not found — run `npx book-scaffold build-exercises` to generate.';
|
|
26
|
+
|
|
27
|
+
const chapters = Object.keys(byChapter).sort();
|
|
28
|
+
const total = chapters.reduce((sum, c) => sum + byChapter[c].length, 0);
|
|
29
|
+
---
|
|
30
|
+
<Base title="Exercises" description="All exercises in this book, grouped by chapter with deep links.">
|
|
31
|
+
<article class="prose">
|
|
32
|
+
<h1>Exercises</h1>
|
|
33
|
+
{loadError && (
|
|
34
|
+
<p class="exercises-empty">{loadError}</p>
|
|
35
|
+
)}
|
|
36
|
+
{!loadError && total === 0 && (
|
|
37
|
+
<p class="exercises-empty">
|
|
38
|
+
No exercises defined yet. Add <code><Exercise id="...">...</Exercise></code> to a chapter
|
|
39
|
+
and run <code>npx book-scaffold build-exercises</code>.
|
|
40
|
+
</p>
|
|
41
|
+
)}
|
|
42
|
+
{total > 0 && (
|
|
43
|
+
<p class="exercises-summary">
|
|
44
|
+
{total} exercise{total === 1 ? '' : 's'} across {chapters.length} chapter{chapters.length === 1 ? '' : 's'}.
|
|
45
|
+
</p>
|
|
46
|
+
)}
|
|
47
|
+
{chapters.map((chapter) => (
|
|
48
|
+
<section class="exercises-chapter" id={`chapter-${chapter}`}>
|
|
49
|
+
<h2 class="exercises-chapter-title">
|
|
50
|
+
Chapter: <a href={`/chapters/${chapter}/`}>{chapter}</a>
|
|
51
|
+
</h2>
|
|
52
|
+
<ol class="exercises-list">
|
|
53
|
+
{byChapter[chapter].map((ex) => (
|
|
54
|
+
<li class="exercises-item">
|
|
55
|
+
<a href={`/chapters/${chapter}/#exercise-${ex.id}`} class="exercises-link">
|
|
56
|
+
<strong class="exercises-id">Exercise {ex.id}</strong>
|
|
57
|
+
<span class="exercises-preview">{ex.problem.slice(0, 120)}{ex.problem.length > 120 ? '…' : ''}</span>
|
|
58
|
+
</a>
|
|
59
|
+
</li>
|
|
60
|
+
))}
|
|
61
|
+
</ol>
|
|
62
|
+
</section>
|
|
63
|
+
))}
|
|
64
|
+
</article>
|
|
65
|
+
</Base>
|
package/pages/tips.astro
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Auto-injected /tips route (v4.3.0, closes #70).
|
|
4
|
+
*
|
|
5
|
+
* Gated on `routes.tips: true`. Reads `src/data/tips.json` (emitted by
|
|
6
|
+
* `book-scaffold build-tips`); renders an ordered list with anchor
|
|
7
|
+
* permalinks (`/tips#tip-N`).
|
|
8
|
+
*
|
|
9
|
+
* Graceful skip: if tips.json doesn't exist, renders an instructions
|
|
10
|
+
* page (doesn't fail the build).
|
|
11
|
+
*/
|
|
12
|
+
import Base from '../layouts/Base.astro';
|
|
13
|
+
|
|
14
|
+
// v4.4.0 fix: use Vite's import.meta.glob with a project-root-relative
|
|
15
|
+
// path. The previous `../../../src/data/tips.json` resolution failed when
|
|
16
|
+
// the auto-injected route was processed from node_modules (or any context
|
|
17
|
+
// other than a workspace symlink). `/src/data/...` is consumer-project-rooted
|
|
18
|
+
// in Vite's resolver and works across all consumer build contexts.
|
|
19
|
+
const tipsModules = import.meta.glob<{ default: Array<{ n: number; title: string; chapter: string; preview: string }> }>(
|
|
20
|
+
'/src/data/tips.json',
|
|
21
|
+
{ eager: true },
|
|
22
|
+
);
|
|
23
|
+
const tipsEntry = tipsModules['/src/data/tips.json'];
|
|
24
|
+
const tips = tipsEntry?.default ?? [];
|
|
25
|
+
const loadError = tipsEntry
|
|
26
|
+
? null
|
|
27
|
+
: 'src/data/tips.json not found — run `npx book-scaffold build-tips` to generate.';
|
|
28
|
+
---
|
|
29
|
+
<Base title="Tips" description="Numbered tips from this book, drawn from <Tip> instances in chapters.">
|
|
30
|
+
<article class="prose">
|
|
31
|
+
<h1>Tips</h1>
|
|
32
|
+
{loadError && (
|
|
33
|
+
<p class="tips-empty">{loadError}</p>
|
|
34
|
+
)}
|
|
35
|
+
{!loadError && tips.length === 0 && (
|
|
36
|
+
<p class="tips-empty">No tips defined yet. Add <code><Tip n="1" title="...">...</Tip></code> to a chapter and rebuild.</p>
|
|
37
|
+
)}
|
|
38
|
+
{tips.length > 0 && (
|
|
39
|
+
<ol class="tips-index">
|
|
40
|
+
{tips.map((tip) => (
|
|
41
|
+
<li id={`tip-${tip.n}`} class="tips-index-item">
|
|
42
|
+
<h2 class="tips-index-title">
|
|
43
|
+
<span class="tips-index-number">Tip {tip.n}.</span>
|
|
44
|
+
{tip.title}
|
|
45
|
+
</h2>
|
|
46
|
+
{tip.preview && (
|
|
47
|
+
<p class="tips-index-preview">{tip.preview}</p>
|
|
48
|
+
)}
|
|
49
|
+
<p class="tips-index-meta">
|
|
50
|
+
From <a href={`/chapters/${tip.chapter}/`}>{tip.chapter}</a>
|
|
51
|
+
</p>
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ol>
|
|
55
|
+
)}
|
|
56
|
+
</article>
|
|
57
|
+
</Base>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Recipe 17 — Draft chapter workflow (v4.3.0+)
|
|
2
|
+
|
|
3
|
+
The scaffold ships a `draft: boolean` field on every chapter schema (default `false`). Chapters with `draft: true` are filtered out by the canonical chapter-list and per-chapter routes — they exist in `src/content/chapters/` but don't render. Closes [#68](https://github.com/brandon-behring/2-scaffold-astro/issues/68).
|
|
4
|
+
|
|
5
|
+
## TL;DR
|
|
6
|
+
|
|
7
|
+
```yaml
|
|
8
|
+
---
|
|
9
|
+
title: "My in-progress chapter"
|
|
10
|
+
week: 4
|
|
11
|
+
part: foundations
|
|
12
|
+
status: scaffolded
|
|
13
|
+
draft: true # ← Chapter is invisible while draft: true. Flip to false to publish.
|
|
14
|
+
---
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## The filter
|
|
18
|
+
|
|
19
|
+
Both the scaffold-injected `/chapters/` index AND the auto-injected per-chapter route `/chapters/[...slug]/` (new in v4.3.0; see [#69](https://github.com/brandon-behring/2-scaffold-astro/issues/69)) filter via:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
await getCollection('chapters', (entry) => !entry.data.draft);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
So a `draft: true` chapter:
|
|
26
|
+
- Does **not** appear in `/chapters/`
|
|
27
|
+
- Does **not** get a `/chapters/<slug>/` route
|
|
28
|
+
- Does **not** appear in nav / TOC
|
|
29
|
+
- DOES exist in the source tree (still validated by `book-scaffold validate`; still scanned by `book-scaffold build-labels`)
|
|
30
|
+
|
|
31
|
+
This is by design: keeps in-progress work under version control without polluting the production build.
|
|
32
|
+
|
|
33
|
+
## Schema definition
|
|
34
|
+
|
|
35
|
+
All 5 built-in chapter schemas define `draft`:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// package/src/schemas.ts (academic + tools + minimal + course-notes + research-portfolio)
|
|
39
|
+
draft: z.boolean().default(false),
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The default is `false` — chapters publish by default. Authors flip to `true` to hide a work-in-progress.
|
|
43
|
+
|
|
44
|
+
## When to use draft
|
|
45
|
+
|
|
46
|
+
- **Active drafting** — writing a chapter over multiple sessions; don't want intermediate states deployed.
|
|
47
|
+
- **Outline-stage chapters** — frontmatter exists but body is just a TODO list.
|
|
48
|
+
- **Migration in progress** — porting from another source format (LaTeX, Docs, etc.); keep the partial port in-tree but invisible.
|
|
49
|
+
|
|
50
|
+
When you're ready to publish, edit the frontmatter to `draft: false` (or delete the line — default is false).
|
|
51
|
+
|
|
52
|
+
## When NOT to use draft
|
|
53
|
+
|
|
54
|
+
- **Deleting a chapter** — if you no longer want it at all, delete the file. `draft: true` is for chapters that ARE coming back to live.
|
|
55
|
+
- **Reordering** — `draft` doesn't affect chapter ordering. To rearrange chapters, edit the `week:` / `part:` / `chapter:` frontmatter fields (the field varies by preset).
|
|
56
|
+
- **Section-level hiding** — `draft` is whole-chapter only. To hide a section within a published chapter, comment it out in MDX or use a build-time conditional.
|
|
57
|
+
|
|
58
|
+
## Previewing draft chapters during development
|
|
59
|
+
|
|
60
|
+
The default canonical filter excludes drafts in BOTH `npm run dev` and `npm run build`. To preview a draft locally without publishing it:
|
|
61
|
+
|
|
62
|
+
**Option A** (transient): temporarily flip the chapter to `draft: false`, run `npm run dev`, then flip back before committing.
|
|
63
|
+
|
|
64
|
+
**Option B** (recommended): scope the filter to honor an env var in your `src/pages/chapters/[...slug].astro` (only relevant if you've ejected from the scaffold's auto-injected route):
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const includeDrafts = import.meta.env.BOOK_INCLUDE_DRAFTS === '1';
|
|
68
|
+
const chapters = await getCollection('chapters', (entry) =>
|
|
69
|
+
includeDrafts || !entry.data.draft
|
|
70
|
+
);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then run `BOOK_INCLUDE_DRAFTS=1 npm run dev` for the draft-inclusive preview. This is NOT shipped in the scaffold's auto-route (would muddy the production behavior); add it to a consumer-owned override file if you need it.
|
|
74
|
+
|
|
75
|
+
## Common gotchas
|
|
76
|
+
|
|
77
|
+
- **Silent zero-chapters build** — if EVERY chapter has `draft: true` (or no chapters are published yet), `/chapters/` renders an empty list and no `/chapters/<slug>/` routes generate. Build succeeds with no warnings. Diagnosis: check `npx book-scaffold validate` output — it reports the total `chapter(s) checked` regardless of draft status, so you'll see "5 chapter(s) checked" even when no chapters render.
|
|
78
|
+
- **Author's intent vs filter behavior** — early Phase-1 chapter authoring sessions sometimes catch this: chapter is fully written, frontmatter looks right, but it doesn't render. First check: `grep '^draft:' src/content/chapters/*.mdx` to find drafts. Time-to-diagnosis tax averages ~20 min per consumer per session until they remember the filter exists. This recipe is the discoverability fix.
|
|
79
|
+
|
|
80
|
+
## See also
|
|
81
|
+
|
|
82
|
+
- `recipes/01-create-book.md` — full scaffold getting-started flow
|
|
83
|
+
- `recipes/09-validation.md` — `book-scaffold validate` semantics (which DON'T honor the draft filter)
|
|
84
|
+
- `PACKAGE_DESIGN.md §5` — `defineBookSchemas` API (where the draft field lives)
|
|
85
|
+
|
|
86
|
+
## Feedback
|
|
87
|
+
|
|
88
|
+
If the filter behavior surprises you in a new way or you want a different draft-preview UX (e.g., a `BOOK_INCLUDE_DRAFTS` env in the scaffold's auto-route by default), file an issue at https://github.com/brandon-behring/book-scaffold-astro/issues with the `consumer:<workspace>` label.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/build-exercises.mjs — emit src/data/exercises.json from <Exercise>
|
|
4
|
+
* instances in chapter MDX (v4.4.0, closes v4.3.0 backlog).
|
|
5
|
+
*
|
|
6
|
+
* Sister script to build-tips.mjs (v4.3.0). Scans chapters honoring
|
|
7
|
+
* loader.base override (via readChaptersBase from walk-mdx.mjs). Extracts
|
|
8
|
+
* <Exercise id="X">body</Exercise> via 4-branch regex (same quote-style
|
|
9
|
+
* portability lesson as build-tips). Emits a chapter-keyed object so the
|
|
10
|
+
* /exercises route + <ExerciseSolutions auto> can scope by chapter.
|
|
11
|
+
*
|
|
12
|
+
* Output shape:
|
|
13
|
+
* {
|
|
14
|
+
* "ch1-foundations": [
|
|
15
|
+
* { "id": "ex-1", "problem": "Given..." },
|
|
16
|
+
* { "id": "ex-2", "problem": "Refactor..." }
|
|
17
|
+
* ],
|
|
18
|
+
* ...
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* Graceful no-op: if no <Exercise> instances exist, writes {} (doesn't
|
|
22
|
+
* fail builds for consumers who don't use the feature).
|
|
23
|
+
*/
|
|
24
|
+
import { writeFile, mkdir, readFile } from 'node:fs/promises';
|
|
25
|
+
import { resolve, dirname, basename } from 'node:path';
|
|
26
|
+
import { walkMdx, readChaptersBase } from './walk-mdx.mjs';
|
|
27
|
+
|
|
28
|
+
const USAGE = `Usage: book-scaffold build-exercises
|
|
29
|
+
|
|
30
|
+
Scan chapter MDX for <Exercise id="X">body</Exercise> occurrences; emit
|
|
31
|
+
src/data/exercises.json keyed by chapter slug. Used by the /exercises
|
|
32
|
+
auto-route + <ExerciseSolutions auto> mode when routes.exercises: true.
|
|
33
|
+
|
|
34
|
+
Env:
|
|
35
|
+
BOOK_CHAPTERS_DIR Override chapters dir (default: src/content/chapters).
|
|
36
|
+
BOOK_EXERCISES_OUT Override output path (default: src/data/exercises.json).
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--help, -h Print this message and exit (non-mutating).
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
43
|
+
process.stdout.write(USAGE);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CWD = process.cwd();
|
|
48
|
+
const CHAPTERS_DIR = await readChaptersBase(CWD);
|
|
49
|
+
const OUTPUT_PATH = process.env.BOOK_EXERCISES_OUT ?? 'src/data/exercises.json';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extract <Exercise id="..."> tags and their body content from MDX.
|
|
53
|
+
*
|
|
54
|
+
* Returns array of { id, problem }. Problem is the full body content
|
|
55
|
+
* (trimmed + whitespace-normalized); the /exercises route + ExerciseSolutions
|
|
56
|
+
* auto truncate for display. Full content kept so consumers using
|
|
57
|
+
* `<ExerciseSolutions auto />` see the entire problem statement.
|
|
58
|
+
*
|
|
59
|
+
* Limitations (same as build-tips):
|
|
60
|
+
* - Doesn't handle nested <Exercise> tags
|
|
61
|
+
* - String attributes must use single OR double quotes (not template literals)
|
|
62
|
+
* - id may not contain a literal quote of the same type used as delimiter
|
|
63
|
+
*/
|
|
64
|
+
function extractExercises(source) {
|
|
65
|
+
const exercises = [];
|
|
66
|
+
// 4-branch alternation (single/single, double/double, mixed) — no
|
|
67
|
+
// backreference for cross-runtime regex portability (v4.1.2 lesson).
|
|
68
|
+
// Since Exercise has only 1 attribute (id), we just need 2 branches.
|
|
69
|
+
const re = new RegExp(
|
|
70
|
+
[
|
|
71
|
+
`<Exercise\\s+id="([^"]+)"\\s*>([\\s\\S]*?)</Exercise>`,
|
|
72
|
+
`<Exercise\\s+id='([^']+)'\\s*>([\\s\\S]*?)</Exercise>`,
|
|
73
|
+
].join('|'),
|
|
74
|
+
'g',
|
|
75
|
+
);
|
|
76
|
+
for (const match of source.matchAll(re)) {
|
|
77
|
+
const [, id1, body1, id2, body2] = match;
|
|
78
|
+
const id = id1 || id2;
|
|
79
|
+
const body = body1 || body2 || '';
|
|
80
|
+
if (!id) continue;
|
|
81
|
+
const problem = body.trim().replace(/\s+/g, ' ');
|
|
82
|
+
exercises.push({ id, problem });
|
|
83
|
+
}
|
|
84
|
+
return exercises;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
const byChapter = {};
|
|
89
|
+
let totalExercises = 0;
|
|
90
|
+
let chaptersWithExercises = 0;
|
|
91
|
+
|
|
92
|
+
for await (const rel of walkMdx(CHAPTERS_DIR)) {
|
|
93
|
+
const chapterPath = resolve(CHAPTERS_DIR, rel);
|
|
94
|
+
const chapterSlug = basename(rel).replace(/\.mdx?$/, '');
|
|
95
|
+
let source;
|
|
96
|
+
try {
|
|
97
|
+
source = await readFile(chapterPath, 'utf8');
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const exercises = extractExercises(source);
|
|
102
|
+
if (exercises.length > 0) {
|
|
103
|
+
byChapter[chapterSlug] = exercises;
|
|
104
|
+
totalExercises += exercises.length;
|
|
105
|
+
chaptersWithExercises += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const outPath = resolve(CWD, OUTPUT_PATH);
|
|
110
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
111
|
+
await writeFile(outPath, JSON.stringify(byChapter, null, 2) + '\n');
|
|
112
|
+
process.stdout.write(
|
|
113
|
+
`build-exercises: ${totalExercises} exercise${totalExercises === 1 ? '' : 's'} across ` +
|
|
114
|
+
`${chaptersWithExercises} chapter${chaptersWithExercises === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main().catch((err) => {
|
|
119
|
+
console.error('build-exercises: failed');
|
|
120
|
+
console.error(err.message ?? err);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Export for tests.
|
|
125
|
+
export { extractExercises };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/build-tips.mjs — emit src/data/tips.json from <Tip> instances
|
|
4
|
+
* in chapter MDX (v4.3.0, closes #70).
|
|
5
|
+
*
|
|
6
|
+
* Scans `src/content/chapters/**\/*.mdx` (honoring loader.base via
|
|
7
|
+
* readChaptersBase from walk-mdx.mjs — same path-resolution as build-labels +
|
|
8
|
+
* validate). Extracts `<Tip n="N" title="T">body</Tip>` occurrences via regex
|
|
9
|
+
* (same approach as build-labels.mjs LABELABLE_TYPES extraction). Emits an
|
|
10
|
+
* array sorted by n.
|
|
11
|
+
*
|
|
12
|
+
* Output shape:
|
|
13
|
+
* [
|
|
14
|
+
* { "n": 1, "title": "...", "chapter": "ch-slug", "preview": "first 80 chars" },
|
|
15
|
+
* ...
|
|
16
|
+
* ]
|
|
17
|
+
*
|
|
18
|
+
* Graceful no-op: if no <Tip> instances exist, writes [] (doesn't fail
|
|
19
|
+
* builds for consumers who don't use the feature).
|
|
20
|
+
*
|
|
21
|
+
* Run on `prebuild` via the consumer's package.json. Doesn't depend on
|
|
22
|
+
* Astro virtual modules — pure regex + Node fs.
|
|
23
|
+
*/
|
|
24
|
+
import { writeFile, mkdir, readFile } from 'node:fs/promises';
|
|
25
|
+
import { resolve, dirname, basename } from 'node:path';
|
|
26
|
+
import { walkMdx, readChaptersBase } from './walk-mdx.mjs';
|
|
27
|
+
|
|
28
|
+
const USAGE = `Usage: book-scaffold build-tips
|
|
29
|
+
|
|
30
|
+
Scan chapter MDX for <Tip n="N" title="T">body</Tip> occurrences; emit
|
|
31
|
+
src/data/tips.json sorted by n. Used by the /tips auto-route + <TipsCard>
|
|
32
|
+
component when routes.tips: true.
|
|
33
|
+
|
|
34
|
+
Env:
|
|
35
|
+
BOOK_CHAPTERS_DIR Override chapters dir (default: src/content/chapters).
|
|
36
|
+
BOOK_TIPS_OUT Override output path (default: src/data/tips.json).
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--help, -h Print this message and exit (non-mutating).
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
43
|
+
process.stdout.write(USAGE);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CWD = process.cwd();
|
|
48
|
+
const CHAPTERS_DIR = await readChaptersBase(CWD);
|
|
49
|
+
const OUTPUT_PATH = process.env.BOOK_TIPS_OUT ?? 'src/data/tips.json';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extract <Tip n="..." title="..."> tags and their body content from MDX.
|
|
53
|
+
*
|
|
54
|
+
* Regex captures:
|
|
55
|
+
* - n: the tip number (string; coerced to int when writing JSON)
|
|
56
|
+
* - title: the tip title (string)
|
|
57
|
+
* - body: text between opening + closing tags
|
|
58
|
+
*
|
|
59
|
+
* Limitations (deliberate, documented):
|
|
60
|
+
* - Doesn't handle nested <Tip> tags (no real use case)
|
|
61
|
+
* - String attributes must use single OR double quotes (not template literals)
|
|
62
|
+
* - title may not contain a literal quote of the same type used as delimiter
|
|
63
|
+
* (escaping isn't supported)
|
|
64
|
+
* - body is captured raw; first 80 chars (ignoring leading whitespace) used as preview
|
|
65
|
+
*/
|
|
66
|
+
function extractTips(source, chapterSlug) {
|
|
67
|
+
const tips = [];
|
|
68
|
+
// Two-branch alternation (single OR double quotes), no backreference —
|
|
69
|
+
// same portability pattern as readChaptersBase regex (v4.1.2 lesson).
|
|
70
|
+
const re = new RegExp(
|
|
71
|
+
[
|
|
72
|
+
// double-quoted attrs
|
|
73
|
+
`<Tip\\s+n="([^"]+)"\\s+title="([^"]+)"\\s*>([\\s\\S]*?)</Tip>`,
|
|
74
|
+
// single-quoted attrs
|
|
75
|
+
`<Tip\\s+n='([^']+)'\\s+title='([^']+)'\\s*>([\\s\\S]*?)</Tip>`,
|
|
76
|
+
// mixed (n double, title single) — rare but support it
|
|
77
|
+
`<Tip\\s+n="([^"]+)"\\s+title='([^']+)'\\s*>([\\s\\S]*?)</Tip>`,
|
|
78
|
+
// mixed (n single, title double)
|
|
79
|
+
`<Tip\\s+n='([^']+)'\\s+title="([^"]+)"\\s*>([\\s\\S]*?)</Tip>`,
|
|
80
|
+
].join('|'),
|
|
81
|
+
'g',
|
|
82
|
+
);
|
|
83
|
+
for (const match of source.matchAll(re)) {
|
|
84
|
+
// One of the 4 alternation branches matched; locate captures.
|
|
85
|
+
const [, n1, t1, b1, n2, t2, b2, n3, t3, b3, n4, t4, b4] = match;
|
|
86
|
+
const n = n1 || n2 || n3 || n4;
|
|
87
|
+
const title = t1 || t2 || t3 || t4;
|
|
88
|
+
const body = b1 || b2 || b3 || b4 || '';
|
|
89
|
+
if (!n || !title) continue;
|
|
90
|
+
const nNum = Number.parseInt(n, 10);
|
|
91
|
+
if (!Number.isFinite(nNum)) continue;
|
|
92
|
+
const preview = body
|
|
93
|
+
.trim()
|
|
94
|
+
.replace(/\s+/g, ' ')
|
|
95
|
+
.slice(0, 80);
|
|
96
|
+
tips.push({
|
|
97
|
+
n: nNum,
|
|
98
|
+
title,
|
|
99
|
+
chapter: chapterSlug,
|
|
100
|
+
preview,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return tips;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function main() {
|
|
107
|
+
const allTips = [];
|
|
108
|
+
for await (const rel of walkMdx(CHAPTERS_DIR)) {
|
|
109
|
+
const chapterPath = resolve(CHAPTERS_DIR, rel);
|
|
110
|
+
const chapterSlug = basename(rel).replace(/\.mdx?$/, '');
|
|
111
|
+
let source;
|
|
112
|
+
try {
|
|
113
|
+
source = await readFile(chapterPath, 'utf8');
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
allTips.push(...extractTips(source, chapterSlug));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Sort by n; warn on duplicates (don't fail — the index page just shows duplicates).
|
|
121
|
+
allTips.sort((a, b) => a.n - b.n);
|
|
122
|
+
const seenN = new Set();
|
|
123
|
+
for (const tip of allTips) {
|
|
124
|
+
if (seenN.has(tip.n)) {
|
|
125
|
+
process.stderr.write(`build-tips: WARN duplicate Tip n="${tip.n}" (last wins on /tips index)\n`);
|
|
126
|
+
}
|
|
127
|
+
seenN.add(tip.n);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const outPath = resolve(CWD, OUTPUT_PATH);
|
|
131
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
132
|
+
await writeFile(outPath, JSON.stringify(allTips, null, 2) + '\n');
|
|
133
|
+
process.stdout.write(`build-tips: ${allTips.length} tip${allTips.length === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
main().catch((err) => {
|
|
137
|
+
console.error('build-tips: failed');
|
|
138
|
+
console.error(err.message ?? err);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Export for tests.
|
|
143
|
+
export { extractTips };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/lib/define-tips.ts — `defineTips()` API for cross-volume tip registry
|
|
3
|
+
* (v4.3.0, closes #70).
|
|
4
|
+
*
|
|
5
|
+
* Pragmatic Programmer-style numbered tips can be distributed across multiple
|
|
6
|
+
* volumes (e.g., Handbook tips 1-25, Architect's Reference 26-40, Field-Guide
|
|
7
|
+
* 41-50). Authors write `<Tip n="14" ...>` with explicit numbers; defineTips()
|
|
8
|
+
* lets per-volume books offset their displayed numbers + label without
|
|
9
|
+
* renumbering source tags.
|
|
10
|
+
*
|
|
11
|
+
* Branded type follows the same convention as `defineStyle` (v4.0.0 D6):
|
|
12
|
+
* type-only `unique symbol` brand, closed shape, readonly fields, no public
|
|
13
|
+
* index signature. Consumer-side metadata goes in scoped `extra` if needed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ===== Branded nominal type =====
|
|
17
|
+
|
|
18
|
+
declare const TipsConfigBrand: unique symbol;
|
|
19
|
+
|
|
20
|
+
export interface TipsConfig {
|
|
21
|
+
/** Type-only brand for nominal typing. Set automatically by defineTips. */
|
|
22
|
+
readonly [TipsConfigBrand]: true;
|
|
23
|
+
/** Internal version marker; auto-set to 1 by defineTips. */
|
|
24
|
+
readonly __tipsConfigVersion: 1;
|
|
25
|
+
/** Display offset added to each `<Tip n="N">` for cross-volume coordination.
|
|
26
|
+
* Example: Vol B with volumeOffset=25 renders `<Tip n="1">` as "Tip 26". */
|
|
27
|
+
readonly volumeOffset?: number;
|
|
28
|
+
/** Optional label shown alongside tip numbers in the /tips index + TipsCard.
|
|
29
|
+
* Example: "Vol B" → "Vol B Tip 26". */
|
|
30
|
+
readonly volumeLabel?: string;
|
|
31
|
+
/** Scoped consumer-side metadata (matches defineStyle pattern). */
|
|
32
|
+
readonly extra?: Readonly<Record<string, unknown>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Input type for defineTips — omits the auto-set internal fields. */
|
|
36
|
+
export type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVersion'>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Identity helper that creates a typed, branded TipsConfig.
|
|
40
|
+
* Zero runtime overhead beyond an object spread + version marker.
|
|
41
|
+
*
|
|
42
|
+
* Usage:
|
|
43
|
+
*
|
|
44
|
+
* import { defineTips } from '@brandon_m_behring/book-scaffold-astro';
|
|
45
|
+
*
|
|
46
|
+
* export const tipsConfig = defineTips({
|
|
47
|
+
* volumeOffset: 25,
|
|
48
|
+
* volumeLabel: 'Vol B',
|
|
49
|
+
* });
|
|
50
|
+
*
|
|
51
|
+
* Consumed by `<Tip>` and `<TipsCard>` components + the auto-injected
|
|
52
|
+
* `/tips` route to compute display numbers from `<Tip n="N">` source tags.
|
|
53
|
+
*/
|
|
54
|
+
export function defineTips(opts: TipsConfigInput): TipsConfig {
|
|
55
|
+
return { __tipsConfigVersion: 1, ...opts } as TipsConfig;
|
|
56
|
+
}
|
package/src/profile-kit.ts
CHANGED
|
@@ -49,6 +49,22 @@ export interface RouteToggles {
|
|
|
49
49
|
* If enabled without defining the collection, Astro errors clearly at build.
|
|
50
50
|
*/
|
|
51
51
|
frontmatter: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* v4.3.0 (closes #70): auto-inject `/tips` route listing all numbered
|
|
54
|
+
* `<Tip>` instances from chapter MDX. Requires running
|
|
55
|
+
* `book-scaffold build-tips` (via prebuild) which emits src/data/tips.json.
|
|
56
|
+
* Default `false` per profile — opt in via
|
|
57
|
+
* defineBookConfig({ routes: { tips: true } }).
|
|
58
|
+
*/
|
|
59
|
+
tips: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* v4.4.0: auto-inject `/exercises` route listing all `<Exercise id="...">`
|
|
62
|
+
* instances from chapter MDX, grouped by chapter with deep links into
|
|
63
|
+
* the chapter routes. Requires running `book-scaffold build-exercises`
|
|
64
|
+
* (via prebuild) which emits src/data/exercises.json. Default `false` per
|
|
65
|
+
* profile — opt in via defineBookConfig({ routes: { exercises: true } }).
|
|
66
|
+
*/
|
|
67
|
+
exercises: boolean;
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
/** Profile definition — declarative shape for one book profile. */
|
package/src/profiles/academic.ts
CHANGED
|
@@ -23,6 +23,8 @@ export const academicProfile = defineProfile({
|
|
|
23
23
|
chapters: false, // academic consumers ship their own week-based /chapters listing
|
|
24
24
|
convergence: false, // tools-profile-specific
|
|
25
25
|
frontmatter: false, // opt-in per book; see #7
|
|
26
|
+
tips: false, // v4.3.0 #70: opt-in per book; requires build-tips
|
|
27
|
+
exercises: false, // v4.4.0: opt-in per book; requires build-exercises
|
|
26
28
|
},
|
|
27
29
|
styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
|
|
28
30
|
katex: true,
|
|
@@ -25,6 +25,8 @@ export const courseNotesProfile = defineProfile({
|
|
|
25
25
|
chapters: false, // multi-book consumers route via [book]/[slug] themselves
|
|
26
26
|
convergence: false,
|
|
27
27
|
frontmatter: false, // opt-in per book; see #7
|
|
28
|
+
tips: false, // v4.3.0 #70: opt-in per book
|
|
29
|
+
exercises: false, // v4.4.0: opt-in per book
|
|
28
30
|
},
|
|
29
31
|
styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
|
|
30
32
|
// v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
|
package/src/profiles/minimal.ts
CHANGED
|
@@ -20,6 +20,8 @@ export const minimalProfile = defineProfile({
|
|
|
20
20
|
chapters: false,
|
|
21
21
|
convergence: false,
|
|
22
22
|
frontmatter: false, // opt-in per book; see #7
|
|
23
|
+
tips: false, // v4.3.0 #70: opt-in per book
|
|
24
|
+
exercises: false, // v4.4.0: opt-in per book
|
|
23
25
|
},
|
|
24
26
|
styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
|
|
25
27
|
// v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
|
|
@@ -36,6 +36,8 @@ export const researchPortfolioProfile = defineProfile({
|
|
|
36
36
|
chapters: false, // portfolio books ship their own landing/index
|
|
37
37
|
convergence: false, // tools-profile-specific
|
|
38
38
|
frontmatter: true, // portfolios universally need title/disclosure/banner pages
|
|
39
|
+
tips: false, // v4.3.0 #70: opt-in per book
|
|
40
|
+
exercises: false, // v4.4.0: opt-in per book
|
|
39
41
|
},
|
|
40
42
|
styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
|
|
41
43
|
katex: true, // math is common in research content
|
package/src/profiles/tools.ts
CHANGED
|
@@ -20,6 +20,8 @@ export const toolsProfile = defineProfile({
|
|
|
20
20
|
chapters: true, // tools profile ships a flat chapter index
|
|
21
21
|
convergence: true, // tools profile ships convergence dashboard
|
|
22
22
|
frontmatter: false, // opt-in per book; see #7
|
|
23
|
+
tips: false, // v4.3.0 #70: opt-in per book
|
|
24
|
+
exercises: false, // v4.4.0: opt-in per book
|
|
23
25
|
},
|
|
24
26
|
styles: [
|
|
25
27
|
'tokens.css', 'layout.css', 'callouts.css', 'chapter.css',
|