explico 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +67 -7
  3. package/dist/compiler/astro.d.ts +30 -0
  4. package/dist/compiler/astro.js +79 -0
  5. package/dist/compiler/cli.d.ts +16 -0
  6. package/dist/compiler/cli.js +43 -9
  7. package/dist/compiler/collections.js +4 -1
  8. package/dist/compiler/course-config.d.ts +80 -4
  9. package/dist/compiler/course-config.js +12 -0
  10. package/dist/compiler/knowledge-version.d.ts +58 -0
  11. package/dist/compiler/knowledge-version.js +80 -0
  12. package/dist/compiler/lesson-derivation.d.ts +1 -1
  13. package/dist/compiler/lesson-derivation.js +2 -2
  14. package/dist/compiler/manifest.d.ts +98 -2
  15. package/dist/compiler/manifest.js +153 -24
  16. package/dist/compiler/resolution-report.d.ts +4 -0
  17. package/dist/reference/base-operators.d.mts +38 -0
  18. package/dist/reference/base-operators.mjs +103 -0
  19. package/dist/reference/consistency.d.ts +7 -2
  20. package/dist/reference/consistency.js +51 -2
  21. package/dist/reference/diagram-layout.d.ts +63 -0
  22. package/dist/reference/diagram-layout.js +122 -0
  23. package/dist/reference/equations-validate.d.ts +10 -0
  24. package/dist/reference/equations-validate.js +1 -1
  25. package/dist/reference/equations.d.mts +80 -13
  26. package/dist/reference/equations.mjs +109 -19
  27. package/dist/reference/figures-validate.d.ts +21 -0
  28. package/dist/reference/figures-validate.js +13 -0
  29. package/dist/reference/keyed-float-validate.d.ts +73 -0
  30. package/dist/reference/keyed-float-validate.js +110 -0
  31. package/dist/reference/keyed-jsx-floats.d.mts +64 -0
  32. package/dist/reference/keyed-jsx-floats.mjs +191 -0
  33. package/dist/reference/math-glyphs.d.mts +18 -0
  34. package/dist/reference/math-glyphs.mjs +92 -0
  35. package/dist/reference/remark-notation.d.mts +61 -0
  36. package/dist/reference/remark-notation.mjs +344 -10
  37. package/dist/reference/tables-validate.d.ts +21 -0
  38. package/dist/reference/tables-validate.js +13 -0
  39. package/dist/reference/units-vocab.js +19 -1
  40. package/docs/VERSIONING.md +66 -0
  41. package/docs/authoring/README.md +100 -0
  42. package/docs/authoring/agents.md +63 -0
  43. package/docs/authoring/migrating.md +169 -0
  44. package/docs/authoring/notation.md +297 -0
  45. package/docs/authoring/structure.md +139 -0
  46. package/docs/authoring/versioning.md +116 -0
  47. package/docs/authoring/visualizations.md +195 -0
  48. package/package.json +20 -5
  49. package/src/compiler/astro.ts +127 -0
  50. package/src/compiler/cli.ts +52 -7
  51. package/src/compiler/collections.ts +12 -3
  52. package/src/compiler/course-config.ts +93 -3
  53. package/src/compiler/knowledge-version.ts +157 -0
  54. package/src/compiler/lesson-derivation.ts +2 -2
  55. package/src/compiler/manifest.ts +320 -26
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/diagram/Diagram.astro +317 -0
  58. package/src/components/examples/CompactExamples.astro +1 -1
  59. package/src/components/figure/Figure.astro +104 -0
  60. package/src/components/notation/NotationLayer.astro +11 -0
  61. package/src/components/notation/render-notation-math.ts +13 -2
  62. package/src/components/reference/FloatRefLayer.astro +671 -0
  63. package/src/components/starlight/AboutPanel.astro +255 -0
  64. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  65. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  66. package/src/components/starlight/LayoutSidebar.astro +62 -1
  67. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  68. package/src/components/starlight/LessonFooter.astro +15 -2
  69. package/src/components/table/Table.astro +105 -0
  70. package/src/reference/base-operators.mjs +103 -0
  71. package/src/reference/consistency.ts +83 -2
  72. package/src/reference/diagram-layout.ts +246 -0
  73. package/src/reference/equations-validate.ts +8 -1
  74. package/src/reference/equations.mjs +109 -19
  75. package/src/reference/figures-validate.ts +40 -0
  76. package/src/reference/keyed-float-validate.ts +210 -0
  77. package/src/reference/keyed-jsx-floats.mjs +191 -0
  78. package/src/reference/math-glyphs.mjs +92 -0
  79. package/src/reference/remark-notation.mjs +344 -10
  80. package/src/reference/tables-validate.ts +40 -0
  81. package/src/reference/units-vocab.ts +18 -0
  82. package/src/styles/global.css +244 -7
  83. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -34,6 +34,7 @@ import {
34
34
  readdirSync,
35
35
  writeFileSync,
36
36
  } from 'node:fs';
37
+ import { createRequire } from 'node:module';
37
38
  import { dirname, join, relative } from 'node:path';
38
39
  import { parseFrontmatter } from '@astrojs/markdown-remark';
39
40
 
@@ -46,11 +47,23 @@ import {
46
47
  type ConsistencyDiagnostic,
47
48
  } from '../reference/consistency';
48
49
  import { validateNotationAlignment } from '../reference/curriculum-alignment';
50
+ import { countDisplayEquations } from '../reference/equations.mjs';
51
+ import { countKeyedJsxFloats } from '../reference/keyed-jsx-floats.mjs';
49
52
  import {
50
53
  validateEquations,
51
54
  type EquationDiagnostic,
52
55
  type EquationLabelRecord,
53
56
  } from '../reference/equations-validate';
57
+ import {
58
+ validateTables,
59
+ type TableDiagnostic,
60
+ type TableLabelRecord,
61
+ } from '../reference/tables-validate';
62
+ import {
63
+ validateFigures,
64
+ type FigureDiagnostic,
65
+ type FigureLabelRecord,
66
+ } from '../reference/figures-validate';
54
67
  import {
55
68
  gateContentMath,
56
69
  type MathGateDiagnostic,
@@ -69,7 +82,11 @@ import type {
69
82
  NotationLessonInput,
70
83
  NotationPageBundle,
71
84
  } from '../reference/types';
72
- import type { CourseConfig } from './course-config';
85
+ import { coursePartsOf, type CourseConfig } from './course-config';
86
+ import {
87
+ resolveKnowledgeVersion,
88
+ type KnowledgeVersionDiagnostic,
89
+ } from './knowledge-version';
73
90
  import {
74
91
  loadCurriculumCatalog,
75
92
  loadNotationDefinitions,
@@ -81,6 +98,7 @@ import {
81
98
  buildSidebar,
82
99
  deriveSources,
83
100
  isLessonSlug,
101
+ lessonIdFromSlug,
84
102
  type SidebarGroup,
85
103
  } from './lesson-derivation';
86
104
 
@@ -92,8 +110,20 @@ import {
92
110
  * `diagnostics.consistency` (Tier-3 corpus checks).
93
111
  * v3 (D7): `equations` (labelled display equations + cross-page number table),
94
112
  * `lessons[].equations`, and `diagnostics.equations`.
113
+ * v4 (F-tables-figures): `tables`/`figures` (same shape as `equations`, for
114
+ * `<Table>`/`<Figure>`), `lessons[].tables`/`lessons[].figures`, and
115
+ * `diagnostics.tables`/`diagnostics.figures`.
116
+ * v5 (about): `about` (engine identity from `explico`'s `package.json`, the
117
+ * course's reader-facing `about` block, and the knowledge version resolved
118
+ * against `content/CHANGELOG.md`) and `diagnostics.knowledge`.
119
+ * v6 (chapter numbering): `lessons[].chapter` — the lesson's 1-based course
120
+ * ordinal, the chapter in every equation/table/figure number (`(2.1)`).
121
+ * v7 (part numbering): `lessons[].part` (the sidebar group's 1-based ordinal,
122
+ * now the number before the dot), `lessons[].chapter` redefined as the
123
+ * lesson's ordinal *within its part*, and `lessons[].numbering` — the
124
+ * per-kind offsets that make `n` run continuously across a part.
95
125
  */
96
- export const MANIFEST_SCHEMA_VERSION = 3;
126
+ export const MANIFEST_SCHEMA_VERSION = 7;
97
127
 
98
128
  const REPOSITORY_ROOT = process.cwd();
99
129
  // Content is the top-level `content/` folder; the build output is `build/`
@@ -101,6 +131,33 @@ const REPOSITORY_ROOT = process.cwd();
101
131
  const CONTENT_ROOT = join(REPOSITORY_ROOT, 'content');
102
132
  export const MANIFEST_PATH = join(REPOSITORY_ROOT, 'build', 'manifest.json');
103
133
 
134
+ interface EnginePackage {
135
+ readonly name: string;
136
+ readonly version: string;
137
+ readonly homepage?: string;
138
+ }
139
+
140
+ /**
141
+ * `explico`'s own identity — the golden source for the engine version the About
142
+ * panel shows (the README points here, not a literal).
143
+ *
144
+ * Resolved as the bare specifier `explico/package.json` (a published `exports`
145
+ * entry, and a package self-reference inside this repo) through `createRequire`,
146
+ * so it survives being re-bundled by a course's Astro build — a relative
147
+ * `../../package.json` would follow the *bundle* location, not the source's.
148
+ * Lazy + cached: importing this module for `readManifestSync` must stay free of
149
+ * filesystem side effects; only `compileManifest` needs the value.
150
+ */
151
+ let enginePackage: EnginePackage | undefined;
152
+ function readEnginePackage(): EnginePackage {
153
+ if (!enginePackage) {
154
+ enginePackage = createRequire(import.meta.url)(
155
+ 'explico/package.json',
156
+ ) as EnginePackage;
157
+ }
158
+ return enginePackage;
159
+ }
160
+
104
161
  // --- shape ------------------------------------------------------------
105
162
 
106
163
  export interface ManifestCitation {
@@ -154,6 +211,29 @@ export interface ManifestLesson {
154
211
  readonly id: string;
155
212
  /** Docs-loader slug, e.g. `bonds/price-yield-relationship`. */
156
213
  readonly slug: string;
214
+ /**
215
+ * The 1-based ordinal of the **part** this lesson sits in — its sidebar
216
+ * group's position in `courseConfig.parts`. It is the number before the dot
217
+ * in every equation, table, and figure number on the page: `(2.1)` is the
218
+ * first equation of part 2. See `docs/authoring/structure.md`.
219
+ */
220
+ readonly part: number;
221
+ /**
222
+ * The lesson's 1-based ordinal **within its part** (merged track order, then
223
+ * any unranked lessons by slug) — its chapter number. This orders the
224
+ * sidebar and the reading path; it does not appear in a float's number.
225
+ */
226
+ readonly chapter: number;
227
+ /**
228
+ * How many floats of each kind the earlier lessons of this part already
229
+ * used. The lesson's own floats continue from here, so a part's sequence
230
+ * runs unbroken across its chapters instead of restarting on every page.
231
+ */
232
+ readonly numbering: {
233
+ readonly equations: number;
234
+ readonly tables: number;
235
+ readonly figures: number;
236
+ };
157
237
  readonly file: string;
158
238
  readonly title: string;
159
239
  readonly description: string;
@@ -172,6 +252,16 @@ export interface ManifestLesson {
172
252
  readonly key: string;
173
253
  readonly number: string;
174
254
  }[];
255
+ /** Keyed `<Table>` elements in appearance order. */
256
+ readonly tables: readonly {
257
+ readonly key: string;
258
+ readonly number: string;
259
+ }[];
260
+ /** Keyed `<Figure>` elements in appearance order. */
261
+ readonly figures: readonly {
262
+ readonly key: string;
263
+ readonly number: string;
264
+ }[];
175
265
  readonly notation: {
176
266
  readonly bindings: readonly {
177
267
  readonly key: string;
@@ -184,8 +274,37 @@ export interface ManifestLesson {
184
274
  };
185
275
  }
186
276
 
277
+ /** The reader-facing "about this material" bundle — the `[?]` panel's data. */
278
+ export interface ManifestAbout {
279
+ /** `explico`'s own identity, from its `package.json` (the version's golden source). */
280
+ readonly engine: {
281
+ readonly name: string;
282
+ readonly version: string;
283
+ readonly homepage?: string;
284
+ };
285
+ readonly material: { readonly name: string; readonly summary: string };
286
+ /** The course's knowledge version — semver over the content, engine-independent. */
287
+ readonly knowledge: {
288
+ readonly version: string;
289
+ /** Date of the newest `content/CHANGELOG.md` entry, if it has one. */
290
+ readonly date?: string;
291
+ /** Bullet notes from that entry. */
292
+ readonly notes: readonly string[];
293
+ /** Content fingerprint at compile time — ties the shown version to a build. */
294
+ readonly contentTree: string;
295
+ };
296
+ readonly authors: readonly {
297
+ readonly name: string;
298
+ readonly contact?: string;
299
+ }[];
300
+ readonly disclaimer: string;
301
+ readonly changelogHref?: string;
302
+ }
303
+
187
304
  export interface Manifest {
188
305
  readonly schemaVersion: number;
306
+ /** Reader-facing identity + versions (the `[?]` About panel). */
307
+ readonly about: ManifestAbout;
189
308
  readonly notation: {
190
309
  readonly definitions: readonly NotationDefinitionRecord[];
191
310
  readonly bundles: readonly NotationPageBundle[];
@@ -207,6 +326,16 @@ export interface Manifest {
207
326
  /** `slug -> { eqKey: number }` — passed to `remarkNotation` for `[[slug#eq:key]]`. */
208
327
  readonly numbersBySlug: Record<string, Record<string, string>>;
209
328
  };
329
+ /** Keyed-float identity for `<Table>` — same shape as `equations`. */
330
+ readonly tables: {
331
+ readonly labels: readonly TableLabelRecord[];
332
+ readonly numbersBySlug: Record<string, Record<string, string>>;
333
+ };
334
+ /** Keyed-float identity for `<Figure>` — same shape as `equations`. */
335
+ readonly figures: {
336
+ readonly labels: readonly FigureLabelRecord[];
337
+ readonly numbersBySlug: Record<string, Record<string, string>>;
338
+ };
210
339
  readonly diagnostics: {
211
340
  readonly curriculum: readonly CurriculumIssue[];
212
341
  readonly notation: readonly NotationDiagnostic[];
@@ -214,8 +343,14 @@ export interface Manifest {
214
343
  readonly math: readonly MathGateDiagnostic[];
215
344
  /** Tier-3 corpus consistency checks (D6). Warnings only. */
216
345
  readonly consistency: readonly ConsistencyDiagnostic[];
346
+ /** Knowledge-version checks (C3): `about.version` vs `content/CHANGELOG.md`. Warnings only. */
347
+ readonly knowledge: readonly KnowledgeVersionDiagnostic[];
217
348
  /** Equation-identity checks (D7): `eq-duplicate-key`, `eq-ref-resolves` (error); `eq-key-unused` (warning). */
218
349
  readonly equations: readonly EquationDiagnostic[];
350
+ /** `<Table>` identity checks (F-tables-figures) — same codes, `tab-` prefixed. */
351
+ readonly tables: readonly TableDiagnostic[];
352
+ /** `<Figure>` identity checks (F-tables-figures) — same codes, `fig-` prefixed. */
353
+ readonly figures: readonly FigureDiagnostic[];
219
354
  };
220
355
  readonly hashes: {
221
356
  /** `sha256` of every content source file, keyed by repo-relative path. */
@@ -395,6 +530,22 @@ function lessonFrontmatterById(): Map<
395
530
  export async function compileManifest(
396
531
  courseConfig: CourseConfig,
397
532
  ): Promise<Manifest> {
533
+ // `about` became required in explico 0.3.0. A course upgrading from 0.2.x
534
+ // hits this before the raw `Cannot read properties of undefined` further
535
+ // down; the message names the fix and the guide.
536
+ if (
537
+ courseConfig.about === undefined ||
538
+ courseConfig.about.version === undefined ||
539
+ courseConfig.about.authors === undefined ||
540
+ courseConfig.about.disclaimer === undefined
541
+ ) {
542
+ throw new Error(
543
+ "course.config.ts is missing a complete `about` block (required since explico 0.3.0): " +
544
+ "`about: { version, authors: [{ name }], disclaimer, materialName?, summary?, changelogHref? }`. " +
545
+ 'See docs/authoring/migrating.md (§ 0.2.x → 0.3.0) in the installed package.',
546
+ );
547
+ }
548
+
398
549
  const [catalog, notationInput] = await Promise.all([
399
550
  loadCurriculumCatalog(),
400
551
  loadNotationRegistryInput(),
@@ -452,20 +603,96 @@ export async function compileManifest(
452
603
  );
453
604
  const frontmatterById = lessonFrontmatterById();
454
605
 
455
- const equationValidation = validateEquations(
456
- notationInput.lessons.map((lesson) => ({
457
- lessonId: lesson.lessonId,
458
- slug: lesson.lessonId.replace(/\./g, '/'),
459
- file: lesson.source.file,
460
- body: lesson.body,
461
- })),
606
+ const lessonSlugs = [...notationInput.lessons]
607
+ .map((lesson) => lesson.lessonId.replace(/\./g, '/'))
608
+ .sort();
609
+ const sidebar = buildSidebar(
610
+ catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
611
+ lessonSlugs,
612
+ coursePartsOf(courseConfig),
462
613
  );
614
+
615
+ // Part and chapter, read straight off the sidebar so there is one
616
+ // lesson-ordering source and not two: a part is a sidebar group (parts in
617
+ // config order), a chapter is a lesson's position inside its part (merged
618
+ // track order, then slug). The part is what prefixes every equation / table
619
+ // / figure number — `(2.1)` is the first equation of part 2 — while `n`
620
+ // continues across the part's chapters rather than restarting on each page,
621
+ // so every lesson also needs the running count that precedes it.
622
+ // See `docs/authoring/structure.md`.
623
+ const bodyByLessonId = new Map(
624
+ notationInput.lessons.map((lesson) => [lesson.lessonId, lesson.body]),
625
+ );
626
+ const placementByLessonId = new Map<
627
+ string,
628
+ {
629
+ part: number;
630
+ chapter: number;
631
+ numbering: { equations: number; tables: number; figures: number };
632
+ }
633
+ >();
634
+ let partCursor = 0;
635
+ for (const group of sidebar) {
636
+ const lessonIds = group.items
637
+ .filter((item): item is string => typeof item === 'string')
638
+ .map(lessonIdFromSlug);
639
+ if (lessonIds.length === 0) continue; // the static Reference group
640
+ partCursor += 1;
641
+ // Running totals for this part, advanced one chapter at a time.
642
+ const used = { equations: 0, tables: 0, figures: 0 };
643
+ let chapterCursor = 0;
644
+ for (const lessonId of lessonIds) {
645
+ const body = bodyByLessonId.get(lessonId) ?? '';
646
+ placementByLessonId.set(lessonId, {
647
+ part: partCursor,
648
+ chapter: ++chapterCursor,
649
+ numbering: { ...used },
650
+ });
651
+ used.equations += countDisplayEquations(body);
652
+ used.tables += countKeyedJsxFloats(body, { tagName: 'Table' });
653
+ used.figures += countKeyedJsxFloats(body, { tagName: 'Figure' });
654
+ }
655
+ }
656
+
657
+ const floatInputs = (kind: 'equations' | 'tables' | 'figures') =>
658
+ notationInput.lessons.map((lesson) => {
659
+ const placement = placementByLessonId.get(lesson.lessonId);
660
+ return {
661
+ lessonId: lesson.lessonId,
662
+ slug: lesson.lessonId.replace(/\./g, '/'),
663
+ file: lesson.source.file,
664
+ body: lesson.body,
665
+ ...(placement
666
+ ? {
667
+ numbering: {
668
+ prefix: placement.part,
669
+ offset: placement.numbering[kind],
670
+ },
671
+ }
672
+ : {}),
673
+ };
674
+ });
675
+ const equationValidation = validateEquations(floatInputs('equations'));
463
676
  const equationsBySlug = new Map(
464
677
  Object.entries(equationValidation.numbersBySlug).map(([slug, table]) => [
465
678
  slug,
466
679
  Object.entries(table).map(([key, number]) => ({ key, number })),
467
680
  ]),
468
681
  );
682
+ const tableValidation = validateTables(floatInputs('tables'));
683
+ const tablesBySlug = new Map(
684
+ Object.entries(tableValidation.numbersBySlug).map(([slug, table]) => [
685
+ slug,
686
+ Object.entries(table).map(([key, number]) => ({ key, number })),
687
+ ]),
688
+ );
689
+ const figureValidation = validateFigures(floatInputs('figures'));
690
+ const figuresBySlug = new Map(
691
+ Object.entries(figureValidation.numbersBySlug).map(([slug, table]) => [
692
+ slug,
693
+ Object.entries(table).map(([key, number]) => ({ key, number })),
694
+ ]),
695
+ );
469
696
 
470
697
  const lessons: ManifestLesson[] = [];
471
698
  for (const notationLesson of notationInput.lessons) {
@@ -480,6 +707,16 @@ export async function compileManifest(
480
707
  lessons.push({
481
708
  id,
482
709
  slug: id.replace(/\./g, '/'),
710
+ // 0 = the lesson is not placed in any configured part; a curriculum
711
+ // diagnostic covers that separately, and both numbering paths then fall
712
+ // back to the legacy `##`-section scheme.
713
+ part: placementByLessonId.get(id)?.part ?? 0,
714
+ chapter: placementByLessonId.get(id)?.chapter ?? 0,
715
+ numbering: placementByLessonId.get(id)?.numbering ?? {
716
+ equations: 0,
717
+ tables: 0,
718
+ figures: 0,
719
+ },
483
720
  file: notationLesson.source.file,
484
721
  title: frontmatter.title,
485
722
  description: frontmatter.description,
@@ -491,6 +728,8 @@ export async function compileManifest(
491
728
  sources: deriveSources(notationLesson.body),
492
729
  citations: lessonCitations(notationLesson.body),
493
730
  equations: equationsBySlug.get(id.replace(/\./g, '/')) ?? [],
731
+ tables: tablesBySlug.get(id.replace(/\./g, '/')) ?? [],
732
+ figures: figuresBySlug.get(id.replace(/\./g, '/')) ?? [],
494
733
  prereqEdges: sortEdges(
495
734
  teaches.flatMap((to) =>
496
735
  (prerequisitesById.get(to) ?? [])
@@ -519,6 +758,9 @@ export async function compileManifest(
519
758
  catalog,
520
759
  lintIgnore: loadLintIgnore(),
521
760
  conventions: courseConfig.conventions,
761
+ ...(courseConfig.operators === undefined
762
+ ? {}
763
+ : { operators: courseConfig.operators }),
522
764
  });
523
765
 
524
766
  const rawNotation = [...loadNotationDefinitions()].sort((a, b) =>
@@ -530,17 +772,54 @@ export async function compileManifest(
530
772
  const sources = [...loadSourceRecords()].sort(byId);
531
773
  const tracks = catalog.tracks.map((entry) => ({ ...entry })).sort(byId);
532
774
 
533
- const lessonSlugs = [...notationInput.lessons]
534
- .map((lesson) => lesson.lessonId.replace(/\./g, '/'))
535
- .sort();
536
- const sidebar = buildSidebar(
537
- catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
538
- lessonSlugs,
539
- courseConfig.sections,
540
- );
775
+ const hashes = contentHashes();
776
+
777
+ // Knowledge version (C3): `about.version` vs the newest `content/CHANGELOG.md`
778
+ // entry. Every finding is a Tier-3 warning; the resolved entry feeds the
779
+ // `[?]` About panel.
780
+ const knowledgeChangelogPath = join(CONTENT_ROOT, 'CHANGELOG.md');
781
+ const knowledge = resolveKnowledgeVersion({
782
+ changelogText: existsSync(knowledgeChangelogPath)
783
+ ? readFileSync(knowledgeChangelogPath, 'utf8')
784
+ : undefined,
785
+ declaredVersion: courseConfig.about.version,
786
+ file: relative(REPOSITORY_ROOT, knowledgeChangelogPath).replace(/\\/g, '/'),
787
+ });
788
+
789
+ const enginePkg = readEnginePackage();
790
+ const about: ManifestAbout = {
791
+ engine: {
792
+ name: enginePkg.name,
793
+ version: enginePkg.version,
794
+ ...(enginePkg.homepage === undefined
795
+ ? {}
796
+ : { homepage: enginePkg.homepage }),
797
+ },
798
+ material: {
799
+ name: courseConfig.about.materialName ?? courseConfig.title,
800
+ summary: courseConfig.about.summary ?? courseConfig.description,
801
+ },
802
+ knowledge: {
803
+ version: knowledge.version,
804
+ ...(knowledge.latest?.date === undefined
805
+ ? {}
806
+ : { date: knowledge.latest.date }),
807
+ notes: knowledge.latest?.notes ?? [],
808
+ contentTree: hashes.contentTree,
809
+ },
810
+ authors: courseConfig.about.authors.map((author) => ({
811
+ name: author.name,
812
+ ...(author.contact === undefined ? {} : { contact: author.contact }),
813
+ })),
814
+ disclaimer: courseConfig.about.disclaimer,
815
+ ...(courseConfig.about.changelogHref === undefined
816
+ ? {}
817
+ : { changelogHref: courseConfig.about.changelogHref }),
818
+ };
541
819
 
542
820
  return {
543
821
  schemaVersion: MANIFEST_SCHEMA_VERSION,
822
+ about,
544
823
  notation: {
545
824
  definitions: registry.definitions,
546
825
  bundles: registry.bundles,
@@ -558,6 +837,14 @@ export async function compileManifest(
558
837
  labels: equationValidation.labels,
559
838
  numbersBySlug: equationValidation.numbersBySlug,
560
839
  },
840
+ tables: {
841
+ labels: tableValidation.labels,
842
+ numbersBySlug: tableValidation.numbersBySlug,
843
+ },
844
+ figures: {
845
+ labels: figureValidation.labels,
846
+ numbersBySlug: figureValidation.numbersBySlug,
847
+ },
561
848
  diagnostics: {
562
849
  curriculum: curriculumIssues,
563
850
  notation: registry.diagnostics,
@@ -565,8 +852,11 @@ export async function compileManifest(
565
852
  math: mathIssues,
566
853
  consistency: consistencyIssues,
567
854
  equations: equationValidation.diagnostics,
855
+ tables: tableValidation.diagnostics,
856
+ figures: figureValidation.diagnostics,
857
+ knowledge: knowledge.diagnostics,
568
858
  },
569
- hashes: contentHashes(),
859
+ hashes,
570
860
  };
571
861
  }
572
862
 
@@ -620,13 +910,17 @@ export function readManifestSync(): Manifest {
620
910
  export async function loadManifest(
621
911
  courseConfig?: CourseConfig,
622
912
  ): Promise<Manifest> {
623
- if (existsSync(MANIFEST_PATH)) return readManifestSync();
624
- if (!courseConfig) {
625
- throw new Error(
626
- `${relative(REPOSITORY_ROOT, MANIFEST_PATH)} not found. Run \`pnpm validate:content\` first.`,
627
- );
913
+ // A caller that has the course config is an authoring/build entry point. It
914
+ // must compile the current content tree even when a persisted manifest is
915
+ // present: using that file here can split the page in two, with remark
916
+ // seeing current frontmatter while Astro components render stale cards.
917
+ if (courseConfig) {
918
+ const manifest = await compileManifest(courseConfig);
919
+ writeManifest(manifest);
920
+ return manifest;
628
921
  }
629
- const manifest = await compileManifest(courseConfig);
630
- writeManifest(manifest);
631
- return manifest;
922
+ if (existsSync(MANIFEST_PATH)) return readManifestSync();
923
+ throw new Error(
924
+ `${relative(REPOSITORY_ROOT, MANIFEST_PATH)} not found. Run \`pnpm validate:content\` first.`,
925
+ );
632
926
  }
@@ -2,6 +2,7 @@
2
2
  import { getCollection } from 'astro:content';
3
3
  import { loadManifest } from '../compiler/manifest';
4
4
  import { isLessonSlug, lessonIdFromSlug } from '../compiler/lesson-derivation';
5
+ import Diagram, { type DiagramEdge, type DiagramNode } from './diagram/Diagram.astro';
5
6
 
6
7
  const [tracks, docs, competencies, manifest] = await Promise.all([
7
8
  getCollection('tracks'),
@@ -22,6 +23,20 @@ const lessonById = new Map(
22
23
  const competencyById = new Map(
23
24
  competencies.map((entry) => [entry.data.id, entry.data]),
24
25
  );
26
+
27
+ // The dependency graph is read straight off the authored `prerequisites`
28
+ // lists — no separate content to keep in sync, and the same data the build's
29
+ // DAG/cycle validator already checks.
30
+ const competencyNodes: DiagramNode[] = competencies.map(({ data }) => ({
31
+ id: data.id,
32
+ text: data.title,
33
+ }));
34
+ const competencyEdges: DiagramEdge[] = competencies.flatMap(({ data }) =>
35
+ data.prerequisites.map((prerequisiteId) => ({
36
+ from: prerequisiteId,
37
+ to: data.id,
38
+ })),
39
+ );
25
40
  ---
26
41
 
27
42
  {
@@ -110,6 +125,18 @@ const competencyById = new Map(
110
125
  The build rejects unknown references, cycles, invalid lesson ordering, and
111
126
  insufficient assessment coverage.
112
127
  </p>
128
+
129
+ {
130
+ competencyNodes.length > 0 && (
131
+ <Diagram
132
+ id="competency-graph"
133
+ label="Competency dependency graph"
134
+ nodes={competencyNodes}
135
+ edges={competencyEdges}
136
+ />
137
+ )
138
+ }
139
+
113
140
  <ol class="competency-map">
114
141
  {
115
142
  competencies