@brandon_m_behring/book-scaffold-astro 4.24.0 → 4.25.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/CLAUDE.md +9 -1
- package/components/AssessmentTest.astro +6 -2
- package/components/ChapterNav.astro +1 -1
- package/components/ChapterTOC.astro +8 -1
- package/components/Cite.astro +1 -1
- package/components/Epigraph.astro +28 -0
- package/components/EvidenceTag.astro +102 -0
- package/components/MarginFigure.astro +49 -0
- package/components/Newthought.astro +18 -0
- package/components/PartReview.astro +1 -1
- package/components/SectionMap.astro +56 -0
- package/components/SectionMap.tsx +170 -0
- package/components/Sidebar.astro +1 -1
- package/components/Term.astro +1 -1
- package/components/TipsCard.astro +1 -1
- package/components/WeekRef.astro +3 -1
- package/components/XRef.astro +5 -1
- package/dist/components/ExamRunner.d.ts +2 -2
- package/dist/components/Flashcards.d.ts +2 -2
- package/dist/components/SectionMap.d.ts +10 -0
- package/dist/components/SectionMap.mjs +107 -0
- package/dist/{exam-manifest-DbTHo90M.d.ts → exam-manifest-X9IrX1G3.d.ts} +8 -3
- package/dist/{flashcards-DPFFQhP8.d.ts → flashcards-okekZcl8.d.ts} +1 -1
- package/dist/index.d.ts +71 -8
- package/dist/index.mjs +41 -7
- package/dist/{schemas-DDWDRUxs.d.ts → schemas-CKipJ5Ie.d.ts} +22 -1
- package/dist/schemas.d.ts +2 -2
- package/dist/schemas.mjs +15 -5
- package/dist/{types-CGN95mrX.d.ts → types-Bn7rKhgP.d.ts} +1 -1
- package/layouts/Base.astro +1 -1
- package/layouts/Chapter.astro +36 -1
- package/package.json +11 -1
- package/pages/answers.astro +4 -2
- package/pages/chapters.astro +4 -1
- package/pages/exercises.astro +5 -2
- package/pages/index.astro +11 -7
- package/pages/search.astro +6 -2
- package/pages/tips.astro +4 -1
- package/recipes/04-component-library.md +7 -3
- package/recipes/16-tikz-figures.md +39 -0
- package/recipes/20-anki-export.md +1 -1
- package/recipes/21-multi-guide-single-app.md +4 -4
- package/recipes/README.md +1 -1
- package/scripts/build-labels.mjs +3 -1
- package/src/lib/exam-manifest.ts +11 -2
- package/src/lib/section-map.ts +88 -0
- package/src/profiles/academic.ts +1 -1
- package/src/profiles/course-notes.ts +1 -1
- package/src/profiles/minimal.ts +1 -1
- package/src/profiles/research-portfolio.ts +1 -1
- package/src/profiles/tools.ts +1 -0
- package/src/schemas.ts +15 -0
- package/styles/layout.css +64 -0
- package/styles/section-map.css +130 -0
- package/styles/typography.css +51 -0
package/CLAUDE.md
CHANGED
|
@@ -100,7 +100,15 @@ Two callout families coexist. Authors import what they need.
|
|
|
100
100
|
|
|
101
101
|
**Pedagogy family** (v4.1.0+, any profile, 4 components): `Pitfall` (rose; "common mistake" — distinct from `WarnBox`'s preemptive warning), `WorkedExample` (plum; collapsible `<details>` block with `#worked-example-{id}` anchor for deep links), `YouWillLearn` (gold; chapter-opener with optional `prerequisites` prop), `Diagnostic` (v4.19.0, #110; teal; pre-reading "Do I Know This Already?" DIKTA self-check — a slotted question list + a skip/skim/read routing rubric via `skimTo`, plus an optional collapsible answer key via `slot="answers"`). Slot bullets/code freely; render at any preset.
|
|
102
102
|
|
|
103
|
-
**Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginNote`, `Sidenote`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `BookLink` (v4.16.0+; cross-book link — `<BookLink book="design" to="…"/>` resolves `book` against `defineBookConfig({ siblingBooks })` and throws on an unknown book; `<XRef>` is in-book only — #96), `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
|
|
103
|
+
**Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginFigure`, `MarginNote`, `Sidenote`, `EvidenceTag`, `Newthought`, `Epigraph`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `BookLink` (v4.16.0+; cross-book link — `<BookLink book="design" to="…"/>` resolves `book` against `defineBookConfig({ siblingBooks })` and throws on an unknown book; `<XRef>` is in-book only — #96), `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
|
|
104
|
+
|
|
105
|
+
**`MarginNote` vs `Sidenote` (don't let the names mislead).** `MarginNote` renders **inline** — a colored callout in the running text column; despite the name it does **not** go in the margin. It's for a load-bearing aside the reader must see. `Sidenote` is the one that **floats into the right gutter** (auto-numbered Tufte marginalia, reflowing inline on mobile). Reach for `Sidenote` for footnote-like asides; `MarginNote` for an inline colored callout.
|
|
106
|
+
|
|
107
|
+
**`MarginFigure` + figure/content placement (1d).** `MarginFigure` is a `<Figure>` that **floats into the right gutter** (the same Tufte float + negative-margin technique `Sidenote`/SectionMap use — *not* a grid), shown at ≥64rem and reflowing inline below on mobile. Props mirror `<Figure>` (`src`/`caption`/`alt`/`desc`/`id`/`width`); rendering is delegated to `Figure`, and `width` defaults to `100%` (of the ~28ch gutter column). Two additive placement classes back it: **`.column-margin`** floats any block into the gutter (the un-figure version), and **`.column-page`** is a full-width breakout — an alias of the canonical **`.wide`** escape (max-width override, spans the gutter column too). For a full-bleed figure use `<Figure class="wide" …/>`; for the margin use `<MarginFigure …/>`. **Per-page width knob:** an optional `layout: wide` frontmatter field (closed enum `default`|`wide`, every profile) widens the main text measure for figure-/table-heavy chapters — `Chapter.astro` threads it as `data-layout="wide"` on `<article class="prose">` and `layout.css` maps `.prose[data-layout="wide"] { --measure-main: 80ch }`. Omitting it (or `layout: default`) emits no attribute, so existing chapters are byte-identical. All of these live in the always-loaded `layout.css` (token-only; additive — `.prose`'s block layout and `.sidenote`'s float are unchanged).
|
|
108
|
+
|
|
109
|
+
**`EvidenceTag`** (v4.25.0): inline claim-confidence pill placed right after a claim — `kind` is a closed union `verified | inference | audit-corrected` (fail-loud via `assertEnumProp`; colors green/blue/rose, token-only). Sibling of `Tag` (which flags claim *provenance*); `EvidenceTag` flags how well a claim has been *checked*. An optional `source` prop is reserved (rendered as `data-source`, no behavior yet) for a future fail-loud `validate.mjs` upgrade. Usage: `<EvidenceTag kind="verified" /> … <EvidenceTag kind="inference" />`.
|
|
110
|
+
|
|
111
|
+
**`Newthought` + `Epigraph`** (v4.25.0): Tufte typographic openers ported from the LaTeX book. `Newthought` is a run-in small-caps section opener (TRUE small-caps via `font-feature-settings: "smcp"`) — `<p><Newthought>In practice</Newthought>, …</p>`. `Epigraph` is a chapter-opening italic quotation with a right-aligned attribution (named `attribution` slot) — `<Epigraph>…<Fragment slot="attribution">Hamming</Fragment></Epigraph>`. Their CSS ships in the always-loaded `typography.css`; an opt-in `.heading-accent` (warm-blue italic) class is available there too (global headings are unchanged).
|
|
104
112
|
|
|
105
113
|
**Provenance** (v4.8.0, any profile, **auto-injected by the chapter route — not author-imported**): per-chapter "How this was made" audit-trail block, rendered from the optional `provenance` frontmatter (`ai_tools`, `prompts_archive`, `decisions_log`, `audit_history`, `citation_backstop`). **Opt-out**: a chapter with no `provenance` shows a fallback ("Audit history not yet recorded"). Distinct from `AICollaborationDisclosure` (book-level, manual model+role disclosure). Repo-relative path fields render as `<code>`; only `http(s)` values link.
|
|
106
114
|
|
|
@@ -80,10 +80,14 @@ for (const e of entries) {
|
|
|
80
80
|
const scoreable = entries.filter(
|
|
81
81
|
(e) => e.data.type === 'mcq' && (e.data.options?.length ?? 0) > 0,
|
|
82
82
|
);
|
|
83
|
+
// #142: thread the deploy base through the routing map + practice-exam link.
|
|
84
|
+
// deriveDomainRouting takes baseUrl as a param (not import.meta.env) because it
|
|
85
|
+
// ships pre-compiled in dist/ where Vite's env replacement does not reach.
|
|
86
|
+
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
|
|
83
87
|
const manifest = buildExamManifest(entries);
|
|
84
|
-
const domainRouting = deriveDomainRouting(entries);
|
|
88
|
+
const domainRouting = deriveDomainRouting(entries, baseUrl);
|
|
85
89
|
const practiceExamHref = (bookConfig.enabledRoutes ?? []).includes('practiceExam')
|
|
86
|
-
?
|
|
90
|
+
? `${baseUrl}practice-exam`
|
|
87
91
|
: null;
|
|
88
92
|
|
|
89
93
|
const rendered = await Promise.all(
|
|
@@ -10,7 +10,7 @@ interface Props {
|
|
|
10
10
|
}
|
|
11
11
|
const { currentId } = Astro.props;
|
|
12
12
|
const { prev, next } = await getNeighbors(currentId);
|
|
13
|
-
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|
13
|
+
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
|
|
14
14
|
---
|
|
15
15
|
{(prev || next) && (
|
|
16
16
|
<nav class="chapter-nav" aria-label="Chapter navigation">
|
|
@@ -8,15 +8,22 @@
|
|
|
8
8
|
*
|
|
9
9
|
* If the chapter has fewer than 3 matching headings, the TOC renders
|
|
10
10
|
* nothing — an anchor list for a short chapter is clutter.
|
|
11
|
+
*
|
|
12
|
+
* The h2/h3 filter is the shared `tocHeadings` (lib/section-map.ts) — the SAME
|
|
13
|
+
* one the right-gutter SectionMap island uses, so this collapsed fallback and
|
|
14
|
+
* the gutter map carry identical entries (#section-map). On desktop (>= 64rem)
|
|
15
|
+
* this <details> is CSS-hidden and the gutter map takes over; below it, this is
|
|
16
|
+
* the fallback.
|
|
11
17
|
*/
|
|
12
18
|
import type { MarkdownHeading } from 'astro';
|
|
19
|
+
import { tocHeadings } from '../src/lib/section-map';
|
|
13
20
|
|
|
14
21
|
interface Props {
|
|
15
22
|
headings: MarkdownHeading[];
|
|
16
23
|
}
|
|
17
24
|
const { headings } = Astro.props;
|
|
18
25
|
|
|
19
|
-
const toc = headings
|
|
26
|
+
const toc = tocHeadings(headings);
|
|
20
27
|
const showToc = toc.length >= 3;
|
|
21
28
|
---
|
|
22
29
|
{showToc && (
|
package/components/Cite.astro
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Epigraph — a chapter-opening quotation in the Tufte style: an italic block
|
|
4
|
+
* quote with a right-aligned attribution. Maps the LaTeX `epigraph`-style
|
|
5
|
+
* opener from interview-preamble-tufte.sty.
|
|
6
|
+
*
|
|
7
|
+
* The quote text goes in the default slot; the attribution goes in the named
|
|
8
|
+
* `attribution` slot (rendered in a <footer>, right-aligned). The measure is
|
|
9
|
+
* inherited from the running text tokens (--measure-main / .prose) rather than
|
|
10
|
+
* a fixed % width, so it doesn't fight the Tufte measure. CSS lives in the
|
|
11
|
+
* shared typography.css (always loaded). The footer renders only when the
|
|
12
|
+
* attribution slot is filled.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* <Epigraph>
|
|
16
|
+
* The purpose of computing is insight, not numbers.
|
|
17
|
+
* <Fragment slot="attribution">Richard Hamming</Fragment>
|
|
18
|
+
* </Epigraph>
|
|
19
|
+
*/
|
|
20
|
+
---
|
|
21
|
+
<div class="epigraph">
|
|
22
|
+
<blockquote>
|
|
23
|
+
<slot />
|
|
24
|
+
{Astro.slots.has('attribution') && (
|
|
25
|
+
<footer class="epigraph-attribution"><slot name="attribution" /></footer>
|
|
26
|
+
)}
|
|
27
|
+
</blockquote>
|
|
28
|
+
</div>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* EvidenceTag — inline claim-confidence marker, placed immediately after a
|
|
4
|
+
* stated claim to calibrate how far the reader should trust it. Ported from
|
|
5
|
+
* the user's Tufte-LaTeX evidence tags (interview-preamble-tufte.sty).
|
|
6
|
+
*
|
|
7
|
+
* Sibling of <Tag> (practice provenance): Tag flags WHERE a claim comes from
|
|
8
|
+
* (official / practitioner / convergence); EvidenceTag flags HOW WELL it has
|
|
9
|
+
* been checked. Three closed-union states:
|
|
10
|
+
*
|
|
11
|
+
* verified Confirmed against a primary source / first-hand check.
|
|
12
|
+
* inference Reasoned conclusion, not directly verified.
|
|
13
|
+
* audit-corrected A prior claim that an audit pass overturned/repaired.
|
|
14
|
+
*
|
|
15
|
+
* Colors are token-only so they recolor with [data-theme] automatically:
|
|
16
|
+
* verified → --warm-green, inference → --warm-blue, audit-corrected →
|
|
17
|
+
* --warm-rose (tinted background + matching text/border), matching the
|
|
18
|
+
* scoped-<style> + data-attribute idiom of <Tag>.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* The kernel is diagonal-plus-low-rank <EvidenceTag kind="verified" />.
|
|
22
|
+
* This generalizes to higher orders <EvidenceTag kind="inference" />.
|
|
23
|
+
*/
|
|
24
|
+
import { assertEnumProp } from '../src/lib/assert-prop';
|
|
25
|
+
|
|
26
|
+
const KINDS = ['verified', 'inference', 'audit-corrected'] as const;
|
|
27
|
+
type Kind = (typeof KINDS)[number];
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
kind: Kind;
|
|
31
|
+
/**
|
|
32
|
+
* Provenance pointer for the claim (citekey, URL, audit-file path). RESERVED
|
|
33
|
+
* (#section-map plan NQ3): rendered as a `data-source` attribute today with
|
|
34
|
+
* no behavior. A future fail-loud upgrade — additive, designed-for here — is
|
|
35
|
+
* a `validate.mjs` check requiring `source` on kind="verified" (an unverified
|
|
36
|
+
* "verified" tag is the silent-degradation class assertEnumProp guards). Do
|
|
37
|
+
* NOT wire that validation now; reserving the prop keeps the upgrade additive.
|
|
38
|
+
*/
|
|
39
|
+
source?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// #109 sweep: fail loud on an unknown kind rather than rendering a blank pill
|
|
43
|
+
// (LABEL[undefined] -> undefined). A closed union with no valid value is an
|
|
44
|
+
// authoring error that should stop the build.
|
|
45
|
+
const kind = assertEnumProp(Astro.props.kind, KINDS, {
|
|
46
|
+
component: 'EvidenceTag',
|
|
47
|
+
prop: 'kind',
|
|
48
|
+
});
|
|
49
|
+
const { source } = Astro.props;
|
|
50
|
+
|
|
51
|
+
const LABEL: Record<Kind, string> = {
|
|
52
|
+
verified: '[V] Verified',
|
|
53
|
+
inference: '[I] Inference',
|
|
54
|
+
'audit-corrected': 'Audit-corrected',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const TITLE: Record<Kind, string> = {
|
|
58
|
+
verified: 'Confirmed against a primary source or first-hand check',
|
|
59
|
+
inference: 'Reasoned conclusion — not directly verified',
|
|
60
|
+
'audit-corrected': 'A prior claim an audit pass overturned or repaired',
|
|
61
|
+
};
|
|
62
|
+
---
|
|
63
|
+
<span class="evidence-tag" data-kind={kind} data-source={source} title={TITLE[kind]}>
|
|
64
|
+
{LABEL[kind]}
|
|
65
|
+
</span>
|
|
66
|
+
|
|
67
|
+
<style>
|
|
68
|
+
.evidence-tag {
|
|
69
|
+
display: inline-block;
|
|
70
|
+
font-family: var(--font-code);
|
|
71
|
+
font-size: 0.75em;
|
|
72
|
+
font-weight: 500;
|
|
73
|
+
padding: 0 0.4em;
|
|
74
|
+
margin: 0 0.1em;
|
|
75
|
+
border-radius: var(--radius-sm);
|
|
76
|
+
border: 1px solid var(--color-border);
|
|
77
|
+
background: var(--color-bg-subtle);
|
|
78
|
+
color: var(--color-text-muted);
|
|
79
|
+
vertical-align: 0.1em;
|
|
80
|
+
letter-spacing: 0.02em;
|
|
81
|
+
/* Inline pill: don't let it stretch the line box awkwardly. */
|
|
82
|
+
line-height: 1.4;
|
|
83
|
+
white-space: nowrap;
|
|
84
|
+
}
|
|
85
|
+
/* Tinted background + matching text/border per kind. Tokens only, so each
|
|
86
|
+
* recolors in the [data-theme="dark"] scope without a dark-block edit. */
|
|
87
|
+
.evidence-tag[data-kind="verified"] {
|
|
88
|
+
border-color: var(--warm-green);
|
|
89
|
+
color: var(--warm-green);
|
|
90
|
+
background: var(--warm-green-tint);
|
|
91
|
+
}
|
|
92
|
+
.evidence-tag[data-kind="inference"] {
|
|
93
|
+
border-color: var(--warm-blue);
|
|
94
|
+
color: var(--warm-blue);
|
|
95
|
+
background: var(--warm-blue-tint);
|
|
96
|
+
}
|
|
97
|
+
.evidence-tag[data-kind="audit-corrected"] {
|
|
98
|
+
border-color: var(--warm-rose);
|
|
99
|
+
color: var(--warm-rose);
|
|
100
|
+
background: var(--warm-rose-tint);
|
|
101
|
+
}
|
|
102
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* MarginFigure — a Figure pulled into the RIGHT margin (Tufte margin figure).
|
|
4
|
+
*
|
|
5
|
+
* Floats into the .prose right gutter using the SAME float + negative
|
|
6
|
+
* margin-right technique as `.sidenote` and SectionMap (layout.css,
|
|
7
|
+
* `.margin-figure`) — NOT a grid. Shown at >= 64rem (the gutter breakpoint);
|
|
8
|
+
* below that the float rule doesn't apply, so it reflows INLINE in document
|
|
9
|
+
* flow right where it appears (mobile gets the figure, just full-width).
|
|
10
|
+
*
|
|
11
|
+
* Rendering is DELEGATED to <Figure> — this component does not reinvent the
|
|
12
|
+
* inline-SVG / <img> / caption / a11y logic; it only wraps Figure in the
|
|
13
|
+
* gutter-floated <aside> and forwards every prop. The wrapper is an <aside>
|
|
14
|
+
* because a margin figure is supplementary to the running text.
|
|
15
|
+
*
|
|
16
|
+
* `width` defaults to "100%" (of the narrow gutter column, ~28ch) rather than
|
|
17
|
+
* Figure's page-width default — a margin figure should fill its column, not
|
|
18
|
+
* the page. Caption is shrunk to --text-xs in layout.css (margin context).
|
|
19
|
+
*
|
|
20
|
+
* Usage (same props as <Figure>):
|
|
21
|
+
* <MarginFigure
|
|
22
|
+
* src="/figures/week04/ex2_hippo_eigenvalues.svg"
|
|
23
|
+
* caption="HiPPO-LegS eigenvalue structure."
|
|
24
|
+
* alt="Two overlaid eigenvalue spectra forming nested arcs."
|
|
25
|
+
* id="w4-fig-hippo-eigenvalues"
|
|
26
|
+
* />
|
|
27
|
+
*
|
|
28
|
+
* For a full-width (not margin) figure, use <Figure> with class="wide" (or
|
|
29
|
+
* class="column-page"). For a generic non-figure block in the gutter, use a
|
|
30
|
+
* plain element with class="column-margin".
|
|
31
|
+
*/
|
|
32
|
+
import Figure from './Figure.astro';
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
src: string;
|
|
36
|
+
caption?: string;
|
|
37
|
+
/** Defaults to 100% of the gutter column (not Figure's page-width default). */
|
|
38
|
+
width?: string;
|
|
39
|
+
id?: string;
|
|
40
|
+
alt?: string;
|
|
41
|
+
/** Long-form description → SVG <desc> (alt stays the short accessible name). */
|
|
42
|
+
desc?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { src, caption, width = '100%', id, alt, desc } = Astro.props;
|
|
46
|
+
---
|
|
47
|
+
<aside class="margin-figure" role="group">
|
|
48
|
+
<Figure src={src} caption={caption} width={width} id={id} alt={alt} desc={desc} />
|
|
49
|
+
</aside>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Newthought — Tufte run-in section opener. Maps LaTeX `\newthought{…}`:
|
|
4
|
+
* the first few words of a new line of thought set in small-caps, used in
|
|
5
|
+
* place of (or alongside) a subheading to mark a shift without breaking the
|
|
6
|
+
* paragraph flow.
|
|
7
|
+
*
|
|
8
|
+
* TRUE small-caps via `font-feature-settings: "smcp"` (NOT
|
|
9
|
+
* `font-variant: small-caps`, which synthesizes faux small-caps by scaling
|
|
10
|
+
* the capitals) — the Roboto variable face ships the smcp feature. CSS lives
|
|
11
|
+
* in the shared typography.css (always loaded via every profile's styles
|
|
12
|
+
* array), so this component is markup-only.
|
|
13
|
+
*
|
|
14
|
+
* Usage (inline at the start of a paragraph):
|
|
15
|
+
* <p><Newthought>In practice</Newthought>, the kernel is computed once …</p>
|
|
16
|
+
*/
|
|
17
|
+
---
|
|
18
|
+
<span class="newthought"><slot /></span>
|
|
@@ -27,7 +27,7 @@ interface Props {
|
|
|
27
27
|
title?: string;
|
|
28
28
|
}
|
|
29
29
|
const { part, title } = Astro.props;
|
|
30
|
-
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|
30
|
+
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
|
|
31
31
|
|
|
32
32
|
// Reuse the build-exercises index (keyed by chapter slug). Project-root-relative
|
|
33
33
|
// glob (the tips.astro / exercises.astro lesson) so it resolves across consumers.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* SectionMap.astro — the right-gutter sticky "On this page" section map
|
|
4
|
+
* (#section-map).
|
|
5
|
+
*
|
|
6
|
+
* Renders a real anchor list (h2/h3 slug links) into the .prose right gutter and
|
|
7
|
+
* mounts the SectionMap island (client:idle) as a controller over it. The island
|
|
8
|
+
* observes the heading elements and lights the active link as the reader scrolls
|
|
9
|
+
* (scrollspy). The collapsed top ChapterTOC remains the mobile/no-JS fallback —
|
|
10
|
+
* section-map.css hides THIS nav below 64rem (the gutter breakpoint) and hides
|
|
11
|
+
* ChapterTOC at/above it, so the two never double-show.
|
|
12
|
+
*
|
|
13
|
+
* Gated identically to ChapterTOC: renders nothing below 3 matching headings
|
|
14
|
+
* (an anchor list for a 1–2 heading chapter is clutter). Uses the shared
|
|
15
|
+
* `tocHeadings` filter so the gutter map and the fallback carry IDENTICAL
|
|
16
|
+
* entries. The island is imported via the package exports map (consumers must
|
|
17
|
+
* not compile raw .tsx out of node_modules — same as practice-exam.astro's
|
|
18
|
+
* ExamRunner import).
|
|
19
|
+
*
|
|
20
|
+
* DOM contract (see SectionMap.tsx): the <nav> is [data-section-map-root]; each
|
|
21
|
+
* link is a[data-section-id="<slug>"]; the island mounts INSIDE the nav so its
|
|
22
|
+
* closest() finds the root.
|
|
23
|
+
*/
|
|
24
|
+
import type { MarkdownHeading } from 'astro';
|
|
25
|
+
import { tocHeadings } from '../src/lib/section-map';
|
|
26
|
+
// Pre-compiled island via the exports map — consumers must not compile raw
|
|
27
|
+
// .tsx out of node_modules (same as practice-exam.astro's ExamRunner import).
|
|
28
|
+
import SectionMap from '@brandon_m_behring/book-scaffold-astro/components/SectionMap';
|
|
29
|
+
import '../styles/section-map.css';
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
headings: MarkdownHeading[];
|
|
33
|
+
/** Optional context label, e.g. "Part II · Ch 4". Omitted when not derivable. */
|
|
34
|
+
label?: string;
|
|
35
|
+
}
|
|
36
|
+
const { headings, label } = Astro.props;
|
|
37
|
+
|
|
38
|
+
const toc = tocHeadings(headings);
|
|
39
|
+
const showMap = toc.length >= 3;
|
|
40
|
+
---
|
|
41
|
+
{showMap && (
|
|
42
|
+
<nav class="section-map" data-section-map-root aria-label="On this page">
|
|
43
|
+
{label && <p class="section-map-label">{label}</p>}
|
|
44
|
+
<p class="section-map-title">On this page</p>
|
|
45
|
+
<ol class="section-map-list">
|
|
46
|
+
{toc.map((h) => (
|
|
47
|
+
<li class={`toc-h${h.depth}`}>
|
|
48
|
+
<a data-section-id={h.slug} href={`#${h.slug}`} class={`toc-h${h.depth}`}>
|
|
49
|
+
{h.text}
|
|
50
|
+
</a>
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
</ol>
|
|
54
|
+
<SectionMap client:idle headings={toc} />
|
|
55
|
+
</nav>
|
|
56
|
+
)}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SectionMap — Preact island driving the right-gutter "On this page" scrollspy
|
|
3
|
+
* (#section-map).
|
|
4
|
+
*
|
|
5
|
+
* Architecture: a CONTROLLER over server-rendered nav links, not a renderer.
|
|
6
|
+
* SectionMap.astro renders the <nav data-section-map-root> with one
|
|
7
|
+
* `a[data-section-id="<slug>"]` per h2/h3 (so the nav is a real anchor list with
|
|
8
|
+
* no JS — but on mobile/no-JS the collapsed ChapterTOC is the fallback and this
|
|
9
|
+
* gutter nav is CSS-hidden below 64rem). This island receives only the pure
|
|
10
|
+
* heading manifest (slug/depth/text), observes the matching heading ELEMENTS
|
|
11
|
+
* (`#<slug>`) in the document via IntersectionObserver, computes the active slug
|
|
12
|
+
* with the pure `pickActive`, and sets `aria-current="page"` + an `active` class
|
|
13
|
+
* on the corresponding link (clearing the others). It NEVER injects markup, so
|
|
14
|
+
* there's zero layout shift — only a class/attribute toggle on existing links.
|
|
15
|
+
*
|
|
16
|
+
* DOM contract with SectionMap.astro:
|
|
17
|
+
* [data-section-map-root] the SSR <nav> wrapper (ancestor of this
|
|
18
|
+
* island's mount point)
|
|
19
|
+
* a[data-section-id="<slug>"] one link per heading; gains/loses
|
|
20
|
+
* aria-current="page" + class "active"
|
|
21
|
+
* #<slug> the heading element in the article body
|
|
22
|
+
* (Astro's MDX slug) — observed, not owned
|
|
23
|
+
*
|
|
24
|
+
* Fail-loud (house invariant, like ExamRunner/Flashcards): a missing wrapper
|
|
25
|
+
* throws. A heading id with no element in the document is skipped gracefully
|
|
26
|
+
* (the TOC filter can include a heading Astro slugged differently / a deferred
|
|
27
|
+
* section), BUT if NONE of the manifest's headings resolve to an element, that's
|
|
28
|
+
* manifest/DOM drift and we throw rather than mount a silently dead nav.
|
|
29
|
+
*
|
|
30
|
+
* No animation → no prefers-reduced-motion handling needed (instant class
|
|
31
|
+
* toggle). No canvas → no book:theme:change listener (CSS tokens recolor the
|
|
32
|
+
* nav from [data-theme] automatically). Hydrated with `client:idle`.
|
|
33
|
+
*/
|
|
34
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
35
|
+
import { pickActive, type VisibleHeading } from '../src/lib/section-map';
|
|
36
|
+
import type { MarkdownHeading } from 'astro';
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
/** TOC headings (tocHeadings output) — slug/depth/text only, serializable. */
|
|
40
|
+
headings: MarkdownHeading[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function SectionMap({ headings }: Props) {
|
|
44
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const root = ref.current?.closest<HTMLElement>('[data-section-map-root]');
|
|
48
|
+
if (!root) {
|
|
49
|
+
// Fail loud: a silently dead scrollspy (no active highlight, ever) is the
|
|
50
|
+
// worst failure mode — surface it as an uncaught console error.
|
|
51
|
+
throw new Error(
|
|
52
|
+
'SectionMap: no [data-section-map-root] ancestor — mount the island inside ' +
|
|
53
|
+
'the <nav> that contains its links (see the DOM contract in SectionMap.tsx).',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolve each manifest heading to its body element. CSS.escape: a slug with
|
|
58
|
+
// an unusual char would otherwise break the selector and throw a DOMException
|
|
59
|
+
// mid-observe (a frozen scrollspy).
|
|
60
|
+
type Tracked = { slug: string; el: Element };
|
|
61
|
+
const tracked: Tracked[] = [];
|
|
62
|
+
for (const h of headings) {
|
|
63
|
+
const el = document.getElementById(h.slug);
|
|
64
|
+
if (el) tracked.push({ slug: h.slug, el });
|
|
65
|
+
// else: skip gracefully — a filtered heading Astro slugged differently or
|
|
66
|
+
// a section not in this document. The drift guard below catches a TOTAL miss.
|
|
67
|
+
}
|
|
68
|
+
if (tracked.length === 0) {
|
|
69
|
+
// None resolved → manifest/DOM drift (mirror ExamRunner). A nav whose every
|
|
70
|
+
// link points at a missing anchor must not pretend to work.
|
|
71
|
+
throw new Error(
|
|
72
|
+
`SectionMap: manifest/DOM drift — none of the ${headings.length} heading id(s) ` +
|
|
73
|
+
`resolve to an element (#${headings.map((h) => h.slug).join(', #')}).`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
// The last tracked heading in document order (bound here, where the length
|
|
77
|
+
// guard above proves it exists — `at(-1)`'s type is still `Tracked | undefined`
|
|
78
|
+
// under noUncheckedIndexedAccess, so narrow it once). Used by the
|
|
79
|
+
// scrolled-to-bottom path to force the final section active.
|
|
80
|
+
const lastTracked = tracked[tracked.length - 1] as Tracked;
|
|
81
|
+
|
|
82
|
+
// Link lookup by slug (CSS.escape on every slug used in a selector).
|
|
83
|
+
function linkFor(slug: string): HTMLElement | null {
|
|
84
|
+
return root!.querySelector<HTMLElement>(
|
|
85
|
+
`a[data-section-id="${CSS.escape(slug)}"]`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Which tracked headings are currently intersecting; recompute reads `tracked`
|
|
90
|
+
// (document order) and filters by membership so `visible` is document-ordered
|
|
91
|
+
// too — pickActive's "ties resolve to document order" then actually holds.
|
|
92
|
+
const inView = new Set<Element>();
|
|
93
|
+
let active: string | null = null;
|
|
94
|
+
|
|
95
|
+
// Set the active slug: clear the previous link, light the next one. Toggle
|
|
96
|
+
// ONLY class/attr on existing SSR links → no layout shift. Shared by the
|
|
97
|
+
// observer path (via recompute) and the scrolled-to-bottom path below.
|
|
98
|
+
function setActive(next: string | null): void {
|
|
99
|
+
if (next === active) return;
|
|
100
|
+
if (active !== null) {
|
|
101
|
+
const prevLink = linkFor(active);
|
|
102
|
+
if (prevLink) {
|
|
103
|
+
prevLink.classList.remove('active');
|
|
104
|
+
prevLink.removeAttribute('aria-current');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (next !== null) {
|
|
108
|
+
const nextLink = linkFor(next);
|
|
109
|
+
if (nextLink) {
|
|
110
|
+
nextLink.classList.add('active');
|
|
111
|
+
nextLink.setAttribute('aria-current', 'page');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
active = next;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function recompute(): void {
|
|
118
|
+
// Iterate `tracked` (document order) filtering by inView — NOT the Set
|
|
119
|
+
// (insertion order) — so equal-top ties resolve to document order.
|
|
120
|
+
const visible: VisibleHeading[] = [];
|
|
121
|
+
for (const t of tracked) {
|
|
122
|
+
if (inView.has(t.el)) {
|
|
123
|
+
visible.push({ slug: t.slug, top: t.el.getBoundingClientRect().top });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
setActive(pickActive(visible, active));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const observer = new IntersectionObserver(
|
|
130
|
+
(entries) => {
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (entry.isIntersecting) inView.add(entry.target);
|
|
133
|
+
else inView.delete(entry.target);
|
|
134
|
+
}
|
|
135
|
+
recompute();
|
|
136
|
+
},
|
|
137
|
+
// A negative bottom margin biases "active" toward the heading nearest the
|
|
138
|
+
// top of the viewport (the section you're reading INTO), not the last one
|
|
139
|
+
// peeking in at the bottom.
|
|
140
|
+
{ rootMargin: '0px 0px -70% 0px', threshold: 0 },
|
|
141
|
+
);
|
|
142
|
+
for (const t of tracked) observer.observe(t.el);
|
|
143
|
+
|
|
144
|
+
// Last-section reachability: the -70% bottom margin confines "active" to the
|
|
145
|
+
// top 30% of the viewport, so a final heading with little content below it
|
|
146
|
+
// can never scroll INTO that zone — it would never light. When the page is
|
|
147
|
+
// scrolled to the bottom, force the LAST tracked heading active so the foot
|
|
148
|
+
// of the chapter always highlights its own section. Passive listeners (no
|
|
149
|
+
// preventDefault) keep scrolling smooth.
|
|
150
|
+
function onScroll(): void {
|
|
151
|
+
const atBottom =
|
|
152
|
+
window.innerHeight + window.scrollY >=
|
|
153
|
+
document.documentElement.scrollHeight - 2;
|
|
154
|
+
if (atBottom) setActive(lastTracked.slug);
|
|
155
|
+
}
|
|
156
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
157
|
+
window.addEventListener('resize', onScroll, { passive: true });
|
|
158
|
+
|
|
159
|
+
return () => {
|
|
160
|
+
observer.disconnect();
|
|
161
|
+
window.removeEventListener('scroll', onScroll);
|
|
162
|
+
window.removeEventListener('resize', onScroll);
|
|
163
|
+
};
|
|
164
|
+
}, [headings]);
|
|
165
|
+
|
|
166
|
+
// A zero-footprint anchor: the island renders only this <span> so it can find
|
|
167
|
+
// [data-section-map-root] via closest(); all visible nav links are SSR'd by
|
|
168
|
+
// SectionMap.astro. No markup injected → no layout shift.
|
|
169
|
+
return <span ref={ref} class="section-map-mount" hidden aria-hidden="true" />;
|
|
170
|
+
}
|
package/components/Sidebar.astro
CHANGED
|
@@ -28,7 +28,7 @@ import { academicPartHeading } from '../src/lib/academic-parts';
|
|
|
28
28
|
const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
|
|
29
29
|
const siteTitle = bookConfig.title ?? 'Book';
|
|
30
30
|
const siteSubtitle = bookConfig.subtitle ?? 'A scaffold-astro book';
|
|
31
|
-
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|
31
|
+
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
|
|
32
32
|
|
|
33
33
|
// Academic profile: part is a string enum. `academicParts` (schemas.ts) is
|
|
34
34
|
// the canonical order, shared with the renderer and ChapterHeader (#95); the
|
package/components/Term.astro
CHANGED
|
@@ -17,7 +17,7 @@ interface Props {
|
|
|
17
17
|
id: string;
|
|
18
18
|
}
|
|
19
19
|
const { id } = Astro.props;
|
|
20
|
-
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|
20
|
+
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
|
|
21
21
|
---
|
|
22
22
|
<a href={`${baseUrl}glossary#term-${id}`} class="term-link"><slot /></a>
|
|
23
23
|
|
|
@@ -20,7 +20,7 @@ const tipsModules = import.meta.glob<{ default: Array<{ n: number; title: string
|
|
|
20
20
|
{ eager: true },
|
|
21
21
|
);
|
|
22
22
|
const tips = tipsModules['/src/data/tips.json']?.default ?? [];
|
|
23
|
-
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|
23
|
+
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
|
|
24
24
|
---
|
|
25
25
|
<aside class="tips-card" role="contentinfo">
|
|
26
26
|
<h2 class="tips-card-title">Tips</h2>
|
package/components/WeekRef.astro
CHANGED
|
@@ -20,7 +20,9 @@ interface Props {
|
|
|
20
20
|
}
|
|
21
21
|
const { week } = Astro.props;
|
|
22
22
|
const slug = `week${String(week).padStart(2, '0')}`;
|
|
23
|
-
|
|
23
|
+
// #142: prefix BASE_URL so the chapter link stays inside a non-root deploy base.
|
|
24
|
+
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
|
|
25
|
+
const href = `${baseUrl}chapters/${slug}/`;
|
|
24
26
|
---
|
|
25
27
|
<aside class="week-ref">
|
|
26
28
|
<span class="week-ref-arrow" aria-hidden="true">→</span>
|
package/components/XRef.astro
CHANGED
|
@@ -44,9 +44,13 @@ interface Props {
|
|
|
44
44
|
|
|
45
45
|
const { id } = Astro.props;
|
|
46
46
|
const entry = map[id];
|
|
47
|
+
// #142: labels.json stores a base-less `chapters/<slug>#<id>` ref; prefix
|
|
48
|
+
// BASE_URL at render. The leading-slash strip tolerates a labels.json built
|
|
49
|
+
// by an older (pre-#142) build-labels that still emits a root-absolute href.
|
|
50
|
+
const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
|
|
47
51
|
---
|
|
48
52
|
{entry ? (
|
|
49
|
-
<a href={entry.href} class="xref">{entry.display}</a>
|
|
53
|
+
<a href={`${baseUrl}${entry.href.replace(/^\//, '')}`} class="xref">{entry.display}</a>
|
|
50
54
|
) : (
|
|
51
55
|
<span class="xref xref-unknown" title={`Unknown label: ${id}`}>[?{id}]</span>
|
|
52
56
|
)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as preact from 'preact';
|
|
2
|
-
import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-
|
|
3
|
-
import '../schemas-
|
|
2
|
+
import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-X9IrX1G3.js';
|
|
3
|
+
import '../schemas-CKipJ5Ie.js';
|
|
4
4
|
import 'astro/zod';
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as preact from 'preact';
|
|
2
|
-
import { F as FlashcardRef } from '../flashcards-
|
|
3
|
-
import '../schemas-
|
|
2
|
+
import { F as FlashcardRef } from '../flashcards-okekZcl8.js';
|
|
3
|
+
import '../schemas-CKipJ5Ie.js';
|
|
4
4
|
import 'astro/zod';
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as preact from 'preact';
|
|
2
|
+
import { MarkdownHeading } from 'astro';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/** TOC headings (tocHeadings output) — slug/depth/text only, serializable. */
|
|
6
|
+
headings: MarkdownHeading[];
|
|
7
|
+
}
|
|
8
|
+
declare function SectionMap({ headings }: Props): preact.JSX.Element;
|
|
9
|
+
|
|
10
|
+
export { SectionMap as default };
|