@brandon_m_behring/book-scaffold-astro 4.30.0 → 5.0.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 +40 -3
- package/MIGRATION-v4-to-v5.md +183 -0
- package/README.md +30 -1
- package/bin/book-scaffold.mjs +1 -1
- package/components/AssessmentTest.astro +26 -5
- package/components/BookLink.astro +6 -3
- package/components/ChapterNav.astro +1 -1
- package/components/Cite.astro +37 -4
- package/components/ExerciseSolutions.astro +24 -4
- package/components/Figure.astro +7 -6
- package/components/Flashcards.tsx +19 -11
- package/components/NavContent.astro +60 -11
- package/components/ObjectiveMap.astro +14 -2
- package/components/PartReview.astro +32 -4
- package/components/PatternTimeline.astro +6 -2
- package/components/Rationale.astro +20 -3
- package/components/Sidebar.astro +11 -3
- package/components/SourceArchive.astro +33 -3
- package/components/Term.astro +18 -1
- package/components/Theorem.astro +15 -5
- package/components/TipsCard.astro +40 -3
- package/components/XRef.astro +14 -2
- package/dist/components/ExamRunner.d.ts +1 -1
- package/dist/components/Flashcards.d.ts +6 -1
- package/dist/components/Flashcards.mjs +14 -11
- package/dist/{exam-manifest-X9IrX1G3.d.ts → exam-manifest-DttY7kyZ.d.ts} +1 -1
- package/dist/index.d.ts +76 -8
- package/dist/index.mjs +517 -43
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +311 -32
- package/dist/{types-DgSlAew3.d.ts → types-D1QZgKMO.d.ts} +73 -21
- package/layouts/Base.astro +38 -9
- package/layouts/Chapter.astro +10 -2
- package/package.json +6 -2
- package/pages/answers.astro +25 -6
- package/pages/book.astro +75 -0
- package/pages/chapters/[...slug].astro +39 -7
- package/pages/chapters-book.astro +17 -0
- package/pages/chapters.astro +87 -9
- package/pages/convergence.astro +40 -10
- package/pages/corpus-apparatus/answers.astro +15 -0
- package/pages/corpus-apparatus/convergence.astro +15 -0
- package/pages/corpus-apparatus/exercises.astro +15 -0
- package/pages/corpus-apparatus/flashcards.astro +15 -0
- package/pages/corpus-apparatus/glossary.astro +15 -0
- package/pages/corpus-apparatus/practice-exam.astro +15 -0
- package/pages/corpus-apparatus/print.astro +15 -0
- package/pages/corpus-apparatus/references.astro +15 -0
- package/pages/corpus-apparatus/tips.astro +15 -0
- package/pages/exercises.astro +18 -5
- package/pages/flashcards.astro +26 -5
- package/pages/glossary.astro +20 -3
- package/pages/index.astro +54 -1
- package/pages/practice-exam.astro +8 -2
- package/pages/print.astro +7 -2
- package/pages/references.astro +26 -6
- package/pages/search.astro +65 -4
- package/pages/tips.astro +17 -4
- package/recipes/03-asset-pipelines.md +12 -4
- package/recipes/09-validation.md +1 -1
- package/recipes/15-defining-styles.md +6 -6
- package/recipes/16-tikz-figures.md +13 -2
- package/recipes/20-anki-export.md +15 -8
- package/recipes/21-multi-guide-single-app.md +293 -44
- package/recipes/22-responsive-nav-and-multibook-routing.md +7 -1
- package/recipes/24-figure-authoring-standard.md +241 -0
- package/recipes/README.md +3 -2
- package/scripts/build-bib.mjs +113 -35
- package/scripts/build-exercises.mjs +101 -24
- package/scripts/build-figures.mjs +13 -10
- package/scripts/build-labels.mjs +199 -194
- package/scripts/build-tips.mjs +95 -23
- package/scripts/corpus-tooling.mjs +268 -0
- package/scripts/render-notebooks.mjs +2 -1
- package/scripts/resolve-book-config.mjs +99 -1
- package/scripts/sync-figure-tokens.mjs +44 -0
- package/scripts/validate.mjs +676 -100
- package/scripts/walk-mdx.mjs +16 -4
- package/src/lib/book-link.ts +72 -0
- package/src/lib/chapters.ts +10 -4
- package/src/lib/corpus-collateral.ts +9 -0
- package/src/lib/corpus.ts +458 -0
- package/src/lib/define-style.ts +28 -15
- package/src/lib/exam-manifest.ts +4 -1
- package/src/lib/figure-palette.mjs +162 -0
- package/src/lib/figure.mjs +113 -37
- package/src/lib/patterns.ts +15 -6
- package/src/lib/questions.ts +8 -3
- package/src/types.ts +603 -0
- package/styles/tokens.css +73 -9
|
@@ -23,8 +23,12 @@ import bookConfig from 'virtual:book-scaffold/book-config';
|
|
|
23
23
|
import { academicParts } from '../src/schemas';
|
|
24
24
|
import { academicPartHeading } from '../src/lib/academic-parts';
|
|
25
25
|
import { chapterHref, apparatusHref, bookOf, isCurrentChapter, type ChapterLike, normalizeBase } from '../src/lib/nav-href';
|
|
26
|
+
import { corpusBookIdFromPath, corpusBookIdOf } from '../src/lib/corpus';
|
|
26
27
|
|
|
27
|
-
const profile = import.meta.env.BOOK_PROFILE
|
|
28
|
+
const profile = import.meta.env.BOOK_PROFILE;
|
|
29
|
+
if (!profile) {
|
|
30
|
+
throw new Error('book-scaffold runtime is missing the integration-defined BOOK_PROFILE.');
|
|
31
|
+
}
|
|
28
32
|
// #142: normalize the base trailing slash (Astro allows base:'/foo' with none).
|
|
29
33
|
const baseUrl = normalizeBase(import.meta.env.BASE_URL);
|
|
30
34
|
|
|
@@ -45,16 +49,30 @@ const rawChapters = await getCollection('chapters', (entry) => !entry.data.draft
|
|
|
45
49
|
function entryOf(e: (typeof rawChapters)[number]): ChapterLike {
|
|
46
50
|
return { id: e.id, data: e.data as Record<string, unknown> };
|
|
47
51
|
}
|
|
52
|
+
const entryBook = (entry: (typeof rawChapters)[number]) =>
|
|
53
|
+
bookConfig.corpus
|
|
54
|
+
? corpusBookIdOf(bookConfig.corpus, entry.id)
|
|
55
|
+
: bookOf(entryOf(entry), bookField);
|
|
48
56
|
const knownBooks = new Set(
|
|
49
|
-
|
|
57
|
+
bookConfig.corpus
|
|
58
|
+
? bookConfig.corpus.books.map((book) => book.id)
|
|
59
|
+
: rawChapters.map(entryBook).filter((book): book is string => book !== null),
|
|
50
60
|
);
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
const currentBook = bookConfig.corpus
|
|
62
|
+
? corpusBookIdFromPath(bookConfig.corpus, currentPath, baseUrl)
|
|
63
|
+
: (() => {
|
|
64
|
+
const relPath = currentPath.startsWith(baseUrl)
|
|
65
|
+
? currentPath.slice(baseUrl.length)
|
|
66
|
+
: currentPath.replace(/^\//, '');
|
|
67
|
+
const urlBook = relPath.split('/')[0] ?? '';
|
|
68
|
+
return knownBooks.has(urlBook) ? urlBook : null;
|
|
69
|
+
})();
|
|
54
70
|
|
|
55
71
|
const scopedChapters = currentBook
|
|
56
|
-
? rawChapters.filter((
|
|
57
|
-
:
|
|
72
|
+
? rawChapters.filter((entry) => entryBook(entry) === currentBook)
|
|
73
|
+
: bookConfig.corpus
|
|
74
|
+
? []
|
|
75
|
+
: rawChapters;
|
|
58
76
|
|
|
59
77
|
// Pre-shape into render-ready rows (all TS casts in frontmatter — Astro's JSX
|
|
60
78
|
// parser mis-tokenizes casts inside expression containers).
|
|
@@ -116,8 +134,15 @@ for (const r of rows) {
|
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
// Index link: multi-book → the book's TOC at /<book>/; single-book → /chapters/.
|
|
119
|
-
const chaptersIndexHref = currentBook
|
|
120
|
-
|
|
137
|
+
const chaptersIndexHref = currentBook
|
|
138
|
+
? `${baseUrl}chapters/${currentBook}/`
|
|
139
|
+
: `${baseUrl}chapters/`;
|
|
140
|
+
const referencesHref = apparatusHref(
|
|
141
|
+
'references',
|
|
142
|
+
currentBook,
|
|
143
|
+
bookConfig.corpus ? '/:book/:route/' : '/:route/',
|
|
144
|
+
baseUrl,
|
|
145
|
+
);
|
|
121
146
|
|
|
122
147
|
// Apparatus links (study-guide routes) — surfaced only when the consumer opts in
|
|
123
148
|
// via `apparatusRoutes`. Book-scoped via the same resolver as chapters.
|
|
@@ -127,7 +152,11 @@ const APPARATUS_LABELS: Record<string, string> = {
|
|
|
127
152
|
flashcards: 'Flashcards',
|
|
128
153
|
answers: 'Answers',
|
|
129
154
|
};
|
|
130
|
-
const
|
|
155
|
+
const currentBookConfig = currentBook && bookConfig.corpus
|
|
156
|
+
? bookConfig.corpus.books.find((book) => book.id === currentBook)
|
|
157
|
+
: null;
|
|
158
|
+
const visibleApparatusRoutes = currentBookConfig?.apparatus ?? apparatusRoutes;
|
|
159
|
+
const apparatusLinks = visibleApparatusRoutes.map((route) => {
|
|
131
160
|
const href = apparatusHref(route, currentBook, apparatusRoute, baseUrl);
|
|
132
161
|
return {
|
|
133
162
|
href,
|
|
@@ -135,6 +164,7 @@ const apparatusLinks = apparatusRoutes.map((route) => {
|
|
|
135
164
|
isCurrent: currentPath === href || currentPath === href.replace(/\/$/, ''),
|
|
136
165
|
};
|
|
137
166
|
});
|
|
167
|
+
const corpusBooks = bookConfig.corpus?.books ?? [];
|
|
138
168
|
|
|
139
169
|
function partLabel(key: string): string {
|
|
140
170
|
if (profile === 'academic') return academicPartHeading(key);
|
|
@@ -149,7 +179,7 @@ function partLabel(key: string): string {
|
|
|
149
179
|
>
|
|
150
180
|
All chapters
|
|
151
181
|
</a>
|
|
152
|
-
{profile === 'academic' && (
|
|
182
|
+
{profile === 'academic' && !bookConfig.corpus && (
|
|
153
183
|
<a
|
|
154
184
|
href={referencesHref}
|
|
155
185
|
class={`sidebar-link sidebar-link-index ${currentPath === referencesHref ? 'is-current' : ''}`}
|
|
@@ -196,6 +226,25 @@ function partLabel(key: string): string {
|
|
|
196
226
|
</ol>
|
|
197
227
|
</section>
|
|
198
228
|
)}
|
|
229
|
+
|
|
230
|
+
{corpusBooks.length > 1 && (
|
|
231
|
+
<section class="sidebar-part sidebar-corpus-books">
|
|
232
|
+
<h3 class="sidebar-part-heading">Books</h3>
|
|
233
|
+
<ol class="sidebar-list">
|
|
234
|
+
{corpusBooks.map((book) => (
|
|
235
|
+
<li>
|
|
236
|
+
<a
|
|
237
|
+
href={`${baseUrl}${book.id}/`}
|
|
238
|
+
aria-current={book.id === currentBook ? 'page' : undefined}
|
|
239
|
+
class={`sidebar-link sidebar-link-book ${book.id === currentBook ? 'is-current' : ''}`}
|
|
240
|
+
>
|
|
241
|
+
<span class="sidebar-chapter-title">{book.title}</span>
|
|
242
|
+
</a>
|
|
243
|
+
</li>
|
|
244
|
+
))}
|
|
245
|
+
</ol>
|
|
246
|
+
</section>
|
|
247
|
+
)}
|
|
199
248
|
</nav>
|
|
200
249
|
|
|
201
250
|
<style>
|
|
@@ -22,6 +22,7 @@ import bookConfig from 'virtual:book-scaffold/book-config';
|
|
|
22
22
|
import { getCollection } from 'astro:content';
|
|
23
23
|
import { deriveObjectiveMap, distinctChaptersSorted } from '../src/lib/questions';
|
|
24
24
|
import { assertKnownDomain } from '../src/lib/exam-domains';
|
|
25
|
+
import { corpusBookIdFromPath } from '../src/lib/corpus';
|
|
25
26
|
|
|
26
27
|
interface Props {
|
|
27
28
|
/** Heading above the table. Defaults to "Exam objective coverage". */
|
|
@@ -35,11 +36,22 @@ const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}',
|
|
|
35
36
|
import: 'default',
|
|
36
37
|
eager: true,
|
|
37
38
|
});
|
|
38
|
-
const
|
|
39
|
+
const currentBook = bookConfig.corpus
|
|
40
|
+
? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
|
|
41
|
+
: null;
|
|
42
|
+
if (bookConfig.corpus && !currentBook) {
|
|
43
|
+
throw new Error('<ObjectiveMap> requires a canonical corpus book route.');
|
|
44
|
+
}
|
|
45
|
+
const hasQuestions = currentBook
|
|
46
|
+
? Object.keys(questionModules).some((path) => path.includes(`/questions/${currentBook}/`))
|
|
47
|
+
: Object.keys(questionModules).length > 0;
|
|
39
48
|
|
|
40
|
-
const
|
|
49
|
+
const allEntries = hasQuestions
|
|
41
50
|
? await getCollection('questions', (e) => !e.data.draft)
|
|
42
51
|
: [];
|
|
52
|
+
const entries = currentBook
|
|
53
|
+
? allEntries.filter((entry) => entry.id.startsWith(`${currentBook}/`))
|
|
54
|
+
: allEntries;
|
|
43
55
|
// Fail loud (like /practice-exam): a question with an unregistered / typo'd
|
|
44
56
|
// domain must throw at build, not silently vanish from the coverage matrix.
|
|
45
57
|
for (const e of entries) {
|
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
import { getCollection } from 'astro:content';
|
|
21
21
|
import { selectPartExercises } from '../src/lib/part-review';
|
|
22
22
|
import { normalizeBase } from '../src/lib/nav-href';
|
|
23
|
+
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
24
|
+
import {
|
|
25
|
+
corpusBookIdFromPath,
|
|
26
|
+
localCorpusEntryId,
|
|
27
|
+
selectBookArtifact,
|
|
28
|
+
} from '../src/lib/corpus';
|
|
23
29
|
|
|
24
30
|
interface Props {
|
|
25
31
|
/** The part to aggregate. Number (tools/minimal/…) or string (academic enum). */
|
|
@@ -32,18 +38,40 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
|
|
|
32
38
|
|
|
33
39
|
// Reuse the build-exercises index (keyed by chapter slug). Project-root-relative
|
|
34
40
|
// glob (the tips.astro / exercises.astro lesson) so it resolves across consumers.
|
|
35
|
-
|
|
41
|
+
type ExerciseIndex = Record<string, Array<{ id: string; problem: string }>>;
|
|
42
|
+
const exModules = import.meta.glob<{ default: unknown }>(
|
|
36
43
|
'/src/data/exercises.json',
|
|
37
44
|
{ eager: true },
|
|
38
45
|
);
|
|
39
46
|
const exEntry = exModules['/src/data/exercises.json'];
|
|
40
|
-
const
|
|
47
|
+
const currentBook = bookConfig.corpus
|
|
48
|
+
? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, baseUrl)
|
|
49
|
+
: null;
|
|
50
|
+
if (bookConfig.corpus && !currentBook) {
|
|
51
|
+
throw new Error('<PartReview> requires a canonical corpus book route.');
|
|
52
|
+
}
|
|
53
|
+
const byChapter = exEntry
|
|
54
|
+
? selectBookArtifact<ExerciseIndex>(
|
|
55
|
+
exEntry.default,
|
|
56
|
+
bookConfig.corpus,
|
|
57
|
+
currentBook,
|
|
58
|
+
'src/data/exercises.json',
|
|
59
|
+
)
|
|
60
|
+
: {};
|
|
41
61
|
const hasIndex = Boolean(exEntry);
|
|
42
62
|
|
|
43
63
|
// Filter this part's chapters, sort to book order, and join the index — pure +
|
|
44
64
|
// unit-tested in src/lib/part-review.ts (`part` is String-coerced, so a
|
|
45
65
|
// number-vs-string prop can't silently miss). This component just renders it.
|
|
46
|
-
const
|
|
66
|
+
const allChapters = await getCollection('chapters', (e) => !e.data.draft);
|
|
67
|
+
const chapters = currentBook && bookConfig.corpus
|
|
68
|
+
? allChapters
|
|
69
|
+
.filter((entry) => entry.id.startsWith(`${currentBook}/`))
|
|
70
|
+
.map((entry) => ({
|
|
71
|
+
...entry,
|
|
72
|
+
id: localCorpusEntryId(bookConfig.corpus!, currentBook, entry.id),
|
|
73
|
+
}))
|
|
74
|
+
: allChapters;
|
|
47
75
|
const { groups, total } = selectPartExercises(chapters, byChapter, part);
|
|
48
76
|
const heading = title ?? `Part ${part} Review`;
|
|
49
77
|
---
|
|
@@ -67,7 +95,7 @@ const heading = title ?? `Part ${part} Review`;
|
|
|
67
95
|
</p>
|
|
68
96
|
{groups.map((g) => (
|
|
69
97
|
<section class="part-review-chapter">
|
|
70
|
-
<h3 class="part-review-chapter-title"><a href={`${baseUrl}chapters/${g.chapter}/`}>{g.chapter}</a></h3>
|
|
98
|
+
<h3 class="part-review-chapter-title"><a href={`${baseUrl}chapters/${currentBook ? `${currentBook}/` : ''}${g.chapter}/`}>{g.chapter}</a></h3>
|
|
71
99
|
<ol class="part-review-list">
|
|
72
100
|
{g.exercises.map((ex) => (
|
|
73
101
|
<li id={`review-${ex.id}`} class="part-review-item">
|
|
@@ -27,11 +27,15 @@ import {
|
|
|
27
27
|
|
|
28
28
|
interface Props {
|
|
29
29
|
pattern: PatternEntry;
|
|
30
|
+
bookId?: string;
|
|
31
|
+
hasChangelog?: boolean;
|
|
30
32
|
}
|
|
31
|
-
const { pattern } = Astro.props;
|
|
33
|
+
const { pattern, bookId, hasChangelog = true } = Astro.props;
|
|
32
34
|
const data = pattern.data;
|
|
33
35
|
|
|
34
|
-
const timeline =
|
|
36
|
+
const timeline = hasChangelog
|
|
37
|
+
? await getPatternTimeline(pattern.id, bookId)
|
|
38
|
+
: [];
|
|
35
39
|
const adopters = toolsInTimeline(timeline);
|
|
36
40
|
|
|
37
41
|
function formatDate(d: Date): string {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
20
20
|
import { baseNoSlash } from '../src/lib/nav-href';
|
|
21
|
+
import { corpusBookHasApparatusRoute, corpusBookIdFromPath } from '../src/lib/corpus';
|
|
21
22
|
|
|
22
23
|
interface Props {
|
|
23
24
|
/** Summary label for the reveal. Defaults to "Answer & rationale". */
|
|
@@ -37,7 +38,21 @@ if (appendix && !forId) {
|
|
|
37
38
|
`/answers#answer-<id> anchor target.`,
|
|
38
39
|
);
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
+
const currentBook = bookConfig.corpus
|
|
42
|
+
? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
|
|
43
|
+
: null;
|
|
44
|
+
const answersEnabled = bookConfig.corpus
|
|
45
|
+
? Boolean(
|
|
46
|
+
currentBook &&
|
|
47
|
+
corpusBookHasApparatusRoute(
|
|
48
|
+
bookConfig.corpus,
|
|
49
|
+
currentBook,
|
|
50
|
+
'answers',
|
|
51
|
+
bookConfig.apparatusRoutes,
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
: (bookConfig.enabledRoutes ?? []).includes('answers');
|
|
55
|
+
if (appendix && !answersEnabled) {
|
|
41
56
|
throw new Error(
|
|
42
57
|
`<Rationale appendix for="${forId}"> — the answers route is not enabled, so ` +
|
|
43
58
|
`the appendix link would 404. Set routes: { answers: true } in ` +
|
|
@@ -50,12 +65,14 @@ if (appendix && !(bookConfig.enabledRoutes ?? []).includes('answers')) {
|
|
|
50
65
|
// render()). BASE_URL-prefixed + trailing-slash tolerant; an endsWith()
|
|
51
66
|
// heuristic would also fire on e.g. a chapter whose slug is "answers".
|
|
52
67
|
const basePath = baseNoSlash(import.meta.env.BASE_URL);
|
|
53
|
-
const
|
|
68
|
+
const answersBookPrefix = currentBook ? `${currentBook}/` : '';
|
|
69
|
+
const answersPath = `${basePath}/${answersBookPrefix}answers`;
|
|
70
|
+
const onAnswersRoute = baseNoSlash(Astro.url.pathname) === answersPath;
|
|
54
71
|
const asLink = appendix && !onAnswersRoute;
|
|
55
72
|
---
|
|
56
73
|
{asLink ? (
|
|
57
74
|
<p class="question-rationale-ref">
|
|
58
|
-
<a href={`${
|
|
75
|
+
<a href={`${answersPath}#answer-${forId}`}>{title} →</a>
|
|
59
76
|
</p>
|
|
60
77
|
) : (
|
|
61
78
|
<details class="question-rationale">
|
package/components/Sidebar.astro
CHANGED
|
@@ -18,17 +18,25 @@
|
|
|
18
18
|
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
19
19
|
import NavContent from './NavContent.astro';
|
|
20
20
|
import { normalizeBase } from '../src/lib/nav-href';
|
|
21
|
+
import { corpusBookIdFromPath, resolveCorpusBook } from '../src/lib/corpus';
|
|
21
22
|
|
|
22
|
-
const siteTitle = bookConfig.title ?? 'Book';
|
|
23
|
-
const siteSubtitle = bookConfig.subtitle ?? 'A scaffold-astro book';
|
|
24
23
|
// #142: Astro does not guarantee a trailing slash on `base` — normalize so the
|
|
25
24
|
// home link is `${base}/`, not a slash-less `/foo`.
|
|
26
25
|
const baseUrl = normalizeBase(import.meta.env.BASE_URL);
|
|
26
|
+
const currentBookId = bookConfig.corpus
|
|
27
|
+
? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, baseUrl)
|
|
28
|
+
: null;
|
|
29
|
+
const currentBook = currentBookId && bookConfig.corpus
|
|
30
|
+
? resolveCorpusBook(bookConfig.corpus, currentBookId)
|
|
31
|
+
: null;
|
|
32
|
+
const siteTitle = currentBook?.title ?? bookConfig.title ?? 'Book';
|
|
33
|
+
const siteSubtitle = currentBook?.subtitle ?? bookConfig.subtitle ?? 'A scaffold-astro book';
|
|
34
|
+
const homeHref = currentBook ? `${baseUrl}${currentBook.id}/` : baseUrl;
|
|
27
35
|
---
|
|
28
36
|
|
|
29
37
|
<aside class="sidebar" aria-label="Chapter navigation">
|
|
30
38
|
<div class="sidebar-inner">
|
|
31
|
-
<a href={
|
|
39
|
+
<a href={homeHref} class="sidebar-home">
|
|
32
40
|
<strong>{siteTitle}</strong>
|
|
33
41
|
<span class="sidebar-subtitle">{siteSubtitle}</span>
|
|
34
42
|
</a>
|
|
@@ -19,14 +19,44 @@ import {
|
|
|
19
19
|
TIER_DESCRIPTIONS,
|
|
20
20
|
} from '../src/lib/sources';
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
interface SerializedSource {
|
|
23
|
+
id: string;
|
|
24
|
+
tier: (typeof sourceTiers)[number];
|
|
25
|
+
title: string;
|
|
26
|
+
url: string;
|
|
27
|
+
author?: string;
|
|
28
|
+
publish_date?: string | Date;
|
|
29
|
+
captured_at?: string | Date;
|
|
30
|
+
tool?: string;
|
|
31
|
+
perma_cc?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Props { sources?: unknown[]; }
|
|
35
|
+
const { sources } = Astro.props;
|
|
36
|
+
const grouped = sources
|
|
37
|
+
? Object.fromEntries(
|
|
38
|
+
sourceTiers.map((tier) => [
|
|
39
|
+
tier,
|
|
40
|
+
(sources as SerializedSource[])
|
|
41
|
+
.filter((source) => source.tier === tier)
|
|
42
|
+
.map((source) => ({ id: source.id, data: source })),
|
|
43
|
+
]),
|
|
44
|
+
) as Record<(typeof sourceTiers)[number], Array<{ id: string; data: SerializedSource }>>
|
|
45
|
+
: await getSourcesByTier();
|
|
46
|
+
|
|
47
|
+
function asDate(d: string | Date | undefined): Date | undefined {
|
|
48
|
+
if (!d) return undefined;
|
|
49
|
+
return d instanceof Date ? d : new Date(d);
|
|
50
|
+
}
|
|
23
51
|
|
|
24
|
-
function formatDate(
|
|
52
|
+
function formatDate(value: string | Date | undefined): string {
|
|
53
|
+
const d = asDate(value);
|
|
25
54
|
if (!d) return '—';
|
|
26
55
|
return d.toISOString().slice(0, 10);
|
|
27
56
|
}
|
|
28
57
|
|
|
29
|
-
function year(
|
|
58
|
+
function year(value: string | Date | undefined): string | null {
|
|
59
|
+
const d = asDate(value);
|
|
30
60
|
if (!d) return null;
|
|
31
61
|
return String(d.getUTCFullYear());
|
|
32
62
|
}
|
package/components/Term.astro
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { normalizeBase } from '../src/lib/nav-href';
|
|
3
|
+
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
4
|
+
import { corpusBookHasApparatusRoute, corpusBookIdFromPath } from '../src/lib/corpus';
|
|
3
5
|
/**
|
|
4
6
|
* Term — inline glossary cross-link (v4.19.0, #115).
|
|
5
7
|
*
|
|
@@ -19,8 +21,23 @@ interface Props {
|
|
|
19
21
|
}
|
|
20
22
|
const { id } = Astro.props;
|
|
21
23
|
const baseUrl = normalizeBase(import.meta.env.BASE_URL);
|
|
24
|
+
const currentBook = bookConfig.corpus
|
|
25
|
+
? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, baseUrl)
|
|
26
|
+
: null;
|
|
27
|
+
if (
|
|
28
|
+
bookConfig.corpus &&
|
|
29
|
+
(!currentBook ||
|
|
30
|
+
!corpusBookHasApparatusRoute(
|
|
31
|
+
bookConfig.corpus,
|
|
32
|
+
currentBook,
|
|
33
|
+
'glossary',
|
|
34
|
+
bookConfig.apparatusRoutes,
|
|
35
|
+
))
|
|
36
|
+
) {
|
|
37
|
+
throw new Error(`<Term id="${id}"> requires the current corpus book's glossary route.`);
|
|
38
|
+
}
|
|
22
39
|
---
|
|
23
|
-
<a href={`${baseUrl}glossary#term-${id}`} class="term-link"><slot /></a>
|
|
40
|
+
<a href={`${baseUrl}${currentBook ? `${currentBook}/` : ''}glossary#term-${id}`} class="term-link"><slot /></a>
|
|
24
41
|
|
|
25
42
|
<style>
|
|
26
43
|
.term-link {
|
package/components/Theorem.astro
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
* </Theorem>
|
|
31
31
|
*/
|
|
32
32
|
import { theoremLabel, resolveTheoremNumber, type TheoremLabelProps } from '../src/lib/theorem-label';
|
|
33
|
+
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
34
|
+
import { corpusBookIdFromPath, selectBookArtifact } from '../src/lib/corpus';
|
|
33
35
|
|
|
34
36
|
interface Props extends TheoremLabelProps {
|
|
35
37
|
id?: string;
|
|
@@ -42,14 +44,22 @@ interface Props extends TheoremLabelProps {
|
|
|
42
44
|
// a missing file → empty map → fall back to explicit n= (the same soft-degrade
|
|
43
45
|
// path as XRef — the validator catches unknown ids at CI).
|
|
44
46
|
type LabelEntry = { href: string; display: string; number?: string | null };
|
|
45
|
-
const labelsModules = import.meta.glob<{ default:
|
|
47
|
+
const labelsModules = import.meta.glob<{ default: unknown }>(
|
|
46
48
|
'/src/data/labels.json',
|
|
47
49
|
{ eager: true },
|
|
48
50
|
);
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const labelsModule = labelsModules['/src/data/labels.json'];
|
|
52
|
+
const currentBook = bookConfig.corpus
|
|
53
|
+
? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
|
|
54
|
+
: null;
|
|
55
|
+
const labelsMap = labelsModule
|
|
56
|
+
? selectBookArtifact<Record<string, LabelEntry>>(
|
|
57
|
+
labelsModule.default,
|
|
58
|
+
bookConfig.corpus,
|
|
59
|
+
currentBook,
|
|
60
|
+
'src/data/labels.json',
|
|
61
|
+
)
|
|
62
|
+
: {};
|
|
53
63
|
|
|
54
64
|
const { id } = Astro.props;
|
|
55
65
|
// labels.json (by id) is the number source; explicit n= is the fallback when no
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { normalizeBase } from '../src/lib/nav-href';
|
|
3
|
+
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
4
|
+
import {
|
|
5
|
+
corpusBookHasApparatusRoute,
|
|
6
|
+
corpusBookIdFromPath,
|
|
7
|
+
selectBookArtifact,
|
|
8
|
+
} from '../src/lib/corpus';
|
|
3
9
|
/**
|
|
4
10
|
* TipsCard — print-friendly pull-out card listing all numbered tips
|
|
5
11
|
* (v4.3.0, closes #70).
|
|
@@ -16,11 +22,37 @@ import { normalizeBase } from '../src/lib/nav-href';
|
|
|
16
22
|
*/
|
|
17
23
|
// v4.4.0 fix: project-root-relative import via Vite's import.meta.glob
|
|
18
24
|
// (the previous `../../../src/data/tips.json` failed in node_modules contexts).
|
|
19
|
-
|
|
25
|
+
type TipIndexEntry = { n: number; title: string; chapter: string; preview: string };
|
|
26
|
+
const tipsModules = import.meta.glob<{ default: unknown }>(
|
|
20
27
|
'/src/data/tips.json',
|
|
21
28
|
{ eager: true },
|
|
22
29
|
);
|
|
23
|
-
const
|
|
30
|
+
const tipsModule = tipsModules['/src/data/tips.json'];
|
|
31
|
+
const tipsPayload = tipsModule?.default ?? [];
|
|
32
|
+
const currentBook = bookConfig.corpus
|
|
33
|
+
? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
|
|
34
|
+
: null;
|
|
35
|
+
const tips = tipsModule
|
|
36
|
+
? selectBookArtifact<TipIndexEntry[]>(
|
|
37
|
+
tipsPayload,
|
|
38
|
+
bookConfig.corpus,
|
|
39
|
+
currentBook,
|
|
40
|
+
'src/data/tips.json',
|
|
41
|
+
)
|
|
42
|
+
: [];
|
|
43
|
+
if (
|
|
44
|
+
tips.length > 0 &&
|
|
45
|
+
bookConfig.corpus &&
|
|
46
|
+
(!currentBook ||
|
|
47
|
+
!corpusBookHasApparatusRoute(
|
|
48
|
+
bookConfig.corpus,
|
|
49
|
+
currentBook,
|
|
50
|
+
'tips',
|
|
51
|
+
bookConfig.apparatusRoutes,
|
|
52
|
+
))
|
|
53
|
+
) {
|
|
54
|
+
throw new Error(`<TipsCard> requires the current corpus book's tips route.`);
|
|
55
|
+
}
|
|
24
56
|
const baseUrl = normalizeBase(import.meta.env.BASE_URL);
|
|
25
57
|
---
|
|
26
58
|
<aside class="tips-card" role="contentinfo">
|
|
@@ -35,7 +67,12 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
|
|
|
35
67
|
<ol class="tips-card-list">
|
|
36
68
|
{tips.map((tip) => (
|
|
37
69
|
<li class="tips-card-item">
|
|
38
|
-
<a
|
|
70
|
+
<a
|
|
71
|
+
href={currentBook
|
|
72
|
+
? `${baseUrl}${currentBook}/tips#tip-${tip.n}`
|
|
73
|
+
: `${baseUrl}tips#tip-${tip.n}`}
|
|
74
|
+
class="tips-card-link"
|
|
75
|
+
>
|
|
39
76
|
<span class="tips-card-number">{tip.n}.</span>
|
|
40
77
|
<strong class="tips-card-rule">{tip.title}</strong>
|
|
41
78
|
</a>
|
package/components/XRef.astro
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { normalizeBase } from '../src/lib/nav-href';
|
|
3
|
+
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
4
|
+
import { corpusBookIdFromPath, selectBookArtifact } from '../src/lib/corpus';
|
|
3
5
|
// XRef — resolves a `\cref{label}` LaTeX reference to a hyperlink.
|
|
4
6
|
//
|
|
5
7
|
// Reads src/data/labels.json built by scripts/build-labels.mjs
|
|
@@ -32,12 +34,22 @@ type LabelEntry = { href: string; display: string };
|
|
|
32
34
|
// Resolve labels.json from the consumer's project root (Vite resolves `/`
|
|
33
35
|
// to project root, not the package). Missing file -> empty map -> [?id]
|
|
34
36
|
// placeholders (silent degrade for dev ergonomics; validator catches at CI).
|
|
35
|
-
const labelsModules = import.meta.glob<{ default:
|
|
37
|
+
const labelsModules = import.meta.glob<{ default: unknown }>(
|
|
36
38
|
'/src/data/labels.json',
|
|
37
39
|
{ eager: true },
|
|
38
40
|
);
|
|
39
41
|
const labelsModule = labelsModules['/src/data/labels.json'];
|
|
40
|
-
const
|
|
42
|
+
const currentBook = bookConfig.corpus
|
|
43
|
+
? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
|
|
44
|
+
: null;
|
|
45
|
+
const map = labelsModule
|
|
46
|
+
? selectBookArtifact<Record<string, LabelEntry>>(
|
|
47
|
+
labelsModule.default,
|
|
48
|
+
bookConfig.corpus,
|
|
49
|
+
currentBook,
|
|
50
|
+
'src/data/labels.json',
|
|
51
|
+
)
|
|
52
|
+
: {};
|
|
41
53
|
|
|
42
54
|
interface Props {
|
|
43
55
|
id: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as preact from 'preact';
|
|
2
|
-
import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-
|
|
2
|
+
import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-DttY7kyZ.js';
|
|
3
3
|
import '../schemas-CKipJ5Ie.js';
|
|
4
4
|
import 'astro/zod';
|
|
5
5
|
|
|
@@ -6,7 +6,12 @@ import 'astro/zod';
|
|
|
6
6
|
interface Props {
|
|
7
7
|
/** Deck manifest (buildFlashcardDeck output) — card ids + fronts only. */
|
|
8
8
|
deck: FlashcardRef[];
|
|
9
|
+
/**
|
|
10
|
+
* Persistence namespace. Corpus routes pass a base- and book-specific key
|
|
11
|
+
* so repeated local glossary ids never share or erase another book's state.
|
|
12
|
+
*/
|
|
13
|
+
storageKey?: string;
|
|
9
14
|
}
|
|
10
|
-
declare function Flashcards({ deck }: Props): preact.JSX.Element;
|
|
15
|
+
declare function Flashcards({ deck, storageKey }: Props): preact.JSX.Element;
|
|
11
16
|
|
|
12
17
|
export { Flashcards as default };
|
|
@@ -15,10 +15,10 @@ function shuffle(arr, rng = Math.random) {
|
|
|
15
15
|
|
|
16
16
|
// components/Flashcards.tsx
|
|
17
17
|
import { Fragment, jsx, jsxs } from "preact/jsx-runtime";
|
|
18
|
-
var
|
|
19
|
-
function readKnown() {
|
|
18
|
+
var DEFAULT_STORAGE_KEY = "book:flashcards:known";
|
|
19
|
+
function readKnown(storageKey) {
|
|
20
20
|
try {
|
|
21
|
-
const raw = localStorage.getItem(
|
|
21
|
+
const raw = localStorage.getItem(storageKey);
|
|
22
22
|
if (!raw) return /* @__PURE__ */ new Set();
|
|
23
23
|
const parsed = JSON.parse(raw);
|
|
24
24
|
if (!Array.isArray(parsed)) return /* @__PURE__ */ new Set();
|
|
@@ -27,13 +27,16 @@ function readKnown() {
|
|
|
27
27
|
return /* @__PURE__ */ new Set();
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
function writeKnown(known) {
|
|
30
|
+
function writeKnown(storageKey, known) {
|
|
31
31
|
try {
|
|
32
|
-
localStorage.setItem(
|
|
32
|
+
localStorage.setItem(storageKey, JSON.stringify([...known]));
|
|
33
33
|
} catch {
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
function Flashcards({ deck }) {
|
|
36
|
+
function Flashcards({ deck, storageKey = DEFAULT_STORAGE_KEY }) {
|
|
37
|
+
if (typeof storageKey !== "string" || storageKey.trim().length === 0) {
|
|
38
|
+
throw new Error("Flashcards: storageKey must be a non-empty string.");
|
|
39
|
+
}
|
|
37
40
|
const [phase, setPhase] = useState("idle");
|
|
38
41
|
const [order, setOrder] = useState([]);
|
|
39
42
|
const [pos, setPos] = useState(0);
|
|
@@ -42,12 +45,12 @@ function Flashcards({ deck }) {
|
|
|
42
45
|
const [unknownOnly, setUnknownOnly] = useState(false);
|
|
43
46
|
const ref = useRef(null);
|
|
44
47
|
useEffect(() => {
|
|
45
|
-
const stored = readKnown();
|
|
48
|
+
const stored = readKnown(storageKey);
|
|
46
49
|
const deckIds = new Set(deck.map((c) => c.id));
|
|
47
50
|
const cleaned = new Set([...stored].filter((id) => deckIds.has(id)));
|
|
48
|
-
if (cleaned.size !== stored.size) writeKnown(cleaned);
|
|
51
|
+
if (cleaned.size !== stored.size) writeKnown(storageKey, cleaned);
|
|
49
52
|
setKnown(cleaned);
|
|
50
|
-
}, []);
|
|
53
|
+
}, [deck, storageKey]);
|
|
51
54
|
function requireRoot() {
|
|
52
55
|
const r = ref.current?.closest("[data-flashcards-root]");
|
|
53
56
|
if (!r) {
|
|
@@ -105,7 +108,7 @@ function Flashcards({ deck }) {
|
|
|
105
108
|
if (knewIt) next.add(id);
|
|
106
109
|
else next.delete(id);
|
|
107
110
|
setKnown(next);
|
|
108
|
-
writeKnown(next);
|
|
111
|
+
writeKnown(storageKey, next);
|
|
109
112
|
if (pos < order.length - 1) goTo(pos + 1);
|
|
110
113
|
else end();
|
|
111
114
|
}
|
|
@@ -124,7 +127,7 @@ function Flashcards({ deck }) {
|
|
|
124
127
|
function resetKnown() {
|
|
125
128
|
const next = /* @__PURE__ */ new Set();
|
|
126
129
|
setKnown(next);
|
|
127
|
-
writeKnown(next);
|
|
130
|
+
writeKnown(storageKey, next);
|
|
128
131
|
}
|
|
129
132
|
if (deck.length === 0) {
|
|
130
133
|
return /* @__PURE__ */ jsx("p", { class: "flashcards-empty", children: "No glossary terms to study yet." });
|
|
@@ -124,7 +124,7 @@ interface RoutingChapter {
|
|
|
124
124
|
*/
|
|
125
125
|
declare function deriveDomainRouting(entries: readonly (QuestionLike & {
|
|
126
126
|
data: Pick<Question, 'id' | 'chapter' | 'domain'>;
|
|
127
|
-
})[], baseUrl?: string): Record<string, RoutingChapter[]>;
|
|
127
|
+
})[], baseUrl?: string, bookId?: string): Record<string, RoutingChapter[]>;
|
|
128
128
|
/**
|
|
129
129
|
* Build the cross-domain blueprint for assessment mode: spread `count` evenly
|
|
130
130
|
* across the domains present in the pool (floor, minimum 1 per domain), letting
|