@brandon_m_behring/book-scaffold-astro 4.28.0 → 4.30.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/CLAUDE.md +4 -1
- package/components/BookLink.astro +3 -2
- package/dist/index.d.ts +8 -70
- package/dist/index.mjs +21 -4
- package/dist/lib/nav-href.d.ts +66 -0
- package/dist/lib/nav-href.mjs +44 -0
- package/dist/schemas.d.ts +1 -1
- package/dist/{types-CZrkqzpC.d.ts → types-DgSlAew3.d.ts} +32 -8
- package/package.json +7 -4
- package/recipes/09-validation.md +51 -4
- package/recipes/22-responsive-nav-and-multibook-routing.md +5 -0
- package/scripts/authored-links.mjs +328 -0
- package/scripts/build-labels.mjs +88 -34
- package/scripts/resolve-book-config.mjs +90 -2
- package/scripts/validate.mjs +328 -44
- package/scripts/walk-mdx.mjs +122 -8
- package/src/lib/book-link.ts +25 -5
package/CLAUDE.md
CHANGED
|
@@ -108,7 +108,9 @@ Two callout families coexist. Authors import what they need.
|
|
|
108
108
|
|
|
109
109
|
**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.
|
|
110
110
|
|
|
111
|
-
**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`).
|
|
111
|
+
**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; #147 adds `{ url, labels }` entries so `validate` checks literal fragment targets against a vendored sibling `labels.json`, while legacy URL strings warn/skip target validation; `<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`).
|
|
112
|
+
|
|
113
|
+
**Cross-book heading indexes (#147):** `book-scaffold build-labels` indexes h2–h6 with Astro's heading collector (inline formatting/smartypants plus GitHub duplicate slugs), alongside the historical component IDs. Emitted hrefs are base-less and resolve the evaluated `chapterRoute` / `bookField`; nested content IDs keep their directory. Heading keys are opaque and path-qualified so the same fragment can exist in multiple chapters; `validate` matches exact normalized href values, while component keys stay backward-compatible for XRef. This makes a generated sibling `labels.json` usable for literal `<BookLink ... to="...#anchor">` validation without assuming `/chapters/`. h1 stays excluded as the chapter title.
|
|
112
114
|
|
|
113
115
|
**Interactive demo substrate (#143; opt-in):** `DemoFrame`, `Slider`, `StatCards`, and `useThemeColors` are named exports from `@brandon_m_behring/book-scaffold-astro/demo`. Import `@brandon_m_behring/book-scaffold-astro/styles/demo.css` on the page that mounts the consumer-owned Preact island; it is never included by a profile and nothing auto-mounts. The substrate owns figure/label/metric semantics, focus/reduced-motion styling, SVG token helpers, and theme-token resolution. Consumers own all data, kernels, charts, and domain interaction policy. See Recipe 23.
|
|
114
116
|
|
|
@@ -199,6 +201,7 @@ the full precedence and customization contract.
|
|
|
199
201
|
- Literal `<Theorem n>` values that disagree with `labels.json` (dynamic expressions and `label=` overrides are skipped)
|
|
200
202
|
- Missing `<Figure src>` files under `public/`
|
|
201
203
|
- Internal markdown links that don't resolve
|
|
204
|
+
- Authored root-absolute Markdown/HTML/JSX `href` or `src` targets that escape a configured non-root Astro `base` (#190). Structurally parsed and decoded literal targets fail with file/line context; root-base books, external/protocol-relative URLs, fragments, dynamic JSX expressions, and already base-prefixed targets are unaffected. `rel="external"` does not change URL resolution or opt out. Validation never rewrites prose.
|
|
202
205
|
- Study-guide questions (v4.17.0, #112) — a question whose frontmatter `domain` isn't in `examDomains`, and duplicate question `id`s
|
|
203
206
|
|
|
204
207
|
Missing `src/data/labels.json` or `references.json` self-heals before checks by running the corresponding package build script; an existing artifact is never rewritten implicitly.
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* build (fail-loud, see `src/lib/book-link`), never a dead cross-origin link.
|
|
12
12
|
*
|
|
13
13
|
* `to` is a path within the sibling book (its router shape), e.g.
|
|
14
|
-
* `chapters/<slug>/#<id>`.
|
|
15
|
-
* validate
|
|
14
|
+
* `chapters/<slug>/#<id>`. A `{ url, labels }` registry descriptor lets
|
|
15
|
+
* `book-scaffold validate` check literal path/fragment targets against a
|
|
16
|
+
* vendored sibling `labels.json`; URL strings remain runtime-compatible.
|
|
16
17
|
*
|
|
17
18
|
* Usage:
|
|
18
19
|
* For design depth see
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { AstroUserConfig, AstroIntegration, MarkdownHeading } from 'astro';
|
|
2
|
-
import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer,
|
|
3
|
-
export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, F as FreshnessAffordance, i as FrontmatterRouteConfig, N as NUMBER_STYLES, j as NumberStyle, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, R as ReleaseStatusConfig, m as RouteToggles, S as SecurityHeadersConfig, n as StatusBadge,
|
|
2
|
+
import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer, p as SiblingBooks, r as Style } from './types-DgSlAew3.js';
|
|
3
|
+
export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, F as FreshnessAffordance, i as FrontmatterRouteConfig, N as NUMBER_STYLES, j as NumberStyle, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, R as ReleaseStatusConfig, m as RouteToggles, S as SecurityHeadersConfig, n as SiblingBookDescriptor, o as SiblingBookEntry, q as StatusBadge, s as StyleInput, V as VolatilityBadge, t as composeStyles, u as defineProfile, v as defineStyle, w as normalizeFrontmatterConfig, x as resolvePreset, y as resolveProfile } from './types-DgSlAew3.js';
|
|
4
4
|
import { E as volatilityLevels, c as academicParts, Q as Question } from './schemas-CKipJ5Ie.js';
|
|
5
5
|
export { A as AcademicChapter, B as BloomLevel, C as CourseNotesChapter, G as GlossaryTerm, M as MinimalChapter, P as Provenance, a as QuestionType, R as ResearchPortfolioChapter, T as ToolsChapter, b as academicChapterSchema, d as bloomLevels, e as changeKinds, f as changelogSchema, g as chapterStatus, h as citationBackstops, i as courseNotesChapterSchema, j as glossarySchema, l as layoutModes, m as minimalChapterSchema, p as patternCategories, k as patternsSchema, n as provenanceObject, o as provenanceSchema, q as questionDifficulties, r as questionSchema, s as questionTypes, t as refineQuestion, u as refinedQuestionSchema, v as researchPortfolioChapterSchema, w as sourceTiers, x as sourceTiersResearch, y as sourcesSchema, z as toolSlugs, D as toolsChapterSchema } from './schemas-CKipJ5Ie.js';
|
|
6
6
|
export { KIND_LABEL, ResolvedTheoremLabel, THEOREM_KINDS, TheoremKind, TheoremLabelProps, resolveTheoremNumber, theoremLabel } from './lib/theorem-label.js';
|
|
7
7
|
export { ssmMacros } from './lib/katex-macros.js';
|
|
8
|
+
export { ChapterLike, apparatusHref, baseNoSlash, bookOf, chapterHref, isCurrentChapter, normalizeBase, slugOf } from './lib/nav-href.js';
|
|
8
9
|
export { D as DomainScore, E as ExamBlueprint, a as ExamQuestion, b as ExamResult, R as RoutingChapter, c as buildExamManifest, d as deriveDomainRouting, s as sampleExam, e as scoreExam, f as shuffle, g as spreadBlueprint } from './exam-manifest-X9IrX1G3.js';
|
|
9
10
|
export { F as FlashcardRef, b as buildFlashcardDeck } from './flashcards-okekZcl8.js';
|
|
10
11
|
import 'astro/zod';
|
|
@@ -303,75 +304,12 @@ declare function assertEnumProp<T extends string>(value: unknown, allowed: reado
|
|
|
303
304
|
* sibling redeploys or extracts to its own repo. An unknown `book` THROWS
|
|
304
305
|
* rather than emitting a dead cross-origin link (fail-loud, like #109).
|
|
305
306
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
307
|
+
* #147 extends each registry entry from a URL string to the backward-compatible
|
|
308
|
+
* `{ url, labels? }` descriptor. Runtime href resolution uses `url`; the
|
|
309
|
+
* validator uses `labels` to check literal sibling path/fragment targets.
|
|
308
310
|
*/
|
|
309
|
-
declare function resolveBookHref(siblingBooks: Record<string, string> | null | undefined, book: string, to: string): string;
|
|
310
311
|
|
|
311
|
-
|
|
312
|
-
* src/lib/nav-href.ts — pure route-href resolver (#80 multi-book navigation).
|
|
313
|
-
*
|
|
314
|
-
* No `astro:content` import (mirrors chapter-sort.ts) so tsup can include it in
|
|
315
|
-
* the DTS bundle without dragging Astro virtual modules into the build graph.
|
|
316
|
-
* The nav components (Sidebar, ChapterNav, NavContent, …) call these helpers
|
|
317
|
-
* instead of hardcoding the single-book `/chapters/<id>/` URL shape — so ONE set
|
|
318
|
-
* of components serves both a single-book site (the default pattern) and a
|
|
319
|
-
* multi-book consumer whose chapters render at `/<book>/<slug>/`.
|
|
320
|
-
*
|
|
321
|
-
* Patterns are base-relative TOKEN STRINGS (a resolver *function* could not
|
|
322
|
-
* survive the book-config virtual module's `JSON.stringify`, so the config
|
|
323
|
-
* surface is a declarative string resolved here):
|
|
324
|
-
* :id → entry.id verbatim, slashes preserved e.g. 'kg/01-intro'
|
|
325
|
-
* :book → the entry's book name (see `bookField`), or '' e.g. 'kg'
|
|
326
|
-
* :slug → entry.id with a leading '<book>/' stripped e.g. '01-intro'
|
|
327
|
-
* BASE_URL is applied here, so patterns are written base-relative (lead '/').
|
|
328
|
-
*
|
|
329
|
-
* Defaults reproduce the single-book behavior BYTE-FOR-BYTE:
|
|
330
|
-
* chapterRoute = '/chapters/:id/' → `${base}chapters/${id}/`
|
|
331
|
-
* bookField = 'book' (academic/tools schemas have no `book` → bookOf
|
|
332
|
-
* returns null → "show all chapters", today's nav)
|
|
333
|
-
* apparatusRoute = '/:route/' → `${base}<route>/`
|
|
334
|
-
*/
|
|
335
|
-
/** Minimal shape the resolver needs from a chapter collection entry. */
|
|
336
|
-
interface ChapterLike {
|
|
337
|
-
id: string;
|
|
338
|
-
data: Record<string, unknown>;
|
|
339
|
-
}
|
|
340
|
-
/**
|
|
341
|
-
* Normalize a base URL to exactly one trailing slash (`''`/`undefined` → `'/'`).
|
|
342
|
-
*
|
|
343
|
-
* v4.27.0 (#182): promoted from this module's private helper to THE shared
|
|
344
|
-
* normalizer — 18 `.astro` files previously inlined the same normalization in
|
|
345
|
-
* three regex idioms. Astro does not guarantee a trailing slash on `base`
|
|
346
|
-
* (`'/foo'` is a documented form), so every `import.meta.env.BASE_URL` read
|
|
347
|
-
* must pass through here before href composition: `${normalizeBase(...)}chapters/`.
|
|
348
|
-
* Takes the base as a PARAMETER because src/lib ships pre-compiled in dist/,
|
|
349
|
-
* where Vite's import.meta.env replacement cannot reach (see exam-manifest.ts).
|
|
350
|
-
*/
|
|
351
|
-
declare function normalizeBase(baseUrl: string | undefined): string;
|
|
352
|
-
/**
|
|
353
|
-
* The composing variant (#182): strip ALL trailing slashes (`'/'` → `''`,
|
|
354
|
-
* `'/foo/'` → `'/foo'`) for `${baseNoSlash(...)}/answers`-style templates
|
|
355
|
-
* where the literal supplies the slash (Rationale.astro's route detection).
|
|
356
|
-
*/
|
|
357
|
-
declare function baseNoSlash(baseUrl: string | undefined): string;
|
|
358
|
-
/**
|
|
359
|
-
* The entry's book name from `data[bookField]`, or `null` when absent/blank.
|
|
360
|
-
* Single-book schemas (academic/tools/minimal) have no such field → `null`,
|
|
361
|
-
* which callers read as "this is the only book — show every chapter".
|
|
362
|
-
*/
|
|
363
|
-
declare function bookOf(entry: ChapterLike, bookField?: string): string | null;
|
|
364
|
-
/** `entry.id` with a leading `'<book>/'` stripped (multi-book) or unchanged. */
|
|
365
|
-
declare function slugOf(entry: ChapterLike, bookField?: string): string;
|
|
366
|
-
/** Resolve a chapter entry to a base-prefixed href via the `chapterRoute` pattern. */
|
|
367
|
-
declare function chapterHref(entry: ChapterLike, pattern?: string, baseUrl?: string, bookField?: string): string;
|
|
368
|
-
/**
|
|
369
|
-
* Resolve a per-book apparatus route (glossary / practice-exam / flashcards /
|
|
370
|
-
* answers) to a base-prefixed href via the `apparatusRoute` pattern.
|
|
371
|
-
*/
|
|
372
|
-
declare function apparatusHref(route: string, book: string | null, pattern?: string, baseUrl?: string): string;
|
|
373
|
-
/** Whether `entry` is the page at `currentPath` (trailing-slash tolerant). */
|
|
374
|
-
declare function isCurrentChapter(entry: ChapterLike, currentPath: string, pattern?: string, baseUrl?: string, bookField?: string): boolean;
|
|
312
|
+
declare function resolveBookHref(siblingBooks: SiblingBooks | null | undefined, book: string, to: string): string;
|
|
375
313
|
|
|
376
314
|
/**
|
|
377
315
|
* exam-domains — validate a question's `domain` against the consumer's closed
|
|
@@ -629,4 +567,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
|
|
|
629
567
|
*/
|
|
630
568
|
declare function defineTips(opts: TipsConfigInput): TipsConfig;
|
|
631
569
|
|
|
632
|
-
export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions,
|
|
570
|
+
export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, type PartReviewGroup, type PartReviewSelection, Question, type ReviewChapter, type ReviewExercise, SiblingBooks, Style, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VisibleHeading, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, assertEnumProp, assertKnownDomain, bookScaffoldIntegration, buildGithubUrl, chapterLabel, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, deriveObjectiveMap, distinctChaptersSorted, fallbackChaptersRenderer, freshnessLabel, getFreshness, groupByChapter, groupByDomain, minimalStyle, originUrlFromGitConfig, parseRepoSlug, pickActive, researchPortfolioChaptersRenderer, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, selectPartExercises, sortQuestions, tocHeadings, toolsChaptersRenderer, toolsStyle, volatilityLevels };
|
package/dist/index.mjs
CHANGED
|
@@ -1379,7 +1379,13 @@ function bookScaffoldIntegration(opts) {
|
|
|
1379
1379
|
}
|
|
1380
1380
|
};
|
|
1381
1381
|
Object.defineProperty(integration, "__bookScaffoldResolvedConfig", {
|
|
1382
|
-
value: Object.freeze({
|
|
1382
|
+
value: Object.freeze({
|
|
1383
|
+
preset: profile,
|
|
1384
|
+
numberStyle,
|
|
1385
|
+
siblingBooks: siblingBooks ?? {},
|
|
1386
|
+
chapterRoute: chapterRoute ?? "/chapters/:id/",
|
|
1387
|
+
bookField: bookField ?? "book"
|
|
1388
|
+
}),
|
|
1383
1389
|
enumerable: false,
|
|
1384
1390
|
configurable: false,
|
|
1385
1391
|
writable: false
|
|
@@ -1528,7 +1534,7 @@ async function defineBookConfig(opts) {
|
|
|
1528
1534
|
// v4.15.0 (#109): repo/branch override; integration auto-detects when undefined.
|
|
1529
1535
|
githubRepo: opts.githubRepo,
|
|
1530
1536
|
githubBranch: opts.githubBranch,
|
|
1531
|
-
// v4.16.0 (#96): cross-book link registry.
|
|
1537
|
+
// v4.16.0 (#96), extended in #147: cross-book link registry.
|
|
1532
1538
|
siblingBooks: opts.siblingBooks,
|
|
1533
1539
|
// v4.17.0 (#112): per-book exam-domain taxonomy for the questions collection.
|
|
1534
1540
|
examDomains: opts.examDomains,
|
|
@@ -1721,14 +1727,25 @@ function assertEnumProp(value, allowed, ctx) {
|
|
|
1721
1727
|
init_katex_macros();
|
|
1722
1728
|
|
|
1723
1729
|
// src/lib/book-link.ts
|
|
1730
|
+
function entryUrl(entry) {
|
|
1731
|
+
if (typeof entry === "string") return entry;
|
|
1732
|
+
return entry?.url;
|
|
1733
|
+
}
|
|
1724
1734
|
function resolveBookHref(siblingBooks, book, to) {
|
|
1725
|
-
const
|
|
1726
|
-
if (!
|
|
1735
|
+
const registered = siblingBooks !== null && siblingBooks !== void 0 && Object.prototype.hasOwnProperty.call(siblingBooks, book);
|
|
1736
|
+
if (!registered) {
|
|
1727
1737
|
const known = siblingBooks ? Object.keys(siblingBooks) : [];
|
|
1728
1738
|
throw new Error(
|
|
1729
1739
|
`<BookLink book="${book}">: unknown sibling book. Register it in defineBookConfig({ siblingBooks: { "${book}": "https://\u2026" } })` + (known.length ? ` (known: ${known.join(", ")})` : "") + "."
|
|
1730
1740
|
);
|
|
1731
1741
|
}
|
|
1742
|
+
const entry = siblingBooks[book];
|
|
1743
|
+
const base = entryUrl(entry);
|
|
1744
|
+
if (typeof base !== "string" || base.length === 0) {
|
|
1745
|
+
throw new Error(
|
|
1746
|
+
`<BookLink book="${book}">: invalid siblingBooks entry. Expected a URL string or { url: "https://\u2026", labels?: "./path/to/labels.json" }.`
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1732
1749
|
return `${base.replace(/\/+$/, "")}/${to.replace(/^\/+/, "")}`;
|
|
1733
1750
|
}
|
|
1734
1751
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/lib/nav-href.ts — pure route-href resolver (#80 multi-book navigation).
|
|
3
|
+
*
|
|
4
|
+
* No `astro:content` import (mirrors chapter-sort.ts) so tsup can include it in
|
|
5
|
+
* the DTS bundle without dragging Astro virtual modules into the build graph.
|
|
6
|
+
* The nav components (Sidebar, ChapterNav, NavContent, …) call these helpers
|
|
7
|
+
* instead of hardcoding the single-book `/chapters/<id>/` URL shape — so ONE set
|
|
8
|
+
* of components serves both a single-book site (the default pattern) and a
|
|
9
|
+
* multi-book consumer whose chapters render at `/<book>/<slug>/`.
|
|
10
|
+
*
|
|
11
|
+
* Patterns are base-relative TOKEN STRINGS (a resolver *function* could not
|
|
12
|
+
* survive the book-config virtual module's `JSON.stringify`, so the config
|
|
13
|
+
* surface is a declarative string resolved here):
|
|
14
|
+
* :id → entry.id verbatim, slashes preserved e.g. 'kg/01-intro'
|
|
15
|
+
* :book → the entry's book name (see `bookField`), or '' e.g. 'kg'
|
|
16
|
+
* :slug → entry.id with a leading '<book>/' stripped e.g. '01-intro'
|
|
17
|
+
* BASE_URL is applied here, so patterns are written base-relative (lead '/').
|
|
18
|
+
*
|
|
19
|
+
* Defaults reproduce the single-book behavior BYTE-FOR-BYTE:
|
|
20
|
+
* chapterRoute = '/chapters/:id/' → `${base}chapters/${id}/`
|
|
21
|
+
* bookField = 'book' (academic/tools schemas have no `book` → bookOf
|
|
22
|
+
* returns null → "show all chapters", today's nav)
|
|
23
|
+
* apparatusRoute = '/:route/' → `${base}<route>/`
|
|
24
|
+
*/
|
|
25
|
+
/** Minimal shape the resolver needs from a chapter collection entry. */
|
|
26
|
+
interface ChapterLike {
|
|
27
|
+
id: string;
|
|
28
|
+
data: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Normalize a base URL to exactly one trailing slash (`''`/`undefined` → `'/'`).
|
|
32
|
+
*
|
|
33
|
+
* v4.27.0 (#182): promoted from this module's private helper to THE shared
|
|
34
|
+
* normalizer — 18 `.astro` files previously inlined the same normalization in
|
|
35
|
+
* three regex idioms. Astro does not guarantee a trailing slash on `base`
|
|
36
|
+
* (`'/foo'` is a documented form), so every `import.meta.env.BASE_URL` read
|
|
37
|
+
* must pass through here before href composition: `${normalizeBase(...)}chapters/`.
|
|
38
|
+
* Takes the base as a PARAMETER because src/lib ships pre-compiled in dist/,
|
|
39
|
+
* where Vite's import.meta.env replacement cannot reach (see exam-manifest.ts).
|
|
40
|
+
*/
|
|
41
|
+
declare function normalizeBase(baseUrl: string | undefined): string;
|
|
42
|
+
/**
|
|
43
|
+
* The composing variant (#182): strip ALL trailing slashes (`'/'` → `''`,
|
|
44
|
+
* `'/foo/'` → `'/foo'`) for `${baseNoSlash(...)}/answers`-style templates
|
|
45
|
+
* where the literal supplies the slash (Rationale.astro's route detection).
|
|
46
|
+
*/
|
|
47
|
+
declare function baseNoSlash(baseUrl: string | undefined): string;
|
|
48
|
+
/**
|
|
49
|
+
* The entry's book name from `data[bookField]`, or `null` when absent/blank.
|
|
50
|
+
* Single-book schemas (academic/tools/minimal) have no such field → `null`,
|
|
51
|
+
* which callers read as "this is the only book — show every chapter".
|
|
52
|
+
*/
|
|
53
|
+
declare function bookOf(entry: ChapterLike, bookField?: string): string | null;
|
|
54
|
+
/** `entry.id` with a leading `'<book>/'` stripped (multi-book) or unchanged. */
|
|
55
|
+
declare function slugOf(entry: ChapterLike, bookField?: string): string;
|
|
56
|
+
/** Resolve a chapter entry to a base-prefixed href via the `chapterRoute` pattern. */
|
|
57
|
+
declare function chapterHref(entry: ChapterLike, pattern?: string, baseUrl?: string, bookField?: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a per-book apparatus route (glossary / practice-exam / flashcards /
|
|
60
|
+
* answers) to a base-prefixed href via the `apparatusRoute` pattern.
|
|
61
|
+
*/
|
|
62
|
+
declare function apparatusHref(route: string, book: string | null, pattern?: string, baseUrl?: string): string;
|
|
63
|
+
/** Whether `entry` is the page at `currentPath` (trailing-slash tolerant). */
|
|
64
|
+
declare function isCurrentChapter(entry: ChapterLike, currentPath: string, pattern?: string, baseUrl?: string, bookField?: string): boolean;
|
|
65
|
+
|
|
66
|
+
export { type ChapterLike, apparatusHref, baseNoSlash, bookOf, chapterHref, isCurrentChapter, normalizeBase, slugOf };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/lib/nav-href.ts
|
|
2
|
+
function normalizeBase(baseUrl) {
|
|
3
|
+
return (baseUrl || "/").replace(/\/*$/, "/");
|
|
4
|
+
}
|
|
5
|
+
function baseNoSlash(baseUrl) {
|
|
6
|
+
return (baseUrl || "/").replace(/\/+$/, "");
|
|
7
|
+
}
|
|
8
|
+
var normBase = normalizeBase;
|
|
9
|
+
function fillTokens(pattern, tokens) {
|
|
10
|
+
return pattern.replace(/:(book|slug|route|id)\b/g, (_m, k) => tokens[k] ?? "");
|
|
11
|
+
}
|
|
12
|
+
function bookOf(entry, bookField = "book") {
|
|
13
|
+
const v = entry.data[bookField];
|
|
14
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
15
|
+
}
|
|
16
|
+
function slugOf(entry, bookField = "book") {
|
|
17
|
+
const book = bookOf(entry, bookField);
|
|
18
|
+
return book && entry.id.startsWith(`${book}/`) ? entry.id.slice(book.length + 1) : entry.id;
|
|
19
|
+
}
|
|
20
|
+
function chapterHref(entry, pattern = "/chapters/:id/", baseUrl = "/", bookField = "book") {
|
|
21
|
+
const path = fillTokens(pattern, {
|
|
22
|
+
id: entry.id,
|
|
23
|
+
book: bookOf(entry, bookField) ?? "",
|
|
24
|
+
slug: slugOf(entry, bookField)
|
|
25
|
+
}).replace(/\/{2,}/g, "/").replace(/^\//, "");
|
|
26
|
+
return normBase(baseUrl) + path;
|
|
27
|
+
}
|
|
28
|
+
function apparatusHref(route, book, pattern = "/:route/", baseUrl = "/") {
|
|
29
|
+
const path = fillTokens(pattern, { route, book: book ?? "" }).replace(/\/{2,}/g, "/").replace(/^\//, "");
|
|
30
|
+
return normBase(baseUrl) + path;
|
|
31
|
+
}
|
|
32
|
+
function isCurrentChapter(entry, currentPath, pattern = "/chapters/:id/", baseUrl = "/", bookField = "book") {
|
|
33
|
+
const href = chapterHref(entry, pattern, baseUrl, bookField);
|
|
34
|
+
return currentPath === href || currentPath === href.replace(/\/$/, "");
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
apparatusHref,
|
|
38
|
+
baseNoSlash,
|
|
39
|
+
bookOf,
|
|
40
|
+
chapterHref,
|
|
41
|
+
isCurrentChapter,
|
|
42
|
+
normalizeBase,
|
|
43
|
+
slugOf
|
|
44
|
+
};
|
package/dist/schemas.d.ts
CHANGED
|
@@ -487,6 +487,22 @@ declare const BOOK_PRESETS: readonly ("academic" | "tools" | "minimal" | "course
|
|
|
487
487
|
/** Theorem-family numbering strategy used by build-labels and validate. */
|
|
488
488
|
type NumberStyle = 'shared' | 'per-kind';
|
|
489
489
|
declare const NUMBER_STYLES: readonly ["shared", "per-kind"];
|
|
490
|
+
/**
|
|
491
|
+
* A sibling book with an optional vendored label index for cross-book
|
|
492
|
+
* `<BookLink>` validation (#147).
|
|
493
|
+
*
|
|
494
|
+
* `url` is the deployed book base, including any path prefix. `labels` is a
|
|
495
|
+
* path to that sibling's vendored `labels.json`, resolved from the consumer
|
|
496
|
+
* project root by `book-scaffold validate`.
|
|
497
|
+
*/
|
|
498
|
+
interface SiblingBookDescriptor {
|
|
499
|
+
url: string;
|
|
500
|
+
labels?: string;
|
|
501
|
+
}
|
|
502
|
+
/** Backward-compatible sibling-book registry entry. */
|
|
503
|
+
type SiblingBookEntry = string | SiblingBookDescriptor;
|
|
504
|
+
/** Registry consumed by `<BookLink>` and the pre-flight validator. */
|
|
505
|
+
type SiblingBooks = Record<string, SiblingBookEntry>;
|
|
490
506
|
/**
|
|
491
507
|
* v4.27.0 (#188): build-time security-header customization.
|
|
492
508
|
*
|
|
@@ -738,12 +754,20 @@ interface BookConfigOptions {
|
|
|
738
754
|
/** v4.15.0 (#109): branch for CodeRef/CodeBlock links. Defaults to `main`. */
|
|
739
755
|
githubBranch?: string;
|
|
740
756
|
/**
|
|
741
|
-
* v4.16.0 (#96)
|
|
742
|
-
* `<BookLink book="…" to="…" />`.
|
|
743
|
-
*
|
|
744
|
-
*
|
|
757
|
+
* v4.16.0 (#96), extended in #147: registry for cross-book
|
|
758
|
+
* `<BookLink book="…" to="…" />`. A string value remains supported. The
|
|
759
|
+
* descriptor form adds a vendored sibling labels index so literal fragment
|
|
760
|
+
* targets can be checked by `book-scaffold validate`.
|
|
761
|
+
*
|
|
762
|
+
* `url` may include a deployment path prefix. Relative `labels` paths are
|
|
763
|
+
* resolved from the consumer project root.
|
|
764
|
+
* @example
|
|
765
|
+
* siblingBooks: {
|
|
766
|
+
* legacy: 'https://legacy.example',
|
|
767
|
+
* design: { url: 'https://hub.example/books/design/', labels: './vendor/design-labels.json' },
|
|
768
|
+
* }
|
|
745
769
|
*/
|
|
746
|
-
siblingBooks?:
|
|
770
|
+
siblingBooks?: SiblingBooks;
|
|
747
771
|
/**
|
|
748
772
|
* v4.17.0 (Tier 3, #112): closed exam-domain taxonomy for the study-guide
|
|
749
773
|
* `questions` collection. A question whose `domain` is not in this list
|
|
@@ -861,8 +885,8 @@ interface BookScaffoldIntegrationOptions {
|
|
|
861
885
|
*/
|
|
862
886
|
githubRepo?: string;
|
|
863
887
|
githubBranch?: string;
|
|
864
|
-
/** v4.16.0 (#96): sibling-book
|
|
865
|
-
siblingBooks?:
|
|
888
|
+
/** v4.16.0 (#96), extended in #147: sibling-book registry for <BookLink>. */
|
|
889
|
+
siblingBooks?: SiblingBooks;
|
|
866
890
|
/** v4.17.0 (#112): closed exam-domain taxonomy for the questions collection. */
|
|
867
891
|
examDomains?: readonly string[];
|
|
868
892
|
/** v4.26.0 (#80): chapter-route token pattern, propagated via the book-config
|
|
@@ -887,4 +911,4 @@ declare function resolvePreset(explicitPreset?: BookPreset, explicitProfile?: Bo
|
|
|
887
911
|
*/
|
|
888
912
|
declare function resolveProfile(explicit?: BookProfile): BookProfile;
|
|
889
913
|
|
|
890
|
-
export { BOOK_PRESETS as B, type ChapterFor as C, type FreshnessAffordance as F, NUMBER_STYLES as N, type PartKey as P, type ReleaseStatusConfig as R, type SecurityHeadersConfig as S, type VolatilityBadge as V, BOOK_PROFILES as a, BookConfigError as b, type BookConfigOptions as c, type BookPreset as d, type BookProfile as e, type BookScaffoldIntegrationOptions as f, type BookSchemasOptions as g, type ChaptersRenderer as h, type FrontmatterRouteConfig as i, type NumberStyle as j, type PartialRouteToggles as k, type ProfileDefinition as l, type RouteToggles as m, type
|
|
914
|
+
export { BOOK_PRESETS as B, type ChapterFor as C, type FreshnessAffordance as F, NUMBER_STYLES as N, type PartKey as P, type ReleaseStatusConfig as R, type SecurityHeadersConfig as S, type VolatilityBadge as V, BOOK_PROFILES as a, BookConfigError as b, type BookConfigOptions as c, type BookPreset as d, type BookProfile as e, type BookScaffoldIntegrationOptions as f, type BookSchemasOptions as g, type ChaptersRenderer as h, type FrontmatterRouteConfig as i, type NumberStyle as j, type PartialRouteToggles as k, type ProfileDefinition as l, type RouteToggles as m, type SiblingBookDescriptor as n, type SiblingBookEntry as o, type SiblingBooks as p, type StatusBadge as q, type Style as r, type StyleInput as s, composeStyles as t, defineProfile as u, defineStyle as v, normalizeFrontmatterConfig as w, resolvePreset as x, resolveProfile as y };
|
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 with five typed presets, Tufte typography, citations, search, PDF, and Cloudflare deployment.",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.30.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Brandon Behring",
|
|
@@ -190,18 +190,21 @@
|
|
|
190
190
|
},
|
|
191
191
|
"rehype-katex": {
|
|
192
192
|
"optional": true
|
|
193
|
-
},
|
|
194
|
-
"remark-math": {
|
|
195
|
-
"optional": true
|
|
196
193
|
}
|
|
197
194
|
},
|
|
198
195
|
"dependencies": {
|
|
196
|
+
"@astrojs/markdown-remark": "^7.1.2",
|
|
199
197
|
"@astrojs/sitemap": "^3.6.1",
|
|
200
198
|
"@citation-js/core": "^0.7.21",
|
|
201
199
|
"@citation-js/plugin-bibtex": "^0.7.21",
|
|
202
200
|
"@fontsource-variable/roboto": "^5.2.10",
|
|
203
201
|
"@fontsource-variable/source-code-pro": "^5.2.7",
|
|
204
202
|
"pagefind": "^1.5.2",
|
|
203
|
+
"parse5": "^7.3.0",
|
|
204
|
+
"remark-math": "^6.0.0",
|
|
205
|
+
"remark-mdx": "^3.1.1",
|
|
206
|
+
"remark-parse": "^11.0.0",
|
|
207
|
+
"unified": "^11.0.5",
|
|
205
208
|
"vite": "^7.3.2",
|
|
206
209
|
"yaml": "^2.9.0"
|
|
207
210
|
},
|
package/recipes/09-validation.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Profile**: any (Cite checks skip under non-academic).
|
|
4
4
|
|
|
5
|
-
**TL;DR**: `npm run validate` runs `scripts/validate.mjs` against
|
|
5
|
+
**TL;DR**: `npm run validate` runs `scripts/validate.mjs` against chapter MD/MDX files (plus its question-collection checks). It first regenerates missing `labels.json` and `references.json`, then catches typo'd bibkeys / XRef ids / Figure paths / internal links that `astro build` would either miss or surface with poor context. Auto-runs as `prebuild`; recommend wiring into pre-commit too.
|
|
6
6
|
|
|
7
7
|
## What gets checked
|
|
8
8
|
|
|
@@ -14,12 +14,59 @@
|
|
|
14
14
|
| `[text](/internal-link)` resolves to known chapter slug or top-level route | all | Astro won't fail on dead internal links. Warning, not error (regex misses dynamic routes). |
|
|
15
15
|
| `<CodeRef path="..." line={N} />` path exists + line in bounds | all, if `BOOK_REPO_ROOT` set | Catches stale line numbers after code refactors in the paired experiments/ repo. |
|
|
16
16
|
| `<Theorem>` has a resolvable `kind=` (or legacy `type=`); an id'd theorem resolves in `labels.json`; a literal `n=` agrees with the index (#121, #126, #176) | all | An absent kind throws at build with less context; an unindexed id silently renders the heading unnumbered; a stale literal `n=` contradicts the heading/XRefs. Dynamic expressions and `label=` overrides are skipped. |
|
|
17
|
-
| `<BookLink book= to=>` both present; `book=` registered in
|
|
17
|
+
| `<BookLink book= to=>` both present; `book=` registered; literal fragment target resolves in a declared sibling labels index (#96, #147) | all | Pre-flights the component's build-time throw and dead cross-book anchors across all files at once. |
|
|
18
18
|
| Questions collection: unique `id`s + `domain` in `examDomains` (#112); `<Rationale appendix>` carries `for=` (#114, v4.21.0) | all, when `src/content/questions/` exists | Duplicate ids break the appendix/flashcards cross-ref key; an unregistered domain throws one-at-a-time at build; an appendix rationale without its anchor target throws at build. |
|
|
19
19
|
| `los[].anchor` ↔ `{/* anchor: <slug> */}` prose markers agree both ways (#130, v4.20.0) | all, when frontmatter has `los:` | A declared objective whose prose marker is missing/misspelled (or an orphan marker) builds green otherwise — frontmatter↔prose drift only a hand audit would catch. |
|
|
20
|
+
| Literal authored Markdown/HTML/JSX `href`/`src` stays inside a non-root Astro `base` (#190) | all | A browser resolves `/chapters/...` from the host root, bypassing Astro's deployment base and producing a convincing 404. This is a build-blocking error, not a rewrite. |
|
|
20
21
|
|
|
21
22
|
Validate also emits two **non-blocking shadow-route warnings** (exit code unaffected): a consumer-owned `src/pages/chapters/[...slug].astro` without `routes: { chapters: false }` (v4.6.0, #76), and a consumer-owned `src/pages/index.astro` without `routes: { landing: false }` (v4.20.0, #129 — Astro has announced this collision becomes a hard error). See [recipe 18](./18-chapter-route-ownership.md).
|
|
22
23
|
|
|
24
|
+
### Cross-book labels indexes (#147)
|
|
25
|
+
|
|
26
|
+
The original URL-string registry remains compatible. Use the descriptor form to opt a sibling into literal path/fragment validation:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
export default await defineBookConfig({
|
|
30
|
+
styles: [minimalStyle],
|
|
31
|
+
site: 'https://docs.example/current/',
|
|
32
|
+
siblingBooks: {
|
|
33
|
+
legacy: 'https://legacy.example/',
|
|
34
|
+
design: {
|
|
35
|
+
// Deployment bases may be path-proxied; BookLink preserves this prefix.
|
|
36
|
+
url: 'https://hub.example/library/design/',
|
|
37
|
+
// Relative paths resolve from this consumer project's root.
|
|
38
|
+
labels: './vendor/design-labels.json',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For a literal `<BookLink book="design" to="/chapters/patterns/#layered">`, validation looks up `layered` and verifies that its indexed `href` matches the authored sibling path. Leading route slashes and the optional slash immediately before `#` are normalized; the sibling deployment base is deliberately not part of the vendored index.
|
|
45
|
+
|
|
46
|
+
Generate the sibling artifact in that sibling's project with `book-scaffold build-labels`, then vendor or sync the resulting `src/data/labels.json`. The builder indexes Markdown/MDX h2–h6 headings in addition to component IDs. Heading text and anchors come from Astro's Markdown processor, so inline formatting, smartypants, and GitHub-style duplicate suffixes (`repeat`, `repeat-1`, …) match the rendered page. Its base-less hrefs also use the sibling's evaluated `chapterRoute` and `bookField`; a root-routed book configured with `chapterRoute: '/:id/'` therefore emits `chapter-id#anchor`, not `chapters/chapter-id#anchor`.
|
|
47
|
+
|
|
48
|
+
Heading-entry keys are intentionally opaque and path-qualified: `#summary` can legitimately occur in every chapter. Validation matches the normalized `href` values, so every repeated fragment remains addressable. Component-ID keys keep their historical shape for `<XRef>`; do not use a heading's internal JSON key as an XRef ID or derive it in consumer code.
|
|
49
|
+
|
|
50
|
+
A declared `labels` file that is missing, unreadable, malformed, or not a JSON object is an error. An unknown book, unknown fragment, wrong path, or malformed label entry is also an error with the source file and target. Dynamic `book=`/`to=` expressions and prop spreads cannot be evaluated safely, so they emit explicit skip warnings. URL strings and `{ url }` descriptors likewise warn for literal fragment links because no index was declared. Non-fragment route links warn and skip: labels indexes describe anchors, not every sibling route.
|
|
51
|
+
|
|
52
|
+
## Authored links under a non-root base (#190)
|
|
53
|
+
|
|
54
|
+
When evaluated `astro.config.*` sets a non-root base such as `base: '/library/books/'`, a root-absolute authored URL escapes that deployment mount:
|
|
55
|
+
|
|
56
|
+
```mdx
|
|
57
|
+
<!-- Errors: both resolve from the host root, outside /library/books/. -->
|
|
58
|
+
[Chapter one](/chapters/one/)
|
|
59
|
+
<img src="/figures/overview.svg" alt="Overview" />
|
|
60
|
+
|
|
61
|
+
<!-- Valid: explicitly remains inside the configured base. -->
|
|
62
|
+
[Chapter one](/library/books/chapters/one/)
|
|
63
|
+
<a href={`${import.meta.env.BASE_URL}references/`}>References</a>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The validator structurally parses inline Markdown links and images, Markdown reference definitions, quoted or unquoted HTML `href`/`src`, and quoted or statically braced JSX string attributes. HTML entities and JavaScript string escapes are decoded before containment is checked. It reports the authored file and line, the evaluated target, the evaluated base, and a browser-normalized base-prefixed replacement. It also scans question MD/MDX files already visited by validation.
|
|
67
|
+
|
|
68
|
+
The check is deliberately validation-only: it does not mutate or rewrite content, and there is no opt-out. Under the root base (`/`) it is inert. Protocol URLs (`https:`, `mailto:`, `data:`, and others), protocol-relative URLs (`//cdn…`), fragments, relative targets, targets already inside the base, and dynamic JSX expressions are excluded. `rel="external"` describes a relationship but does not change where a browser navigates, so it does not exempt a host-root URL. Frontmatter, comments, fenced/inline/indented code examples, and `<pre>`/`<code>` examples are excluded by their parsed Markdown/MDX structure so documentation snippets do not become false diagnostics.
|
|
69
|
+
|
|
23
70
|
## Missing generated artifacts
|
|
24
71
|
|
|
25
72
|
`src/data/labels.json` and `src/data/references.json` are derived and normally gitignored. v4.27+ `validate` self-heals either missing file unconditionally by invoking the package's own `build-labels` or `build-bib` command before loading data. Existing files are untouched. A failed child command stops validation with the child's original diagnostic and exit status.
|
|
@@ -68,7 +115,7 @@ For pre-commit: add to `.pre-commit-config.yaml`:
|
|
|
68
115
|
|
|
69
116
|
## Preset / chaptersBase resolution (v4.7.0+, #75)
|
|
70
117
|
|
|
71
|
-
The validator evaluates `astro.config.*` through Vite first. A resolved scaffold integration supplies the composed preset and `numberStyle`, so CLI tooling sees the same Style chain as the Astro build. Without such an integration, the legacy preset chain remains: `--preset` → process `BOOK_PRESET`/`BOOK_PROFILE` → root `.env` → the literal value in `defineBookSchemas` → a warned `minimal` v4 compatibility fallback. Every selected value is checked against the five-preset enum; config-evaluation failures stop validation instead of silently using defaults.
|
|
118
|
+
The validator evaluates `astro.config.*` through Vite first. A resolved scaffold integration supplies the composed preset and `numberStyle`, so CLI tooling sees the same Style chain as the Astro build. The evaluated Astro `base` is read from that same config regardless of whether the scaffold integration is present; omission defaults to `/`. Without such an integration, the legacy preset chain remains: `--preset` → process `BOOK_PRESET`/`BOOK_PROFILE` → root `.env` → the literal value in `defineBookSchemas` → a warned `minimal` v4 compatibility fallback. Every selected value is checked against the five-preset enum; config-evaluation failures stop validation instead of silently using defaults.
|
|
72
119
|
|
|
73
120
|
`chaptersBase` resolution still consults `BOOK_CHAPTERS_DIR`, content configuration, then `src/content/chapters`. The v4.5+ canonical form is:
|
|
74
121
|
|
|
@@ -101,7 +148,7 @@ validate: ✗ 17 error(s) in 6 chapter(s) (profile=academic, number-style=shared
|
|
|
101
148
|
...
|
|
102
149
|
```
|
|
103
150
|
|
|
104
|
-
Warnings (
|
|
151
|
+
Warnings (internal-link uncertainty plus explicit BookLink skip cases) are printed to stderr but don't affect exit code.
|
|
105
152
|
|
|
106
153
|
## Extending the validator
|
|
107
154
|
|
|
@@ -83,6 +83,11 @@ import { chapterHref, apparatusHref, bookOf, isCurrentChapter }
|
|
|
83
83
|
Pure functions, no `astro:content` — pass a `{ id, data }` and the same
|
|
84
84
|
`chapterRoute` / `bookField` you set in `defineBookConfig`.
|
|
85
85
|
|
|
86
|
+
`book-scaffold build-labels` evaluates those same two fields and routes every
|
|
87
|
+
component and h2–h6 heading entry through `chapterHref`. A root- or multi-book
|
|
88
|
+
site's vendored cross-book index therefore describes its real routes rather
|
|
89
|
+
than silently falling back to `/chapters/<id>/`.
|
|
90
|
+
|
|
86
91
|
## Verify
|
|
87
92
|
|
|
88
93
|
A consumer should drive a cross-device audit (Playwright is ideal): for each rich
|