explico 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +46 -5
  3. package/dist/compiler/cli.d.ts +16 -0
  4. package/dist/compiler/cli.js +41 -3
  5. package/dist/compiler/collections.js +4 -1
  6. package/dist/compiler/course-config.d.ts +52 -1
  7. package/dist/compiler/knowledge-version.d.ts +58 -0
  8. package/dist/compiler/knowledge-version.js +80 -0
  9. package/dist/compiler/manifest.d.ts +77 -2
  10. package/dist/compiler/manifest.js +115 -18
  11. package/dist/compiler/resolution-report.d.ts +4 -0
  12. package/dist/reference/base-operators.d.mts +38 -0
  13. package/dist/reference/base-operators.mjs +103 -0
  14. package/dist/reference/consistency.d.ts +7 -2
  15. package/dist/reference/consistency.js +51 -2
  16. package/dist/reference/diagram-layout.d.ts +63 -0
  17. package/dist/reference/diagram-layout.js +122 -0
  18. package/dist/reference/equations-validate.d.ts +5 -0
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/equations.d.mts +42 -11
  21. package/dist/reference/equations.mjs +58 -17
  22. package/dist/reference/figures-validate.d.ts +21 -0
  23. package/dist/reference/figures-validate.js +13 -0
  24. package/dist/reference/keyed-float-validate.d.ts +67 -0
  25. package/dist/reference/keyed-float-validate.js +110 -0
  26. package/dist/reference/keyed-jsx-floats.d.mts +56 -0
  27. package/dist/reference/keyed-jsx-floats.mjs +167 -0
  28. package/dist/reference/math-glyphs.d.mts +18 -0
  29. package/dist/reference/math-glyphs.mjs +92 -0
  30. package/dist/reference/remark-notation.d.mts +12 -0
  31. package/dist/reference/remark-notation.mjs +199 -9
  32. package/dist/reference/tables-validate.d.ts +21 -0
  33. package/dist/reference/tables-validate.js +13 -0
  34. package/dist/reference/units-vocab.js +19 -1
  35. package/docs/VERSIONING.md +66 -0
  36. package/docs/authoring/README.md +96 -0
  37. package/docs/authoring/agents.md +63 -0
  38. package/docs/authoring/migrating.md +107 -0
  39. package/docs/authoring/notation.md +296 -0
  40. package/docs/authoring/versioning.md +116 -0
  41. package/docs/authoring/visualizations.md +195 -0
  42. package/package.json +20 -5
  43. package/src/compiler/cli.ts +49 -0
  44. package/src/compiler/collections.ts +12 -3
  45. package/src/compiler/course-config.ts +54 -0
  46. package/src/compiler/knowledge-version.ts +157 -0
  47. package/src/compiler/manifest.ts +239 -17
  48. package/src/components/CurriculumMap.astro +27 -0
  49. package/src/components/diagram/Diagram.astro +317 -0
  50. package/src/components/examples/CompactExamples.astro +1 -1
  51. package/src/components/figure/Figure.astro +104 -0
  52. package/src/components/notation/render-notation-math.ts +13 -2
  53. package/src/components/reference/FloatRefLayer.astro +671 -0
  54. package/src/components/starlight/AboutPanel.astro +255 -0
  55. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  56. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  57. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  58. package/src/components/starlight/LessonFooter.astro +4 -2
  59. package/src/components/table/Table.astro +104 -0
  60. package/src/reference/base-operators.mjs +103 -0
  61. package/src/reference/consistency.ts +83 -2
  62. package/src/reference/diagram-layout.ts +246 -0
  63. package/src/reference/equations-validate.ts +6 -1
  64. package/src/reference/equations.mjs +58 -17
  65. package/src/reference/figures-validate.ts +40 -0
  66. package/src/reference/keyed-float-validate.ts +207 -0
  67. package/src/reference/keyed-jsx-floats.mjs +167 -0
  68. package/src/reference/math-glyphs.mjs +92 -0
  69. package/src/reference/remark-notation.mjs +199 -9
  70. package/src/reference/tables-validate.ts +40 -0
  71. package/src/reference/units-vocab.ts +18 -0
  72. package/src/styles/global.css +115 -7
  73. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The course's **knowledge version** — semver over the learning-facing content,
3
+ * independent of the `explico` engine's own package version.
4
+ *
5
+ * The declared value lives in `content/course.config.ts` (`about.version`); the
6
+ * history and the human-readable notes live in `content/CHANGELOG.md`
7
+ * (Keep a Changelog shape). This module parses that file and checks the two
8
+ * agree — rule C3 in `docs/authoring/versioning.md`. Every finding is a Tier-3
9
+ * warning: `pnpm validate:content` prints it, `manifest.diagnostics.knowledge`
10
+ * records it, and the build still passes. The resolved entry is what the `[?]`
11
+ * About panel publishes to readers (`manifest.about.knowledge`).
12
+ *
13
+ * Pure: `parseKnowledgeChangelog` takes text, `resolveKnowledgeVersion` takes
14
+ * text + the declared version. The caller (`compileManifest`) does the file IO.
15
+ */
16
+
17
+ /** A released entry from `content/CHANGELOG.md`, newest first in file order. */
18
+ export interface KnowledgeChangelogEntry {
19
+ readonly version: string;
20
+ /** The `- YYYY-MM-DD` part of the heading, if present. */
21
+ readonly date?: string;
22
+ /** Bullet lines under the heading, marker stripped, in file order. */
23
+ readonly notes: readonly string[];
24
+ }
25
+
26
+ export type KnowledgeVersionCode =
27
+ | 'knowledge-changelog-missing'
28
+ | 'knowledge-version-invalid'
29
+ | 'knowledge-version-mismatch';
30
+
31
+ export interface KnowledgeVersionDiagnostic {
32
+ readonly code: KnowledgeVersionCode;
33
+ /** Tier 3 — a knowledge-version drift never fails the build. */
34
+ readonly severity: 'warning';
35
+ readonly message: string;
36
+ readonly file?: string;
37
+ }
38
+
39
+ export interface KnowledgeVersion {
40
+ /** The declared `about.version`, echoed for consumers. */
41
+ readonly version: string;
42
+ /** The newest released `## [x.y.z]` entry, when the changelog has one. */
43
+ readonly latest?: KnowledgeChangelogEntry;
44
+ readonly diagnostics: readonly KnowledgeVersionDiagnostic[];
45
+ }
46
+
47
+ // `1.2.3`, optionally `-rc.1` / `+build.5` — the shape `about.version` and a
48
+ // `## [x.y.z]` heading must both take.
49
+ const SEMVER_RE =
50
+ /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
51
+ const HEADING_RE = /^##\s+\[([^\]]+)\]\s*(?:[-–—]\s*(\S.*?))?\s*$/;
52
+ const BULLET_RE = /^[-*]\s+(.*\S)\s*$/;
53
+
54
+ export function isKnowledgeSemver(value: string): boolean {
55
+ return SEMVER_RE.test(value.trim());
56
+ }
57
+
58
+ /**
59
+ * Every released entry in a `content/CHANGELOG.md` body, in file order (newest
60
+ * first, by Keep a Changelog convention). `## [Unreleased]` is skipped.
61
+ */
62
+ export function parseKnowledgeChangelog(
63
+ text: string,
64
+ ): KnowledgeChangelogEntry[] {
65
+ const lines = text.split(/\r?\n/);
66
+ const entries: {
67
+ version: string;
68
+ date?: string;
69
+ notes: string[];
70
+ }[] = [];
71
+ let current: { version: string; date?: string; notes: string[] } | null =
72
+ null;
73
+
74
+ for (const line of lines) {
75
+ const heading = HEADING_RE.exec(line);
76
+ if (heading) {
77
+ const token = (heading[1] ?? '').trim();
78
+ if (/^unreleased$/i.test(token)) {
79
+ current = null;
80
+ continue;
81
+ }
82
+ current = { version: token, notes: [] };
83
+ const date = (heading[2] ?? '').trim();
84
+ if (date) current.date = date;
85
+ entries.push(current);
86
+ continue;
87
+ }
88
+ // A top-level `#` or a deeper `###` heading ends the current entry's notes.
89
+ if (/^#{1,3}\s/.test(line) && !line.startsWith('## [')) {
90
+ current = null;
91
+ continue;
92
+ }
93
+ if (!current) continue;
94
+ const bullet = BULLET_RE.exec(line);
95
+ if (bullet) current.notes.push(bullet[1] ?? '');
96
+ }
97
+
98
+ return entries.map((entry) => ({
99
+ version: entry.version,
100
+ ...(entry.date === undefined ? {} : { date: entry.date }),
101
+ notes: [...entry.notes],
102
+ }));
103
+ }
104
+
105
+ /**
106
+ * Check the declared knowledge version against `content/CHANGELOG.md`.
107
+ *
108
+ * @param options.changelogText the file body, or `undefined` when it is absent
109
+ * @param options.declaredVersion `courseConfig.about.version`
110
+ * @param options.file repo-relative path, for diagnostic messages
111
+ */
112
+ export function resolveKnowledgeVersion(options: {
113
+ readonly changelogText: string | undefined;
114
+ readonly declaredVersion: string;
115
+ readonly file?: string;
116
+ }): KnowledgeVersion {
117
+ const { changelogText, declaredVersion } = options;
118
+ const file = options.file ?? 'content/CHANGELOG.md';
119
+ const diagnostics: KnowledgeVersionDiagnostic[] = [];
120
+ const warn = (code: KnowledgeVersionCode, message: string): void => {
121
+ diagnostics.push({ code, severity: 'warning', message, file });
122
+ };
123
+
124
+ if (!isKnowledgeSemver(declaredVersion)) {
125
+ warn(
126
+ 'knowledge-version-invalid',
127
+ `about.version ${JSON.stringify(declaredVersion)} in content/course.config.ts is not a semver \`x.y.z\` string.`,
128
+ );
129
+ }
130
+
131
+ if (changelogText === undefined) {
132
+ warn(
133
+ 'knowledge-changelog-missing',
134
+ `No ${file}. Add a Keep a Changelog file whose newest \`## [x.y.z]\` heading is ${JSON.stringify(declaredVersion)} (rule C1).`,
135
+ );
136
+ return { version: declaredVersion, diagnostics };
137
+ }
138
+
139
+ const entries = parseKnowledgeChangelog(changelogText);
140
+ const latest = entries[0];
141
+ if (!latest) {
142
+ warn(
143
+ 'knowledge-changelog-missing',
144
+ `${file} has no released \`## [x.y.z]\` entry — only \`## [Unreleased]\`. Cut a release: move it to \`## [${declaredVersion}] - <date>\` (rule C4).`,
145
+ );
146
+ return { version: declaredVersion, diagnostics };
147
+ }
148
+
149
+ if (isKnowledgeSemver(declaredVersion) && latest.version !== declaredVersion) {
150
+ warn(
151
+ 'knowledge-version-mismatch',
152
+ `about.version is ${JSON.stringify(declaredVersion)} but the newest ${file} entry is ${JSON.stringify(latest.version)}. Bump one to match the other (rule C3).`,
153
+ );
154
+ }
155
+
156
+ return { version: declaredVersion, latest, diagnostics };
157
+ }
@@ -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
 
@@ -51,6 +52,16 @@ import {
51
52
  type EquationDiagnostic,
52
53
  type EquationLabelRecord,
53
54
  } from '../reference/equations-validate';
55
+ import {
56
+ validateTables,
57
+ type TableDiagnostic,
58
+ type TableLabelRecord,
59
+ } from '../reference/tables-validate';
60
+ import {
61
+ validateFigures,
62
+ type FigureDiagnostic,
63
+ type FigureLabelRecord,
64
+ } from '../reference/figures-validate';
54
65
  import {
55
66
  gateContentMath,
56
67
  type MathGateDiagnostic,
@@ -70,6 +81,10 @@ import type {
70
81
  NotationPageBundle,
71
82
  } from '../reference/types';
72
83
  import type { CourseConfig } from './course-config';
84
+ import {
85
+ resolveKnowledgeVersion,
86
+ type KnowledgeVersionDiagnostic,
87
+ } from './knowledge-version';
73
88
  import {
74
89
  loadCurriculumCatalog,
75
90
  loadNotationDefinitions,
@@ -81,6 +96,7 @@ import {
81
96
  buildSidebar,
82
97
  deriveSources,
83
98
  isLessonSlug,
99
+ lessonIdFromSlug,
84
100
  type SidebarGroup,
85
101
  } from './lesson-derivation';
86
102
 
@@ -92,8 +108,16 @@ import {
92
108
  * `diagnostics.consistency` (Tier-3 corpus checks).
93
109
  * v3 (D7): `equations` (labelled display equations + cross-page number table),
94
110
  * `lessons[].equations`, and `diagnostics.equations`.
111
+ * v4 (F-tables-figures): `tables`/`figures` (same shape as `equations`, for
112
+ * `<Table>`/`<Figure>`), `lessons[].tables`/`lessons[].figures`, and
113
+ * `diagnostics.tables`/`diagnostics.figures`.
114
+ * v5 (about): `about` (engine identity from `explico`'s `package.json`, the
115
+ * course's reader-facing `about` block, and the knowledge version resolved
116
+ * against `content/CHANGELOG.md`) and `diagnostics.knowledge`.
117
+ * v6 (chapter numbering): `lessons[].chapter` — the lesson's 1-based course
118
+ * ordinal, the chapter in every equation/table/figure number (`(2.1)`).
95
119
  */
96
- export const MANIFEST_SCHEMA_VERSION = 3;
120
+ export const MANIFEST_SCHEMA_VERSION = 6;
97
121
 
98
122
  const REPOSITORY_ROOT = process.cwd();
99
123
  // Content is the top-level `content/` folder; the build output is `build/`
@@ -101,6 +125,33 @@ const REPOSITORY_ROOT = process.cwd();
101
125
  const CONTENT_ROOT = join(REPOSITORY_ROOT, 'content');
102
126
  export const MANIFEST_PATH = join(REPOSITORY_ROOT, 'build', 'manifest.json');
103
127
 
128
+ interface EnginePackage {
129
+ readonly name: string;
130
+ readonly version: string;
131
+ readonly homepage?: string;
132
+ }
133
+
134
+ /**
135
+ * `explico`'s own identity — the golden source for the engine version the About
136
+ * panel shows (the README points here, not a literal).
137
+ *
138
+ * Resolved as the bare specifier `explico/package.json` (a published `exports`
139
+ * entry, and a package self-reference inside this repo) through `createRequire`,
140
+ * so it survives being re-bundled by a course's Astro build — a relative
141
+ * `../../package.json` would follow the *bundle* location, not the source's.
142
+ * Lazy + cached: importing this module for `readManifestSync` must stay free of
143
+ * filesystem side effects; only `compileManifest` needs the value.
144
+ */
145
+ let enginePackage: EnginePackage | undefined;
146
+ function readEnginePackage(): EnginePackage {
147
+ if (!enginePackage) {
148
+ enginePackage = createRequire(import.meta.url)(
149
+ 'explico/package.json',
150
+ ) as EnginePackage;
151
+ }
152
+ return enginePackage;
153
+ }
154
+
104
155
  // --- shape ------------------------------------------------------------
105
156
 
106
157
  export interface ManifestCitation {
@@ -154,6 +205,12 @@ export interface ManifestLesson {
154
205
  readonly id: string;
155
206
  /** Docs-loader slug, e.g. `bonds/price-yield-relationship`. */
156
207
  readonly slug: string;
208
+ /**
209
+ * The lesson's 1-based ordinal in the course (merged track order, then any
210
+ * unranked lessons by slug). It is the chapter in every equation, table, and
211
+ * figure number on the page — `(2.1)` is the first equation of chapter 2.
212
+ */
213
+ readonly chapter: number;
157
214
  readonly file: string;
158
215
  readonly title: string;
159
216
  readonly description: string;
@@ -172,6 +229,16 @@ export interface ManifestLesson {
172
229
  readonly key: string;
173
230
  readonly number: string;
174
231
  }[];
232
+ /** Keyed `<Table>` elements in appearance order. */
233
+ readonly tables: readonly {
234
+ readonly key: string;
235
+ readonly number: string;
236
+ }[];
237
+ /** Keyed `<Figure>` elements in appearance order. */
238
+ readonly figures: readonly {
239
+ readonly key: string;
240
+ readonly number: string;
241
+ }[];
175
242
  readonly notation: {
176
243
  readonly bindings: readonly {
177
244
  readonly key: string;
@@ -184,8 +251,37 @@ export interface ManifestLesson {
184
251
  };
185
252
  }
186
253
 
254
+ /** The reader-facing "about this material" bundle — the `[?]` panel's data. */
255
+ export interface ManifestAbout {
256
+ /** `explico`'s own identity, from its `package.json` (the version's golden source). */
257
+ readonly engine: {
258
+ readonly name: string;
259
+ readonly version: string;
260
+ readonly homepage?: string;
261
+ };
262
+ readonly material: { readonly name: string; readonly summary: string };
263
+ /** The course's knowledge version — semver over the content, engine-independent. */
264
+ readonly knowledge: {
265
+ readonly version: string;
266
+ /** Date of the newest `content/CHANGELOG.md` entry, if it has one. */
267
+ readonly date?: string;
268
+ /** Bullet notes from that entry. */
269
+ readonly notes: readonly string[];
270
+ /** Content fingerprint at compile time — ties the shown version to a build. */
271
+ readonly contentTree: string;
272
+ };
273
+ readonly authors: readonly {
274
+ readonly name: string;
275
+ readonly contact?: string;
276
+ }[];
277
+ readonly disclaimer: string;
278
+ readonly changelogHref?: string;
279
+ }
280
+
187
281
  export interface Manifest {
188
282
  readonly schemaVersion: number;
283
+ /** Reader-facing identity + versions (the `[?]` About panel). */
284
+ readonly about: ManifestAbout;
189
285
  readonly notation: {
190
286
  readonly definitions: readonly NotationDefinitionRecord[];
191
287
  readonly bundles: readonly NotationPageBundle[];
@@ -207,6 +303,16 @@ export interface Manifest {
207
303
  /** `slug -> { eqKey: number }` — passed to `remarkNotation` for `[[slug#eq:key]]`. */
208
304
  readonly numbersBySlug: Record<string, Record<string, string>>;
209
305
  };
306
+ /** Keyed-float identity for `<Table>` — same shape as `equations`. */
307
+ readonly tables: {
308
+ readonly labels: readonly TableLabelRecord[];
309
+ readonly numbersBySlug: Record<string, Record<string, string>>;
310
+ };
311
+ /** Keyed-float identity for `<Figure>` — same shape as `equations`. */
312
+ readonly figures: {
313
+ readonly labels: readonly FigureLabelRecord[];
314
+ readonly numbersBySlug: Record<string, Record<string, string>>;
315
+ };
210
316
  readonly diagnostics: {
211
317
  readonly curriculum: readonly CurriculumIssue[];
212
318
  readonly notation: readonly NotationDiagnostic[];
@@ -214,8 +320,14 @@ export interface Manifest {
214
320
  readonly math: readonly MathGateDiagnostic[];
215
321
  /** Tier-3 corpus consistency checks (D6). Warnings only. */
216
322
  readonly consistency: readonly ConsistencyDiagnostic[];
323
+ /** Knowledge-version checks (C3): `about.version` vs `content/CHANGELOG.md`. Warnings only. */
324
+ readonly knowledge: readonly KnowledgeVersionDiagnostic[];
217
325
  /** Equation-identity checks (D7): `eq-duplicate-key`, `eq-ref-resolves` (error); `eq-key-unused` (warning). */
218
326
  readonly equations: readonly EquationDiagnostic[];
327
+ /** `<Table>` identity checks (F-tables-figures) — same codes, `tab-` prefixed. */
328
+ readonly tables: readonly TableDiagnostic[];
329
+ /** `<Figure>` identity checks (F-tables-figures) — same codes, `fig-` prefixed. */
330
+ readonly figures: readonly FigureDiagnostic[];
219
331
  };
220
332
  readonly hashes: {
221
333
  /** `sha256` of every content source file, keyed by repo-relative path. */
@@ -395,6 +507,22 @@ function lessonFrontmatterById(): Map<
395
507
  export async function compileManifest(
396
508
  courseConfig: CourseConfig,
397
509
  ): Promise<Manifest> {
510
+ // `about` became required in explico 0.3.0. A course upgrading from 0.2.x
511
+ // hits this before the raw `Cannot read properties of undefined` further
512
+ // down; the message names the fix and the guide.
513
+ if (
514
+ courseConfig.about === undefined ||
515
+ courseConfig.about.version === undefined ||
516
+ courseConfig.about.authors === undefined ||
517
+ courseConfig.about.disclaimer === undefined
518
+ ) {
519
+ throw new Error(
520
+ "course.config.ts is missing a complete `about` block (required since explico 0.3.0): " +
521
+ "`about: { version, authors: [{ name }], disclaimer, materialName?, summary?, changelogHref? }`. " +
522
+ 'See docs/authoring/migrating.md (§ 0.2.x → 0.3.0) in the installed package.',
523
+ );
524
+ }
525
+
398
526
  const [catalog, notationInput] = await Promise.all([
399
527
  loadCurriculumCatalog(),
400
528
  loadNotationRegistryInput(),
@@ -452,20 +580,57 @@ export async function compileManifest(
452
580
  );
453
581
  const frontmatterById = lessonFrontmatterById();
454
582
 
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
- })),
583
+ const lessonSlugs = [...notationInput.lessons]
584
+ .map((lesson) => lesson.lessonId.replace(/\./g, '/'))
585
+ .sort();
586
+ const sidebar = buildSidebar(
587
+ catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
588
+ lessonSlugs,
589
+ courseConfig.sections,
462
590
  );
591
+
592
+ // A lesson's chapter is its position when the sidebar is read top to bottom
593
+ // (sections in config order, lessons within a section by merged track order
594
+ // then slug). It prefixes every equation / table / figure number on the page
595
+ // (`(2.1)` = first equation of chapter 2). Derived from `sidebar` so there is
596
+ // one lesson-ordering source, not two.
597
+ const chapterByLessonId = new Map<string, number>();
598
+ let chapterCursor = 0;
599
+ for (const group of sidebar) {
600
+ for (const item of group.items) {
601
+ if (typeof item !== 'string') continue; // the static Reference group
602
+ chapterByLessonId.set(lessonIdFromSlug(item), ++chapterCursor);
603
+ }
604
+ }
605
+
606
+ const keyedFloatLessonInputs = notationInput.lessons.map((lesson) => ({
607
+ lessonId: lesson.lessonId,
608
+ slug: lesson.lessonId.replace(/\./g, '/'),
609
+ file: lesson.source.file,
610
+ body: lesson.body,
611
+ chapter: chapterByLessonId.get(lesson.lessonId),
612
+ }));
613
+ const equationValidation = validateEquations(keyedFloatLessonInputs);
463
614
  const equationsBySlug = new Map(
464
615
  Object.entries(equationValidation.numbersBySlug).map(([slug, table]) => [
465
616
  slug,
466
617
  Object.entries(table).map(([key, number]) => ({ key, number })),
467
618
  ]),
468
619
  );
620
+ const tableValidation = validateTables(keyedFloatLessonInputs);
621
+ const tablesBySlug = new Map(
622
+ Object.entries(tableValidation.numbersBySlug).map(([slug, table]) => [
623
+ slug,
624
+ Object.entries(table).map(([key, number]) => ({ key, number })),
625
+ ]),
626
+ );
627
+ const figureValidation = validateFigures(keyedFloatLessonInputs);
628
+ const figuresBySlug = new Map(
629
+ Object.entries(figureValidation.numbersBySlug).map(([slug, table]) => [
630
+ slug,
631
+ Object.entries(table).map(([key, number]) => ({ key, number })),
632
+ ]),
633
+ );
469
634
 
470
635
  const lessons: ManifestLesson[] = [];
471
636
  for (const notationLesson of notationInput.lessons) {
@@ -480,6 +645,10 @@ export async function compileManifest(
480
645
  lessons.push({
481
646
  id,
482
647
  slug: id.replace(/\./g, '/'),
648
+ // 0 = the lesson is not placed in any configured section; a curriculum
649
+ // diagnostic covers that separately, and both numbering paths then fall
650
+ // back to the legacy `##`-section scheme.
651
+ chapter: chapterByLessonId.get(id) ?? 0,
483
652
  file: notationLesson.source.file,
484
653
  title: frontmatter.title,
485
654
  description: frontmatter.description,
@@ -491,6 +660,8 @@ export async function compileManifest(
491
660
  sources: deriveSources(notationLesson.body),
492
661
  citations: lessonCitations(notationLesson.body),
493
662
  equations: equationsBySlug.get(id.replace(/\./g, '/')) ?? [],
663
+ tables: tablesBySlug.get(id.replace(/\./g, '/')) ?? [],
664
+ figures: figuresBySlug.get(id.replace(/\./g, '/')) ?? [],
494
665
  prereqEdges: sortEdges(
495
666
  teaches.flatMap((to) =>
496
667
  (prerequisitesById.get(to) ?? [])
@@ -519,6 +690,9 @@ export async function compileManifest(
519
690
  catalog,
520
691
  lintIgnore: loadLintIgnore(),
521
692
  conventions: courseConfig.conventions,
693
+ ...(courseConfig.operators === undefined
694
+ ? {}
695
+ : { operators: courseConfig.operators }),
522
696
  });
523
697
 
524
698
  const rawNotation = [...loadNotationDefinitions()].sort((a, b) =>
@@ -530,17 +704,54 @@ export async function compileManifest(
530
704
  const sources = [...loadSourceRecords()].sort(byId);
531
705
  const tracks = catalog.tracks.map((entry) => ({ ...entry })).sort(byId);
532
706
 
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
- );
707
+ const hashes = contentHashes();
708
+
709
+ // Knowledge version (C3): `about.version` vs the newest `content/CHANGELOG.md`
710
+ // entry. Every finding is a Tier-3 warning; the resolved entry feeds the
711
+ // `[?]` About panel.
712
+ const knowledgeChangelogPath = join(CONTENT_ROOT, 'CHANGELOG.md');
713
+ const knowledge = resolveKnowledgeVersion({
714
+ changelogText: existsSync(knowledgeChangelogPath)
715
+ ? readFileSync(knowledgeChangelogPath, 'utf8')
716
+ : undefined,
717
+ declaredVersion: courseConfig.about.version,
718
+ file: relative(REPOSITORY_ROOT, knowledgeChangelogPath).replace(/\\/g, '/'),
719
+ });
720
+
721
+ const enginePkg = readEnginePackage();
722
+ const about: ManifestAbout = {
723
+ engine: {
724
+ name: enginePkg.name,
725
+ version: enginePkg.version,
726
+ ...(enginePkg.homepage === undefined
727
+ ? {}
728
+ : { homepage: enginePkg.homepage }),
729
+ },
730
+ material: {
731
+ name: courseConfig.about.materialName ?? courseConfig.title,
732
+ summary: courseConfig.about.summary ?? courseConfig.description,
733
+ },
734
+ knowledge: {
735
+ version: knowledge.version,
736
+ ...(knowledge.latest?.date === undefined
737
+ ? {}
738
+ : { date: knowledge.latest.date }),
739
+ notes: knowledge.latest?.notes ?? [],
740
+ contentTree: hashes.contentTree,
741
+ },
742
+ authors: courseConfig.about.authors.map((author) => ({
743
+ name: author.name,
744
+ ...(author.contact === undefined ? {} : { contact: author.contact }),
745
+ })),
746
+ disclaimer: courseConfig.about.disclaimer,
747
+ ...(courseConfig.about.changelogHref === undefined
748
+ ? {}
749
+ : { changelogHref: courseConfig.about.changelogHref }),
750
+ };
541
751
 
542
752
  return {
543
753
  schemaVersion: MANIFEST_SCHEMA_VERSION,
754
+ about,
544
755
  notation: {
545
756
  definitions: registry.definitions,
546
757
  bundles: registry.bundles,
@@ -558,6 +769,14 @@ export async function compileManifest(
558
769
  labels: equationValidation.labels,
559
770
  numbersBySlug: equationValidation.numbersBySlug,
560
771
  },
772
+ tables: {
773
+ labels: tableValidation.labels,
774
+ numbersBySlug: tableValidation.numbersBySlug,
775
+ },
776
+ figures: {
777
+ labels: figureValidation.labels,
778
+ numbersBySlug: figureValidation.numbersBySlug,
779
+ },
561
780
  diagnostics: {
562
781
  curriculum: curriculumIssues,
563
782
  notation: registry.diagnostics,
@@ -565,8 +784,11 @@ export async function compileManifest(
565
784
  math: mathIssues,
566
785
  consistency: consistencyIssues,
567
786
  equations: equationValidation.diagnostics,
787
+ tables: tableValidation.diagnostics,
788
+ figures: figureValidation.diagnostics,
789
+ knowledge: knowledge.diagnostics,
568
790
  },
569
- hashes: contentHashes(),
791
+ hashes,
570
792
  };
571
793
  }
572
794
 
@@ -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