@brandon_m_behring/book-scaffold-astro 4.2.0 → 4.3.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 +3 -1
- package/components/Exercise.astro +24 -0
- package/components/ExerciseSolutions.astro +22 -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 +35 -5
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +15 -5
- package/package.json +7 -1
- package/pages/chapters/[...slug].astro +40 -0
- package/pages/chapters.astro +1 -1
- package/pages/tips.astro +51 -0
- package/recipes/17-draft-chapter-workflow.md +88 -0
- package/scripts/build-tips.mjs +143 -0
- package/src/lib/define-tips.ts +56 -0
- package/src/profile-kit.ts +8 -0
- package/src/profiles/academic.ts +1 -0
- package/src/profiles/course-notes.ts +1 -0
- package/src/profiles/minimal.ts +1 -0
- package/src/profiles/research-portfolio.ts +1 -0
- package/src/profiles/tools.ts +1 -0
- package/styles/callouts.css +145 -0
package/bin/book-scaffold.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const handlers = {
|
|
|
17
17
|
'build-labels': '../scripts/build-labels.mjs',
|
|
18
18
|
'build-bib': '../scripts/build-bib.mjs',
|
|
19
19
|
'build-figures': '../scripts/build-figures.mjs',
|
|
20
|
+
'build-tips': '../scripts/build-tips.mjs',
|
|
20
21
|
'render-notebooks': '../scripts/render-notebooks.mjs',
|
|
21
22
|
};
|
|
22
23
|
|
|
@@ -26,7 +27,8 @@ Sub-commands:
|
|
|
26
27
|
validate Pre-flight content validator (XRef ids, Cite keys, Figure srcs).
|
|
27
28
|
build-labels Emit src/data/labels.json for cross-references (Phase C).
|
|
28
29
|
build-bib BibTeX -> CSL JSON for the <Cite> component.
|
|
29
|
-
build-figures PDF -> SVG via pdftocairo / pdftoppm fallback.
|
|
30
|
+
build-figures PDF -> SVG via pdftocairo / pdftoppm fallback (+ TikZ in v4.2.0).
|
|
31
|
+
build-tips Scan chapters for <Tip> instances; emit src/data/tips.json (v4.3.0).
|
|
30
32
|
render-notebooks ipynb -> HTML via Jupyter nbconvert.
|
|
31
33
|
|
|
32
34
|
--help, -h This message.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Exercise — inline at concept introduction per CS:APP precedent
|
|
4
|
+
* (v4.3.0, closes #71).
|
|
5
|
+
*
|
|
6
|
+
* Reader is expected to attempt while reading. Solutions live at the
|
|
7
|
+
* chapter end via id reference (paired via <Solution for="{id}"> inside
|
|
8
|
+
* <ExerciseSolutions>; manual pairing — no auto-collection).
|
|
9
|
+
*
|
|
10
|
+
* Light treatment: border-left in neutral color + "Exercise" label +
|
|
11
|
+
* anchor id (`#exercise-{id}`) for cross-linking from <Solution>.
|
|
12
|
+
*
|
|
13
|
+
* Family: book-genre (cross-profile).
|
|
14
|
+
*/
|
|
15
|
+
interface Props {
|
|
16
|
+
id: string;
|
|
17
|
+
}
|
|
18
|
+
const { id } = Astro.props;
|
|
19
|
+
const anchorId = `exercise-${id}`;
|
|
20
|
+
---
|
|
21
|
+
<aside class="exercise" id={anchorId} role="note">
|
|
22
|
+
<strong class="exercise-label">Exercise</strong>
|
|
23
|
+
<div class="exercise-body"><slot /></div>
|
|
24
|
+
</aside>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ExerciseSolutions — chapter-end wrapper that provides the section
|
|
4
|
+
* heading + container for <Solution> elements (v4.3.0, closes #71).
|
|
5
|
+
*
|
|
6
|
+
* Author places this at chapter end and fills it with <Solution for="...">
|
|
7
|
+
* elements. The wrapper provides:
|
|
8
|
+
* - <h2>Exercise solutions</h2> heading
|
|
9
|
+
* - Container styling (separates the section visually from preceding prose)
|
|
10
|
+
* - Anchor (`#exercise-solutions`) so the section is linkable
|
|
11
|
+
*
|
|
12
|
+
* v4.3.0 design: manual <Solution> placement (no auto-collection of
|
|
13
|
+
* <Exercise> content). Auto-collection deferred to v4.4.0+ if real
|
|
14
|
+
* demand surfaces — would require MDX AST traversal at build time.
|
|
15
|
+
*
|
|
16
|
+
* Family: book-genre (cross-profile).
|
|
17
|
+
*/
|
|
18
|
+
---
|
|
19
|
+
<section class="exercise-solutions" id="exercise-solutions">
|
|
20
|
+
<h2 class="exercise-solutions-heading">Exercise solutions</h2>
|
|
21
|
+
<div class="exercise-solutions-body"><slot /></div>
|
|
22
|
+
</section>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Practice — end-of-chapter problem with difficulty marker per CS:APP
|
|
4
|
+
* (v4.3.0, closes #71).
|
|
5
|
+
*
|
|
6
|
+
* Difficulty 1-4 rendered as ◆ count (filled diamonds out of 4). No
|
|
7
|
+
* inline solutions; instructor-manual style — separate from Exercise
|
|
8
|
+
* which has solutions at chapter end.
|
|
9
|
+
*
|
|
10
|
+
* Anchor id (`#practice-{id}`) for cross-references.
|
|
11
|
+
*
|
|
12
|
+
* Family: book-genre (cross-profile).
|
|
13
|
+
*/
|
|
14
|
+
type Difficulty = '1' | '2' | '3' | '4';
|
|
15
|
+
interface Props {
|
|
16
|
+
id: string;
|
|
17
|
+
difficulty: Difficulty;
|
|
18
|
+
}
|
|
19
|
+
const { id, difficulty } = Astro.props;
|
|
20
|
+
const anchorId = `practice-${id}`;
|
|
21
|
+
const filled = Number.parseInt(difficulty, 10);
|
|
22
|
+
const markers = '◆'.repeat(filled) + '◇'.repeat(4 - filled);
|
|
23
|
+
---
|
|
24
|
+
<aside class="practice" id={anchorId} role="note">
|
|
25
|
+
<div class="practice-header">
|
|
26
|
+
<strong class="practice-label">Practice</strong>
|
|
27
|
+
<span class="practice-difficulty" aria-label={`Difficulty ${difficulty} of 4`}>{markers}</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="practice-body"><slot /></div>
|
|
30
|
+
</aside>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Solution — paired by id with an <Exercise> at chapter end
|
|
4
|
+
* (v4.3.0, closes #71).
|
|
5
|
+
*
|
|
6
|
+
* Author writes `<Solution for="ch1-ex-2">solution text</Solution>` inside
|
|
7
|
+
* an <ExerciseSolutions> wrapper. The `for` attribute matches the
|
|
8
|
+
* corresponding <Exercise>'s `id`; the rendered solution links back to
|
|
9
|
+
* the inline exercise via `#exercise-{for}`.
|
|
10
|
+
*
|
|
11
|
+
* Manual pairing — no build-time auto-collection of Exercise content
|
|
12
|
+
* (deferred to v4.4.0+). Author maintains the id↔for mapping by hand.
|
|
13
|
+
*
|
|
14
|
+
* Family: book-genre (cross-profile).
|
|
15
|
+
*/
|
|
16
|
+
interface Props {
|
|
17
|
+
for: string;
|
|
18
|
+
}
|
|
19
|
+
const { for: forId } = Astro.props;
|
|
20
|
+
---
|
|
21
|
+
<div class="solution" id={`solution-${forId}`}>
|
|
22
|
+
<div class="solution-header">
|
|
23
|
+
<strong class="solution-label">Solution</strong>
|
|
24
|
+
<a href={`#exercise-${forId}`} class="solution-backlink">↑ Exercise</a>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="solution-body"><slot /></div>
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Tip — numbered cross-volume tip per Pragmatic Programmer precedent
|
|
4
|
+
* (v4.3.0, closes #70).
|
|
5
|
+
*
|
|
6
|
+
* Author writes `<Tip n="14" title="Care About Your Craft">rule statement</Tip>`.
|
|
7
|
+
* Number is explicit (registry doesn't auto-number). Body slot is the
|
|
8
|
+
* pull-quotable single-sentence rule.
|
|
9
|
+
*
|
|
10
|
+
* Gold border (reuses --callout-insight); distinctive single-line layout
|
|
11
|
+
* + anchor id (`#tip-{n}`) for cross-referencing from later chapters.
|
|
12
|
+
*
|
|
13
|
+
* Family: book-genre (cross-profile; usable from any preset).
|
|
14
|
+
*/
|
|
15
|
+
interface Props {
|
|
16
|
+
n: string | number;
|
|
17
|
+
title: string;
|
|
18
|
+
}
|
|
19
|
+
const { n, title } = Astro.props;
|
|
20
|
+
const anchorId = `tip-${n}`;
|
|
21
|
+
---
|
|
22
|
+
<aside class="callout callout-tip-numbered" id={anchorId} role="note">
|
|
23
|
+
<div class="callout-tip-header">
|
|
24
|
+
<span class="callout-tip-number">Tip {n}</span>
|
|
25
|
+
<strong class="callout-tip-title">{title}</strong>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="callout-body"><slot /></div>
|
|
28
|
+
</aside>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* TipsCard — print-friendly pull-out card listing all numbered tips
|
|
4
|
+
* (v4.3.0, closes #70).
|
|
5
|
+
*
|
|
6
|
+
* Reads `src/data/tips.json` (emitted by `book-scaffold build-tips`).
|
|
7
|
+
* Author places it on a back-matter print route (e.g., `/print-tips`).
|
|
8
|
+
*
|
|
9
|
+
* Renders a single-column ordered list with page-break-inside: avoid
|
|
10
|
+
* so the card prints cleanly on one or more dedicated pages.
|
|
11
|
+
*
|
|
12
|
+
* Graceful skip: if tips.json doesn't exist or is empty, renders an
|
|
13
|
+
* empty container with a note (doesn't fail the build). Consumers who
|
|
14
|
+
* don't use the tips feature can include the component without prep.
|
|
15
|
+
*/
|
|
16
|
+
let tips: Array<{ n: number; title: string; chapter: string; preview: string }> = [];
|
|
17
|
+
try {
|
|
18
|
+
const mod = await import('../../../src/data/tips.json', { with: { type: 'json' } });
|
|
19
|
+
tips = (mod.default ?? []) as typeof tips;
|
|
20
|
+
} catch {
|
|
21
|
+
// tips.json not generated yet — render empty card with hint.
|
|
22
|
+
}
|
|
23
|
+
---
|
|
24
|
+
<aside class="tips-card" role="contentinfo">
|
|
25
|
+
<h2 class="tips-card-title">Tips</h2>
|
|
26
|
+
{tips.length === 0 && (
|
|
27
|
+
<p class="tips-card-empty">
|
|
28
|
+
No tips found. Run <code>book-scaffold build-tips</code> to generate
|
|
29
|
+
<code>src/data/tips.json</code> from <code><Tip></code> instances in chapters.
|
|
30
|
+
</p>
|
|
31
|
+
)}
|
|
32
|
+
{tips.length > 0 && (
|
|
33
|
+
<ol class="tips-card-list">
|
|
34
|
+
{tips.map((tip) => (
|
|
35
|
+
<li class="tips-card-item">
|
|
36
|
+
<a href={`/tips#tip-${tip.n}`} class="tips-card-link">
|
|
37
|
+
<span class="tips-card-number">{tip.n}.</span>
|
|
38
|
+
<strong class="tips-card-rule">{tip.title}</strong>
|
|
39
|
+
</a>
|
|
40
|
+
</li>
|
|
41
|
+
))}
|
|
42
|
+
</ol>
|
|
43
|
+
)}
|
|
44
|
+
</aside>
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AstroUserConfig, AstroIntegration } from 'astro';
|
|
2
|
-
import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Q as volatilityLevels, h as ChaptersRenderer, n as Style } from './types-
|
|
3
|
-
export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, R as ResearchPortfolioChapter, m as RouteToggles, S as StatusBadge, o as StyleInput, T as ToolsChapter, V as VolatilityBadge, p as academicChapterSchema, q as academicParts, r as changeKinds, s as changelogSchema, t as chapterStatus, u as composeStyles, v as courseNotesChapterSchema, w as defineProfile, x as defineStyle, y as minimalChapterSchema, z as normalizeFrontmatterConfig, D as patternCategories, E as patternsSchema, G as researchPortfolioChapterSchema, H as resolvePreset, I as resolveProfile, J as sourceTiers, K as sourceTiersResearch, L as sourcesSchema, N as toolSlugs, O as toolsChapterSchema } from './types-
|
|
2
|
+
import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Q as volatilityLevels, h as ChaptersRenderer, n as Style } from './types-B8Js3qF0.js';
|
|
3
|
+
export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, R as ResearchPortfolioChapter, m as RouteToggles, S as StatusBadge, o as StyleInput, T as ToolsChapter, V as VolatilityBadge, p as academicChapterSchema, q as academicParts, r as changeKinds, s as changelogSchema, t as chapterStatus, u as composeStyles, v as courseNotesChapterSchema, w as defineProfile, x as defineStyle, y as minimalChapterSchema, z as normalizeFrontmatterConfig, D as patternCategories, E as patternsSchema, G as researchPortfolioChapterSchema, H as resolvePreset, I as resolveProfile, J as sourceTiers, K as sourceTiersResearch, L as sourcesSchema, N as toolSlugs, O as toolsChapterSchema } from './types-B8Js3qF0.js';
|
|
4
4
|
import 'astro/zod';
|
|
5
5
|
|
|
6
6
|
declare function defineBookConfig(opts: BookConfigOptions): Promise<AstroUserConfig>;
|
|
@@ -195,4 +195,53 @@ declare const BUILTIN_STYLES: {
|
|
|
195
195
|
readonly 'research-portfolio': Style;
|
|
196
196
|
};
|
|
197
197
|
|
|
198
|
-
|
|
198
|
+
/**
|
|
199
|
+
* src/lib/define-tips.ts — `defineTips()` API for cross-volume tip registry
|
|
200
|
+
* (v4.3.0, closes #70).
|
|
201
|
+
*
|
|
202
|
+
* Pragmatic Programmer-style numbered tips can be distributed across multiple
|
|
203
|
+
* volumes (e.g., Handbook tips 1-25, Architect's Reference 26-40, Field-Guide
|
|
204
|
+
* 41-50). Authors write `<Tip n="14" ...>` with explicit numbers; defineTips()
|
|
205
|
+
* lets per-volume books offset their displayed numbers + label without
|
|
206
|
+
* renumbering source tags.
|
|
207
|
+
*
|
|
208
|
+
* Branded type follows the same convention as `defineStyle` (v4.0.0 D6):
|
|
209
|
+
* type-only `unique symbol` brand, closed shape, readonly fields, no public
|
|
210
|
+
* index signature. Consumer-side metadata goes in scoped `extra` if needed.
|
|
211
|
+
*/
|
|
212
|
+
declare const TipsConfigBrand: unique symbol;
|
|
213
|
+
interface TipsConfig {
|
|
214
|
+
/** Type-only brand for nominal typing. Set automatically by defineTips. */
|
|
215
|
+
readonly [TipsConfigBrand]: true;
|
|
216
|
+
/** Internal version marker; auto-set to 1 by defineTips. */
|
|
217
|
+
readonly __tipsConfigVersion: 1;
|
|
218
|
+
/** Display offset added to each `<Tip n="N">` for cross-volume coordination.
|
|
219
|
+
* Example: Vol B with volumeOffset=25 renders `<Tip n="1">` as "Tip 26". */
|
|
220
|
+
readonly volumeOffset?: number;
|
|
221
|
+
/** Optional label shown alongside tip numbers in the /tips index + TipsCard.
|
|
222
|
+
* Example: "Vol B" → "Vol B Tip 26". */
|
|
223
|
+
readonly volumeLabel?: string;
|
|
224
|
+
/** Scoped consumer-side metadata (matches defineStyle pattern). */
|
|
225
|
+
readonly extra?: Readonly<Record<string, unknown>>;
|
|
226
|
+
}
|
|
227
|
+
/** Input type for defineTips — omits the auto-set internal fields. */
|
|
228
|
+
type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVersion'>;
|
|
229
|
+
/**
|
|
230
|
+
* Identity helper that creates a typed, branded TipsConfig.
|
|
231
|
+
* Zero runtime overhead beyond an object spread + version marker.
|
|
232
|
+
*
|
|
233
|
+
* Usage:
|
|
234
|
+
*
|
|
235
|
+
* import { defineTips } from '@brandon_m_behring/book-scaffold-astro';
|
|
236
|
+
*
|
|
237
|
+
* export const tipsConfig = defineTips({
|
|
238
|
+
* volumeOffset: 25,
|
|
239
|
+
* volumeLabel: 'Vol B',
|
|
240
|
+
* });
|
|
241
|
+
*
|
|
242
|
+
* Consumed by `<Tip>` and `<TipsCard>` components + the auto-injected
|
|
243
|
+
* `/tips` route to compute display numbers from `<Tip n="N">` source tags.
|
|
244
|
+
*/
|
|
245
|
+
declare function defineTips(opts: TipsConfigInput): TipsConfig;
|
|
246
|
+
|
|
247
|
+
export { BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, type Freshness, type FreshnessStatus, Style, type TipsConfig, type TipsConfigInput, type VolatilityLevel, academicChaptersRenderer, academicStyle, bookScaffoldIntegration, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, fallbackChaptersRenderer, freshnessLabel, getFreshness, minimalStyle, researchPortfolioStyle, toolsChaptersRenderer, toolsStyle, volatilityLevels };
|
package/dist/index.mjs
CHANGED
|
@@ -376,8 +376,10 @@ var academicProfile = defineProfile({
|
|
|
376
376
|
// academic consumers ship their own week-based /chapters listing
|
|
377
377
|
convergence: false,
|
|
378
378
|
// tools-profile-specific
|
|
379
|
-
frontmatter: false
|
|
379
|
+
frontmatter: false,
|
|
380
380
|
// opt-in per book; see #7
|
|
381
|
+
tips: false
|
|
382
|
+
// v4.3.0 #70: opt-in per book; requires build-tips
|
|
381
383
|
},
|
|
382
384
|
styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
|
|
383
385
|
katex: true,
|
|
@@ -490,8 +492,10 @@ var toolsProfile = defineProfile({
|
|
|
490
492
|
// tools profile ships a flat chapter index
|
|
491
493
|
convergence: true,
|
|
492
494
|
// tools profile ships convergence dashboard
|
|
493
|
-
frontmatter: false
|
|
495
|
+
frontmatter: false,
|
|
494
496
|
// opt-in per book; see #7
|
|
497
|
+
tips: false
|
|
498
|
+
// v4.3.0 #70: opt-in per book
|
|
495
499
|
},
|
|
496
500
|
styles: [
|
|
497
501
|
"tokens.css",
|
|
@@ -568,8 +572,10 @@ var minimalProfile = defineProfile({
|
|
|
568
572
|
print: true,
|
|
569
573
|
chapters: false,
|
|
570
574
|
convergence: false,
|
|
571
|
-
frontmatter: false
|
|
575
|
+
frontmatter: false,
|
|
572
576
|
// opt-in per book; see #7
|
|
577
|
+
tips: false
|
|
578
|
+
// v4.3.0 #70: opt-in per book
|
|
573
579
|
},
|
|
574
580
|
styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
|
|
575
581
|
// v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
|
|
@@ -587,8 +593,10 @@ var courseNotesProfile = defineProfile({
|
|
|
587
593
|
chapters: false,
|
|
588
594
|
// multi-book consumers route via [book]/[slug] themselves
|
|
589
595
|
convergence: false,
|
|
590
|
-
frontmatter: false
|
|
596
|
+
frontmatter: false,
|
|
591
597
|
// opt-in per book; see #7
|
|
598
|
+
tips: false
|
|
599
|
+
// v4.3.0 #70: opt-in per book
|
|
592
600
|
},
|
|
593
601
|
styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
|
|
594
602
|
// v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
|
|
@@ -607,8 +615,10 @@ var researchPortfolioProfile = defineProfile({
|
|
|
607
615
|
// portfolio books ship their own landing/index
|
|
608
616
|
convergence: false,
|
|
609
617
|
// tools-profile-specific
|
|
610
|
-
frontmatter: true
|
|
618
|
+
frontmatter: true,
|
|
611
619
|
// portfolios universally need title/disclosure/banner pages
|
|
620
|
+
tips: false
|
|
621
|
+
// v4.3.0 #70: opt-in per book
|
|
612
622
|
},
|
|
613
623
|
styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
|
|
614
624
|
katex: true,
|
|
@@ -800,7 +810,16 @@ var ROUTE_REGISTRY = {
|
|
|
800
810
|
search: { pattern: "/search", file: "search.astro" },
|
|
801
811
|
print: { pattern: "/print", file: "print.astro" },
|
|
802
812
|
chapters: { pattern: "/chapters", file: "chapters.astro" },
|
|
813
|
+
// v4.3.0 (#69): per-chapter dynamic route auto-injected when
|
|
814
|
+
// routes.chapters: true. Mirrors the frontmatter pattern — toolkit ships
|
|
815
|
+
// BOTH the /chapters/ index AND the /chapters/<slug>/ dynamic route.
|
|
816
|
+
// Pre-v4.3.0 each consumer wrote this file by hand; all instances were
|
|
817
|
+
// mechanical copies of the same boilerplate.
|
|
818
|
+
chaptersSlug: { pattern: "/chapters/[...slug]", file: "chapters/[...slug].astro" },
|
|
803
819
|
convergence: { pattern: "/convergence", file: "convergence.astro" },
|
|
820
|
+
// v4.3.0 (#70): cross-volume numbered-tips index. Opt-in via
|
|
821
|
+
// routes.tips: true; pairs with build-tips script + <Tip> component.
|
|
822
|
+
tips: { pattern: "/tips", file: "tips.astro" },
|
|
804
823
|
// v3.4.0 (#7): consumer-collection-backed frontmatter route. Opt-in via
|
|
805
824
|
// routes: { frontmatter: true } AND content.config.ts defining the
|
|
806
825
|
// collection (use frontmatterCollection() helper from /schemas subpath).
|
|
@@ -841,8 +860,13 @@ function bookScaffoldIntegration(opts) {
|
|
|
841
860
|
if (def.katex) {
|
|
842
861
|
injectScript("page-ssr", "import 'katex/dist/katex.min.css';");
|
|
843
862
|
}
|
|
863
|
+
const routesToInject = [];
|
|
844
864
|
for (const [name, on] of Object.entries(enabledRoutes)) {
|
|
845
865
|
if (!on) continue;
|
|
866
|
+
routesToInject.push(name);
|
|
867
|
+
if (name === "chapters") routesToInject.push("chaptersSlug");
|
|
868
|
+
}
|
|
869
|
+
for (const name of routesToInject) {
|
|
846
870
|
const route = ROUTE_REGISTRY[name];
|
|
847
871
|
if (!route) continue;
|
|
848
872
|
const pattern = name === "frontmatter" ? frontmatterPatternFromPrefix(fmPrefix) : route.pattern;
|
|
@@ -1070,6 +1094,11 @@ var BUILTIN_STYLES = {
|
|
|
1070
1094
|
"course-notes": courseNotesStyle,
|
|
1071
1095
|
"research-portfolio": researchPortfolioStyle
|
|
1072
1096
|
};
|
|
1097
|
+
|
|
1098
|
+
// src/lib/define-tips.ts
|
|
1099
|
+
function defineTips(opts) {
|
|
1100
|
+
return { __tipsConfigVersion: 1, ...opts };
|
|
1101
|
+
}
|
|
1073
1102
|
export {
|
|
1074
1103
|
BOOK_PRESETS,
|
|
1075
1104
|
BOOK_PROFILES,
|
|
@@ -1091,6 +1120,7 @@ export {
|
|
|
1091
1120
|
defineMdxComponents,
|
|
1092
1121
|
defineProfile,
|
|
1093
1122
|
defineStyle,
|
|
1123
|
+
defineTips,
|
|
1094
1124
|
fallbackChaptersRenderer,
|
|
1095
1125
|
freshnessLabel,
|
|
1096
1126
|
getFreshness,
|
package/dist/schemas.d.ts
CHANGED
package/dist/schemas.mjs
CHANGED
|
@@ -261,8 +261,10 @@ var academicProfile = defineProfile({
|
|
|
261
261
|
// academic consumers ship their own week-based /chapters listing
|
|
262
262
|
convergence: false,
|
|
263
263
|
// tools-profile-specific
|
|
264
|
-
frontmatter: false
|
|
264
|
+
frontmatter: false,
|
|
265
265
|
// opt-in per book; see #7
|
|
266
|
+
tips: false
|
|
267
|
+
// v4.3.0 #70: opt-in per book; requires build-tips
|
|
266
268
|
},
|
|
267
269
|
styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
|
|
268
270
|
katex: true,
|
|
@@ -375,8 +377,10 @@ var toolsProfile = defineProfile({
|
|
|
375
377
|
// tools profile ships a flat chapter index
|
|
376
378
|
convergence: true,
|
|
377
379
|
// tools profile ships convergence dashboard
|
|
378
|
-
frontmatter: false
|
|
380
|
+
frontmatter: false,
|
|
379
381
|
// opt-in per book; see #7
|
|
382
|
+
tips: false
|
|
383
|
+
// v4.3.0 #70: opt-in per book
|
|
380
384
|
},
|
|
381
385
|
styles: [
|
|
382
386
|
"tokens.css",
|
|
@@ -453,8 +457,10 @@ var minimalProfile = defineProfile({
|
|
|
453
457
|
print: true,
|
|
454
458
|
chapters: false,
|
|
455
459
|
convergence: false,
|
|
456
|
-
frontmatter: false
|
|
460
|
+
frontmatter: false,
|
|
457
461
|
// opt-in per book; see #7
|
|
462
|
+
tips: false
|
|
463
|
+
// v4.3.0 #70: opt-in per book
|
|
458
464
|
},
|
|
459
465
|
styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
|
|
460
466
|
// v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
|
|
@@ -472,8 +478,10 @@ var courseNotesProfile = defineProfile({
|
|
|
472
478
|
chapters: false,
|
|
473
479
|
// multi-book consumers route via [book]/[slug] themselves
|
|
474
480
|
convergence: false,
|
|
475
|
-
frontmatter: false
|
|
481
|
+
frontmatter: false,
|
|
476
482
|
// opt-in per book; see #7
|
|
483
|
+
tips: false
|
|
484
|
+
// v4.3.0 #70: opt-in per book
|
|
477
485
|
},
|
|
478
486
|
styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
|
|
479
487
|
// v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
|
|
@@ -492,8 +500,10 @@ var researchPortfolioProfile = defineProfile({
|
|
|
492
500
|
// portfolio books ship their own landing/index
|
|
493
501
|
convergence: false,
|
|
494
502
|
// tools-profile-specific
|
|
495
|
-
frontmatter: true
|
|
503
|
+
frontmatter: true,
|
|
496
504
|
// portfolios universally need title/disclosure/banner pages
|
|
505
|
+
tips: false
|
|
506
|
+
// v4.3.0 #70: opt-in per book
|
|
497
507
|
},
|
|
498
508
|
styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
|
|
499
509
|
katex: true,
|
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": "4.
|
|
4
|
+
"version": "4.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Brandon Behring",
|
|
@@ -57,6 +57,8 @@
|
|
|
57
57
|
"./components/Divergence.astro": "./components/Divergence.astro",
|
|
58
58
|
"./components/DynConnect.astro": "./components/DynConnect.astro",
|
|
59
59
|
"./components/ExampleBox.astro": "./components/ExampleBox.astro",
|
|
60
|
+
"./components/Exercise.astro": "./components/Exercise.astro",
|
|
61
|
+
"./components/ExerciseSolutions.astro": "./components/ExerciseSolutions.astro",
|
|
60
62
|
"./components/Figure.astro": "./components/Figure.astro",
|
|
61
63
|
"./components/InsightBox.astro": "./components/InsightBox.astro",
|
|
62
64
|
"./components/KeyIdea.astro": "./components/KeyIdea.astro",
|
|
@@ -68,17 +70,21 @@
|
|
|
68
70
|
"./components/Pitfall.astro": "./components/Pitfall.astro",
|
|
69
71
|
"./components/PocLayout.astro": "./components/PocLayout.astro",
|
|
70
72
|
"./components/PolicyRef.astro": "./components/PolicyRef.astro",
|
|
73
|
+
"./components/Practice.astro": "./components/Practice.astro",
|
|
71
74
|
"./components/PreReleaseBanner.astro": "./components/PreReleaseBanner.astro",
|
|
72
75
|
"./components/Recovery.astro": "./components/Recovery.astro",
|
|
73
76
|
"./components/ResultBox.astro": "./components/ResultBox.astro",
|
|
74
77
|
"./components/Sidebar.astro": "./components/Sidebar.astro",
|
|
75
78
|
"./components/Sidenote.astro": "./components/Sidenote.astro",
|
|
76
79
|
"./components/SkillBox.astro": "./components/SkillBox.astro",
|
|
80
|
+
"./components/Solution.astro": "./components/Solution.astro",
|
|
77
81
|
"./components/SourceArchive.astro": "./components/SourceArchive.astro",
|
|
78
82
|
"./components/StatusBadge.astro": "./components/StatusBadge.astro",
|
|
79
83
|
"./components/Tag.astro": "./components/Tag.astro",
|
|
80
84
|
"./components/Theorem.astro": "./components/Theorem.astro",
|
|
85
|
+
"./components/Tip.astro": "./components/Tip.astro",
|
|
81
86
|
"./components/TipBox.astro": "./components/TipBox.astro",
|
|
87
|
+
"./components/TipsCard.astro": "./components/TipsCard.astro",
|
|
82
88
|
"./components/ToolFilter": {
|
|
83
89
|
"types": "./dist/components/ToolFilter.d.ts",
|
|
84
90
|
"import": "./dist/components/ToolFilter.mjs"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Auto-injected per-chapter dynamic route (v4.3.0, closes #69).
|
|
4
|
+
*
|
|
5
|
+
* Gated on `routes.chapters: true` via bookScaffoldIntegration's
|
|
6
|
+
* injectRoute call. Mirrors the frontmatter route pattern (v3.4.0 #7):
|
|
7
|
+
* the toolkit ships BOTH the /chapters/ index AND the /chapters/<slug>/
|
|
8
|
+
* dynamic route, so consumers don't have to write the boilerplate.
|
|
9
|
+
*
|
|
10
|
+
* Layout switching by preset:
|
|
11
|
+
* - academic + research-portfolio → Chapter.astro (KaTeX + theorem chrome)
|
|
12
|
+
* - all others → Base.astro (lighter; for tools/minimal/course-notes)
|
|
13
|
+
*
|
|
14
|
+
* Pre-v4.3.0 each consumer wrote this file by hand; all instances were
|
|
15
|
+
* mechanical copies (see post_transformers/guides/web/src/pages/chapters/
|
|
16
|
+
* [...slug].astro and double_ml_time_series/web/src/pages/chapters/
|
|
17
|
+
* [...slug].astro for the canonical pattern).
|
|
18
|
+
*/
|
|
19
|
+
import { getCollection, render } from 'astro:content';
|
|
20
|
+
import Chapter from '../../layouts/Chapter.astro';
|
|
21
|
+
import Base from '../../layouts/Base.astro';
|
|
22
|
+
|
|
23
|
+
const BOOK_PRESET = import.meta.env.BOOK_PRESET ?? 'minimal';
|
|
24
|
+
const USE_CHAPTER_LAYOUT = ['academic', 'research-portfolio'].includes(BOOK_PRESET);
|
|
25
|
+
|
|
26
|
+
export async function getStaticPaths() {
|
|
27
|
+
const chapters = await getCollection('chapters', (entry) => !entry.data.draft);
|
|
28
|
+
return chapters.map((entry) => ({
|
|
29
|
+
params: { slug: entry.id },
|
|
30
|
+
props: { entry },
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { entry } = Astro.props;
|
|
35
|
+
const { Content, headings } = await render(entry);
|
|
36
|
+
const Layout = USE_CHAPTER_LAYOUT ? Chapter : Base;
|
|
37
|
+
---
|
|
38
|
+
<Layout entry={entry} headings={headings}>
|
|
39
|
+
<Content />
|
|
40
|
+
</Layout>
|
package/pages/chapters.astro
CHANGED
|
@@ -112,7 +112,7 @@ for (const c of chapters) {
|
|
|
112
112
|
<ol class="chapter-list">
|
|
113
113
|
{cards.map((card) => (
|
|
114
114
|
<li class="chapter-card" data-tools={card.toolsAttr}>
|
|
115
|
-
<a href={
|
|
115
|
+
<a href={`/chapters/${card.c.id}/`} class="chapter-card-link">
|
|
116
116
|
<div class="chapter-card-meta">
|
|
117
117
|
<span class="chapter-card-number">{card.number}</span>
|
|
118
118
|
{card.volatility && (
|
package/pages/tips.astro
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
let tips: Array<{ n: number; title: string; chapter: string; preview: string }> = [];
|
|
15
|
+
let loadError: string | null = null;
|
|
16
|
+
try {
|
|
17
|
+
const mod = await import('../../../src/data/tips.json', { with: { type: 'json' } });
|
|
18
|
+
tips = (mod.default ?? []) as typeof tips;
|
|
19
|
+
} catch {
|
|
20
|
+
loadError = 'src/data/tips.json not found — run `npx book-scaffold build-tips` to generate.';
|
|
21
|
+
}
|
|
22
|
+
---
|
|
23
|
+
<Base title="Tips" description="Numbered tips from this book, drawn from <Tip> instances in chapters.">
|
|
24
|
+
<article class="prose">
|
|
25
|
+
<h1>Tips</h1>
|
|
26
|
+
{loadError && (
|
|
27
|
+
<p class="tips-empty">{loadError}</p>
|
|
28
|
+
)}
|
|
29
|
+
{!loadError && tips.length === 0 && (
|
|
30
|
+
<p class="tips-empty">No tips defined yet. Add <code><Tip n="1" title="...">...</Tip></code> to a chapter and rebuild.</p>
|
|
31
|
+
)}
|
|
32
|
+
{tips.length > 0 && (
|
|
33
|
+
<ol class="tips-index">
|
|
34
|
+
{tips.map((tip) => (
|
|
35
|
+
<li id={`tip-${tip.n}`} class="tips-index-item">
|
|
36
|
+
<h2 class="tips-index-title">
|
|
37
|
+
<span class="tips-index-number">Tip {tip.n}.</span>
|
|
38
|
+
{tip.title}
|
|
39
|
+
</h2>
|
|
40
|
+
{tip.preview && (
|
|
41
|
+
<p class="tips-index-preview">{tip.preview}</p>
|
|
42
|
+
)}
|
|
43
|
+
<p class="tips-index-meta">
|
|
44
|
+
From <a href={`/chapters/${tip.chapter}/`}>{tip.chapter}</a>
|
|
45
|
+
</p>
|
|
46
|
+
</li>
|
|
47
|
+
))}
|
|
48
|
+
</ol>
|
|
49
|
+
)}
|
|
50
|
+
</article>
|
|
51
|
+
</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,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,14 @@ 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;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
/** Profile definition — declarative shape for one book profile. */
|
package/src/profiles/academic.ts
CHANGED
|
@@ -23,6 +23,7 @@ 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
|
|
26
27
|
},
|
|
27
28
|
styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
|
|
28
29
|
katex: true,
|
|
@@ -25,6 +25,7 @@ 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
|
|
28
29
|
},
|
|
29
30
|
styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
|
|
30
31
|
// 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,7 @@ 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
|
|
23
24
|
},
|
|
24
25
|
styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
|
|
25
26
|
// v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
|
|
@@ -36,6 +36,7 @@ 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
|
|
39
40
|
},
|
|
40
41
|
styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
|
|
41
42
|
katex: true, // math is common in research content
|
package/src/profiles/tools.ts
CHANGED
|
@@ -20,6 +20,7 @@ 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
|
|
23
24
|
},
|
|
24
25
|
styles: [
|
|
25
26
|
'tokens.css', 'layout.css', 'callouts.css', 'chapter.css',
|
package/styles/callouts.css
CHANGED
|
@@ -210,6 +210,151 @@
|
|
|
210
210
|
color: var(--color-text);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/* ===== v4.3.0 book-genre family =====================================
|
|
214
|
+
* Tip / TipsCard (Pragmatic Programmer precedent, #70)
|
|
215
|
+
* Exercise / Practice / Solution / ExerciseSolutions (CS:APP, #71)
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
/* Tip (numbered, #70). Gold border + tip-number badge + title row. */
|
|
219
|
+
.callout-tip-numbered {
|
|
220
|
+
border-left-color: var(--callout-learn);
|
|
221
|
+
background: var(--warm-gold-tint);
|
|
222
|
+
}
|
|
223
|
+
.callout-tip-numbered .callout-tip-header {
|
|
224
|
+
display: flex;
|
|
225
|
+
align-items: baseline;
|
|
226
|
+
gap: var(--space-3);
|
|
227
|
+
margin-bottom: var(--space-2);
|
|
228
|
+
}
|
|
229
|
+
.callout-tip-numbered .callout-tip-number {
|
|
230
|
+
font-size: var(--text-xs);
|
|
231
|
+
text-transform: uppercase;
|
|
232
|
+
letter-spacing: 0.05em;
|
|
233
|
+
font-weight: 700;
|
|
234
|
+
color: var(--callout-learn);
|
|
235
|
+
padding: 0 var(--space-2);
|
|
236
|
+
border: 1px solid var(--callout-learn);
|
|
237
|
+
border-radius: var(--radius-sm);
|
|
238
|
+
white-space: nowrap;
|
|
239
|
+
}
|
|
240
|
+
.callout-tip-numbered .callout-tip-title {
|
|
241
|
+
font-weight: 600;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* TipsCard (print-friendly tips card, #70). */
|
|
245
|
+
.tips-card {
|
|
246
|
+
margin: var(--space-8) 0;
|
|
247
|
+
padding: var(--space-6);
|
|
248
|
+
border: 1px solid var(--color-border);
|
|
249
|
+
border-radius: var(--radius-md);
|
|
250
|
+
background: var(--color-bg-subtle);
|
|
251
|
+
page-break-inside: avoid;
|
|
252
|
+
}
|
|
253
|
+
.tips-card-title {
|
|
254
|
+
margin: 0 0 var(--space-4) 0;
|
|
255
|
+
font-size: var(--text-2xl);
|
|
256
|
+
}
|
|
257
|
+
.tips-card-list {
|
|
258
|
+
list-style: none;
|
|
259
|
+
padding: 0;
|
|
260
|
+
margin: 0;
|
|
261
|
+
}
|
|
262
|
+
.tips-card-item {
|
|
263
|
+
padding: var(--space-2) 0;
|
|
264
|
+
border-bottom: 1px dashed var(--color-border);
|
|
265
|
+
}
|
|
266
|
+
.tips-card-link {
|
|
267
|
+
display: flex;
|
|
268
|
+
gap: var(--space-3);
|
|
269
|
+
text-decoration: none;
|
|
270
|
+
color: var(--color-text);
|
|
271
|
+
}
|
|
272
|
+
.tips-card-number {
|
|
273
|
+
font-weight: 700;
|
|
274
|
+
color: var(--callout-learn);
|
|
275
|
+
min-width: 2.5em;
|
|
276
|
+
}
|
|
277
|
+
.tips-card-empty {
|
|
278
|
+
color: var(--color-text-muted);
|
|
279
|
+
font-style: italic;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* Exercise (#71). Light treatment for inline placement. */
|
|
283
|
+
.exercise {
|
|
284
|
+
margin: var(--space-4) 0;
|
|
285
|
+
padding: var(--space-3) var(--space-4);
|
|
286
|
+
border-left: var(--border-bar) solid var(--color-border);
|
|
287
|
+
background: var(--color-bg-subtle);
|
|
288
|
+
}
|
|
289
|
+
.exercise-label {
|
|
290
|
+
display: block;
|
|
291
|
+
font-weight: 600;
|
|
292
|
+
font-size: var(--text-sm);
|
|
293
|
+
text-transform: uppercase;
|
|
294
|
+
letter-spacing: 0.05em;
|
|
295
|
+
color: var(--color-text-muted);
|
|
296
|
+
margin-bottom: var(--space-2);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* Practice (#71). Diamond-marker difficulty + label. */
|
|
300
|
+
.practice {
|
|
301
|
+
margin: var(--space-6) 0;
|
|
302
|
+
padding: var(--space-4);
|
|
303
|
+
border-left: var(--border-bar) solid var(--callout-official);
|
|
304
|
+
background: var(--warm-plum-tint);
|
|
305
|
+
}
|
|
306
|
+
.practice-header {
|
|
307
|
+
display: flex;
|
|
308
|
+
justify-content: space-between;
|
|
309
|
+
align-items: baseline;
|
|
310
|
+
margin-bottom: var(--space-3);
|
|
311
|
+
}
|
|
312
|
+
.practice-label {
|
|
313
|
+
font-weight: 600;
|
|
314
|
+
font-size: var(--text-sm);
|
|
315
|
+
text-transform: uppercase;
|
|
316
|
+
letter-spacing: 0.05em;
|
|
317
|
+
}
|
|
318
|
+
.practice-difficulty {
|
|
319
|
+
font-family: var(--font-code);
|
|
320
|
+
color: var(--callout-official);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* Solution (#71). Inside ExerciseSolutions wrapper. */
|
|
324
|
+
.solution {
|
|
325
|
+
margin: var(--space-5) 0;
|
|
326
|
+
padding-left: var(--space-4);
|
|
327
|
+
border-left: 2px solid var(--callout-insight);
|
|
328
|
+
}
|
|
329
|
+
.solution-header {
|
|
330
|
+
display: flex;
|
|
331
|
+
justify-content: space-between;
|
|
332
|
+
align-items: baseline;
|
|
333
|
+
margin-bottom: var(--space-2);
|
|
334
|
+
}
|
|
335
|
+
.solution-label {
|
|
336
|
+
font-weight: 600;
|
|
337
|
+
font-size: var(--text-sm);
|
|
338
|
+
text-transform: uppercase;
|
|
339
|
+
letter-spacing: 0.05em;
|
|
340
|
+
color: var(--callout-insight);
|
|
341
|
+
}
|
|
342
|
+
.solution-backlink {
|
|
343
|
+
font-size: var(--text-xs);
|
|
344
|
+
text-decoration: none;
|
|
345
|
+
color: var(--color-text-muted);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/* ExerciseSolutions (#71). Section wrapper at chapter end. */
|
|
349
|
+
.exercise-solutions {
|
|
350
|
+
margin: var(--space-12) 0 var(--space-6) 0;
|
|
351
|
+
padding-top: var(--space-6);
|
|
352
|
+
border-top: 2px solid var(--color-border);
|
|
353
|
+
}
|
|
354
|
+
.exercise-solutions-heading {
|
|
355
|
+
margin: 0 0 var(--space-5) 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
213
358
|
/* ===== Theorem family (Theorem.astro) =====
|
|
214
359
|
* Plain (theorem/proposition/lemma/corollary): italic body
|
|
215
360
|
* Definition family (definition/example/exercise/remark/proof): upright body
|