explico 0.1.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 (113) hide show
  1. package/README.md +108 -0
  2. package/dist/analytics/AnalyticsEmitter.d.ts +22 -0
  3. package/dist/analytics/AnalyticsEmitter.js +5 -0
  4. package/dist/compiler/cli.d.ts +189 -0
  5. package/dist/compiler/cli.js +552 -0
  6. package/dist/compiler/collections.d.ts +70 -0
  7. package/dist/compiler/collections.js +383 -0
  8. package/dist/compiler/course-config.d.ts +31 -0
  9. package/dist/compiler/course-config.js +0 -0
  10. package/dist/compiler/lesson-derivation.d.ts +89 -0
  11. package/dist/compiler/lesson-derivation.js +91 -0
  12. package/dist/compiler/manifest.d.ts +178 -0
  13. package/dist/compiler/manifest.js +345 -0
  14. package/dist/compiler/resolution-report.d.ts +45 -0
  15. package/dist/compiler/resolution-report.js +97 -0
  16. package/dist/curriculum/validation.d.ts +76 -0
  17. package/dist/curriculum/validation.js +320 -0
  18. package/dist/progress/ProgressRepository.d.ts +53 -0
  19. package/dist/progress/ProgressRepository.js +12 -0
  20. package/dist/reference/base-library.mjs +27 -0
  21. package/dist/reference/citation-format.mjs +133 -0
  22. package/dist/reference/consistency.d.ts +64 -0
  23. package/dist/reference/consistency.js +234 -0
  24. package/dist/reference/curriculum-alignment.d.ts +7 -0
  25. package/dist/reference/curriculum-alignment.js +166 -0
  26. package/dist/reference/equations-validate.d.ts +50 -0
  27. package/dist/reference/equations-validate.js +90 -0
  28. package/dist/reference/equations.mjs +144 -0
  29. package/dist/reference/gate-math.d.ts +41 -0
  30. package/dist/reference/gate-math.js +151 -0
  31. package/dist/reference/katex-options.mjs +91 -0
  32. package/dist/reference/lab-math-scope.d.ts +21 -0
  33. package/dist/reference/lab-math-scope.js +28 -0
  34. package/dist/reference/label.d.ts +11 -0
  35. package/dist/reference/label.js +38 -0
  36. package/dist/reference/lint-ignore.d.ts +32 -0
  37. package/dist/reference/lint-ignore.js +39 -0
  38. package/dist/reference/math-glyphs.mjs +830 -0
  39. package/dist/reference/prose.d.ts +15 -0
  40. package/dist/reference/prose.js +12 -0
  41. package/dist/reference/references.d.ts +17 -0
  42. package/dist/reference/references.js +42 -0
  43. package/dist/reference/registry.d.ts +6 -0
  44. package/dist/reference/registry.js +548 -0
  45. package/dist/reference/rehype-fail-katex-errors.mjs +55 -0
  46. package/dist/reference/remark-citation.mjs +0 -0
  47. package/dist/reference/remark-notation.mjs +753 -0
  48. package/dist/reference/render-lab-math.d.ts +21 -0
  49. package/dist/reference/render-lab-math.js +58 -0
  50. package/dist/reference/types.d.ts +151 -0
  51. package/dist/reference/types.js +0 -0
  52. package/dist/reference/units-vocab.d.ts +26 -0
  53. package/dist/reference/units-vocab.js +119 -0
  54. package/dist/session/PreferenceStore.d.ts +28 -0
  55. package/dist/session/PreferenceStore.js +30 -0
  56. package/dist/session/user-context.d.ts +7 -0
  57. package/dist/session/user-context.js +10 -0
  58. package/dist/session/user.d.ts +21 -0
  59. package/dist/session/user.js +8 -0
  60. package/package.json +53 -0
  61. package/src/analytics/AnalyticsEmitter.ts +23 -0
  62. package/src/compiler/cli.ts +866 -0
  63. package/src/compiler/collections.ts +554 -0
  64. package/src/compiler/course-config.ts +32 -0
  65. package/src/compiler/lesson-derivation.ts +186 -0
  66. package/src/compiler/manifest.ts +601 -0
  67. package/src/compiler/resolution-report.ts +150 -0
  68. package/src/components/CurriculumMap.astro +208 -0
  69. package/src/components/HoverPanel.astro +67 -0
  70. package/src/components/assessments/AssessmentRunner.tsx +320 -0
  71. package/src/components/assessments/AssessmentSet.astro +54 -0
  72. package/src/components/citation/CitationLayer.astro +173 -0
  73. package/src/components/examples/CompactExample.astro +34 -0
  74. package/src/components/examples/CompactExamples.astro +253 -0
  75. package/src/components/hover-panel.ts +273 -0
  76. package/src/components/notation/NotationGlossary.astro +412 -0
  77. package/src/components/notation/NotationLayer.astro +594 -0
  78. package/src/components/notation/NotationMath.astro +24 -0
  79. package/src/components/notation/SafeMathNode.astro +23 -0
  80. package/src/components/notation/render-notation-math.ts +223 -0
  81. package/src/components/starlight/EquationEnhancer.astro +72 -0
  82. package/src/components/starlight/LayoutHeader.astro +102 -0
  83. package/src/components/starlight/LayoutPageSidebar.astro +33 -0
  84. package/src/components/starlight/LayoutSidebar.astro +29 -0
  85. package/src/components/starlight/LessonFooter.astro +58 -0
  86. package/src/content-config.ts +259 -0
  87. package/src/curriculum/validation.ts +481 -0
  88. package/src/progress/ProgressRepository.ts +64 -0
  89. package/src/reference/base-library.mjs +27 -0
  90. package/src/reference/citation-format.mjs +133 -0
  91. package/src/reference/consistency.ts +375 -0
  92. package/src/reference/curriculum-alignment.ts +228 -0
  93. package/src/reference/equations-validate.ts +167 -0
  94. package/src/reference/equations.mjs +144 -0
  95. package/src/reference/gate-math.ts +250 -0
  96. package/src/reference/katex-options.mjs +91 -0
  97. package/src/reference/lab-math-scope.ts +54 -0
  98. package/src/reference/label.ts +47 -0
  99. package/src/reference/lint-ignore.ts +75 -0
  100. package/src/reference/math-glyphs.mjs +830 -0
  101. package/src/reference/prose.ts +21 -0
  102. package/src/reference/references.ts +82 -0
  103. package/src/reference/registry.ts +723 -0
  104. package/src/reference/rehype-fail-katex-errors.mjs +55 -0
  105. package/src/reference/remark-citation.mjs +0 -0
  106. package/src/reference/remark-notation.mjs +753 -0
  107. package/src/reference/render-lab-math.ts +99 -0
  108. package/src/reference/types.ts +182 -0
  109. package/src/reference/units-vocab.ts +143 -0
  110. package/src/session/PreferenceStore.ts +54 -0
  111. package/src/session/user-context.ts +18 -0
  112. package/src/session/user.ts +24 -0
  113. package/src/styles/global.css +763 -0
@@ -0,0 +1,601 @@
1
+ /**
2
+ * The one compiler manifest (Phase D5).
3
+ *
4
+ * `compileManifest()` walks the content tree exactly once — through the single
5
+ * loader in `collections.ts` — builds the notation registry, runs every
6
+ * diagnostic pass, and assembles one deterministic JSON document. Everything
7
+ * downstream reads that document and nothing else:
8
+ *
9
+ * - `pnpm validate:content` (`scripts/compile-manifest.ts`) regenerates and
10
+ * writes it, then reports diagnostics.
11
+ * - `astro.config.mjs` reads the sidebar order and the raw notation / source
12
+ * records the remark adapters need.
13
+ * - The page components (`LessonFooter`, `NotationGlossary`, `CurriculumMap`,
14
+ * `AssessmentSet`) read resolved bundles, backlinks and lesson metadata.
15
+ * None re-reads the filesystem or rebuilds the registry.
16
+ * - `pnpm build` runs `validate:content` first, so the file is always current
17
+ * before `astro build`.
18
+ *
19
+ * The MDX render path (`getCollection` / `render(entry)`) is unchanged: the
20
+ * manifest replaces the *derived* structures, not the Markdown pipeline.
21
+ *
22
+ * Determinism: `serializeManifest` sorts object keys recursively, every array
23
+ * is ordered by a stable key, paths are repo-relative, and nothing records a
24
+ * timestamp. Two compiles of one tree produce a byte-identical file and
25
+ * therefore a byte-identical `dist/`. This is the contract a future MCP server
26
+ * or LMS importer consumes — see `docs/architecture.md` section 15.
27
+ */
28
+
29
+ import { createHash } from 'node:crypto';
30
+ import {
31
+ existsSync,
32
+ mkdirSync,
33
+ readFileSync,
34
+ readdirSync,
35
+ writeFileSync,
36
+ } from 'node:fs';
37
+ import { dirname, join, relative } from 'node:path';
38
+ import { parseFrontmatter } from '@astrojs/markdown-remark';
39
+
40
+ import {
41
+ validateCurriculum,
42
+ type CurriculumIssue,
43
+ } from '../curriculum/validation';
44
+ import {
45
+ runConsistencyChecks,
46
+ type ConsistencyDiagnostic,
47
+ } from '../reference/consistency';
48
+ import { validateNotationAlignment } from '../reference/curriculum-alignment';
49
+ import {
50
+ validateEquations,
51
+ type EquationDiagnostic,
52
+ type EquationLabelRecord,
53
+ } from '../reference/equations-validate';
54
+ import {
55
+ gateContentMath,
56
+ type MathGateDiagnostic,
57
+ } from '../reference/gate-math';
58
+ import { loadLintIgnore } from '../reference/lint-ignore';
59
+ import { buildNotationRegistry } from '../reference/registry';
60
+ import { resolveReferenceId } from '../reference/references';
61
+ import type {
62
+ EditorialStatus,
63
+ NotationBacklink,
64
+ NotationDefinitionRecord,
65
+ NotationDiagnostic,
66
+ NotationLessonInput,
67
+ NotationPageBundle,
68
+ } from '../reference/types';
69
+ import type { CourseConfig } from './course-config';
70
+ import {
71
+ loadCurriculumCatalog,
72
+ loadNotationDefinitions,
73
+ loadNotationRegistryInput,
74
+ loadNotationSpecs,
75
+ loadSourceRecords,
76
+ } from './collections';
77
+ import {
78
+ buildSidebar,
79
+ deriveSources,
80
+ isLessonSlug,
81
+ type SidebarGroup,
82
+ } from './lesson-derivation';
83
+
84
+ /**
85
+ * Bump when a consumer-visible shape changes. A client asserts against this
86
+ * before trusting the document (`manifest-version-compat`, Tier 5).
87
+ *
88
+ * v2 (D6): `lessons[].notation.resolution` (symbol → key → scope → span) and
89
+ * `diagnostics.consistency` (Tier-3 corpus checks).
90
+ * v3 (D7): `equations` (labelled display equations + cross-page number table),
91
+ * `lessons[].equations`, and `diagnostics.equations`.
92
+ */
93
+ export const MANIFEST_SCHEMA_VERSION = 3;
94
+
95
+ const REPOSITORY_ROOT = process.cwd();
96
+ // Content is the top-level `content/` folder; the build output is `build/`
97
+ // (Phase F). The engine names only these conventions, never a course path.
98
+ const CONTENT_ROOT = join(REPOSITORY_ROOT, 'content');
99
+ export const MANIFEST_PATH = join(REPOSITORY_ROOT, 'build', 'manifest.json');
100
+
101
+ // --- shape ------------------------------------------------------------
102
+
103
+ export interface ManifestCitation {
104
+ readonly id: string;
105
+ readonly locator?: string;
106
+ }
107
+
108
+ export interface PrereqEdge {
109
+ readonly from: string;
110
+ readonly to: string;
111
+ }
112
+
113
+ /**
114
+ * One resolved notation reference occurrence — the row of the committed
115
+ * per-lesson resolution report (`resolution/<lessonId>.notation.json`).
116
+ */
117
+ export interface ManifestNotationResolution {
118
+ /** The resolved definition's LaTeX glyph, or the raw key if unresolved. */
119
+ readonly symbol: string;
120
+ readonly key: string;
121
+ readonly scope: 'shared' | 'local' | 'unresolved';
122
+ readonly definitionId: string | null;
123
+ readonly kind: 'prose' | 'math' | 'definition';
124
+ readonly line: number | null;
125
+ readonly column: number | null;
126
+ }
127
+
128
+ /** One resolved notation entry, denormalized for the lesson footer. */
129
+ export interface ManifestLessonNotation {
130
+ readonly id: string;
131
+ readonly key: string;
132
+ readonly scope: 'shared' | 'local';
133
+ readonly notation: string;
134
+ readonly label: string;
135
+ readonly summary: string;
136
+ readonly editorialStatus: EditorialStatus;
137
+ readonly seeAlso: readonly string[];
138
+ readonly units?: string;
139
+ readonly formula?: string;
140
+ }
141
+
142
+ export interface ManifestLesson {
143
+ readonly id: string;
144
+ /** Docs-loader slug, e.g. `bonds/price-yield-relationship`. */
145
+ readonly slug: string;
146
+ readonly file: string;
147
+ readonly title: string;
148
+ readonly description: string;
149
+ readonly status: EditorialStatus;
150
+ /** Ordered — the curriculum contract. */
151
+ readonly teaches: readonly string[];
152
+ readonly requires: readonly string[];
153
+ readonly assumptions: readonly string[];
154
+ readonly assessments: readonly string[];
155
+ readonly sources: readonly string[];
156
+ readonly citations: readonly ManifestCitation[];
157
+ /** Competency prerequisite edges this lesson introduces. */
158
+ readonly prereqEdges: readonly PrereqEdge[];
159
+ /** Labelled display equations in appearance order (D7). */
160
+ readonly equations: readonly {
161
+ readonly key: string;
162
+ readonly number: string;
163
+ }[];
164
+ readonly notation: {
165
+ readonly bindings: readonly {
166
+ readonly key: string;
167
+ readonly definitionId: string;
168
+ }[];
169
+ readonly definitionIds: readonly string[];
170
+ readonly definitions: readonly ManifestLessonNotation[];
171
+ /** Every `[[key]]` / `\explain{key}` occurrence, resolved and located. */
172
+ readonly resolution: readonly ManifestNotationResolution[];
173
+ };
174
+ }
175
+
176
+ export interface Manifest {
177
+ readonly schemaVersion: number;
178
+ readonly notation: {
179
+ readonly definitions: readonly NotationDefinitionRecord[];
180
+ readonly bundles: readonly NotationPageBundle[];
181
+ readonly backlinks: readonly NotationBacklink[];
182
+ readonly diagnostics: readonly NotationDiagnostic[];
183
+ /** Raw notation frontmatter for `remarkNotation`, ordered by key. */
184
+ readonly raw: readonly Record<string, unknown>[];
185
+ };
186
+ readonly competencies: readonly Record<string, unknown>[];
187
+ readonly sources: readonly Record<string, unknown>[];
188
+ readonly tracks: readonly Record<string, unknown>[];
189
+ readonly sidebar: readonly SidebarGroup[];
190
+ readonly lessons: readonly ManifestLesson[];
191
+ /** Full competency prerequisite graph. */
192
+ readonly prereqEdges: readonly PrereqEdge[];
193
+ /** Equation identity (D7): every labelled display equation + the render-path lookup. */
194
+ readonly equations: {
195
+ readonly labels: readonly EquationLabelRecord[];
196
+ /** `slug -> { eqKey: number }` — passed to `remarkNotation` for `[[slug#eq:key]]`. */
197
+ readonly numbersBySlug: Record<string, Record<string, string>>;
198
+ };
199
+ readonly diagnostics: {
200
+ readonly curriculum: readonly CurriculumIssue[];
201
+ readonly notation: readonly NotationDiagnostic[];
202
+ readonly alignment: readonly NotationDiagnostic[];
203
+ readonly math: readonly MathGateDiagnostic[];
204
+ /** Tier-3 corpus consistency checks (D6). Warnings only. */
205
+ readonly consistency: readonly ConsistencyDiagnostic[];
206
+ /** Equation-identity checks (D7): `eq-duplicate-key`, `eq-ref-resolves` (error); `eq-key-unused` (warning). */
207
+ readonly equations: readonly EquationDiagnostic[];
208
+ };
209
+ readonly hashes: {
210
+ /** `sha256` of every content source file, keyed by repo-relative path. */
211
+ readonly content: Record<string, string>;
212
+ /** Single fingerprint of the content tree. */
213
+ readonly contentTree: string;
214
+ };
215
+ }
216
+
217
+ // --- compile ---------------------------------------------------------
218
+
219
+ const CITE_PATTERN = /\[@([a-z0-9][a-z0-9.-]*)(?:;[ \t\r\n]*([^\]]*))?\]/g;
220
+
221
+ /** Distinct `[@id; locator]` occurrences in a body, in first-appearance order. */
222
+ function lessonCitations(body: string): ManifestCitation[] {
223
+ const withoutCode = body
224
+ .replace(/```[\s\S]*?```/g, '')
225
+ .replace(/`[^`\n]*`/g, '');
226
+ const seen = new Set<string>();
227
+ const citations: ManifestCitation[] = [];
228
+ for (const match of withoutCode.matchAll(CITE_PATTERN)) {
229
+ const id = (match[1] ?? '').trim();
230
+ const locator = (match[2] ?? '').replace(/\s+/g, ' ').trim() || undefined;
231
+ const dedupeKey = `${id} ${locator ?? ''}`;
232
+ if (seen.has(dedupeKey)) continue;
233
+ seen.add(dedupeKey);
234
+ citations.push(locator === undefined ? { id } : { id, locator });
235
+ }
236
+ return citations;
237
+ }
238
+
239
+ function contentHashes(): {
240
+ content: Record<string, string>;
241
+ contentTree: string;
242
+ } {
243
+ const files: string[] = [];
244
+ const walk = (directory: string): void => {
245
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
246
+ const path = join(directory, entry.name);
247
+ if (entry.isDirectory()) walk(path);
248
+ else if (entry.isFile()) files.push(path);
249
+ }
250
+ };
251
+ walk(CONTENT_ROOT);
252
+ files.sort();
253
+
254
+ const content: Record<string, string> = {};
255
+ const tree = createHash('sha256');
256
+ for (const path of files) {
257
+ const relPath = relative(REPOSITORY_ROOT, path).replace(/\\/g, '/');
258
+ const digest = createHash('sha256')
259
+ .update(readFileSync(path))
260
+ .digest('hex');
261
+ content[relPath] = `sha256:${digest}`;
262
+ tree.update(`${relPath} ${digest}\n`);
263
+ }
264
+ return { content, contentTree: `sha256:${tree.digest('hex')}` };
265
+ }
266
+
267
+ /**
268
+ * Every `[[key]]` / `\explain{key}` occurrence in a lesson, resolved by lexical
269
+ * scope (page-local first, then shared) and located. The rows of the committed
270
+ * resolution report (D6 (d)); order follows `references`, already sorted by
271
+ * (line, column, key).
272
+ */
273
+ function lessonNotationResolution(
274
+ lesson: NotationLessonInput,
275
+ definitionsById: ReadonlyMap<string, NotationDefinitionRecord>,
276
+ availableDefinitionIds: ReadonlySet<string>,
277
+ ): ManifestNotationResolution[] {
278
+ const scope = { kind: 'page' as const, lessonId: lesson.lessonId };
279
+ return lesson.references.map((reference) => {
280
+ const definitionId = resolveReferenceId(
281
+ reference.key,
282
+ scope,
283
+ availableDefinitionIds,
284
+ );
285
+ const definition =
286
+ definitionId === undefined
287
+ ? undefined
288
+ : definitionsById.get(definitionId);
289
+ return {
290
+ symbol: definition?.notation ?? reference.key,
291
+ key: reference.key,
292
+ scope: definition
293
+ ? definition.kind === 'shared'
294
+ ? 'shared'
295
+ : 'local'
296
+ : 'unresolved',
297
+ definitionId: definitionId ?? null,
298
+ kind: reference.kind,
299
+ line: reference.source.line ?? null,
300
+ column: reference.source.column ?? null,
301
+ };
302
+ });
303
+ }
304
+
305
+ function lessonNotationBundle(
306
+ bundle: NotationPageBundle | undefined,
307
+ definitionsById: ReadonlyMap<string, NotationDefinitionRecord>,
308
+ resolution: readonly ManifestNotationResolution[],
309
+ ): ManifestLesson['notation'] {
310
+ const definitions: ManifestLessonNotation[] = [];
311
+ for (const definitionId of bundle?.definitionIds ?? []) {
312
+ const definition = definitionsById.get(definitionId);
313
+ if (!definition) continue;
314
+ definitions.push({
315
+ id: definition.id,
316
+ key: definition.key,
317
+ scope: definition.kind === 'shared' ? 'shared' : 'local',
318
+ notation: definition.notation,
319
+ label: definition.label,
320
+ summary: definition.summary,
321
+ editorialStatus: definition.status,
322
+ seeAlso: [...definition.seeAlso],
323
+ ...(definition.units === undefined ? {} : { units: definition.units }),
324
+ ...(definition.formula === undefined
325
+ ? {}
326
+ : { formula: definition.formula }),
327
+ });
328
+ }
329
+ return {
330
+ bindings: [...(bundle?.bindings ?? [])].map((binding) => ({ ...binding })),
331
+ definitionIds: [...(bundle?.definitionIds ?? [])],
332
+ definitions,
333
+ resolution: [...resolution],
334
+ };
335
+ }
336
+
337
+ /** `title` / `description` from a lesson's raw docs frontmatter, keyed by id. */
338
+ function lessonFrontmatterById(): Map<
339
+ string,
340
+ { title: string; description: string }
341
+ > {
342
+ const base = join(CONTENT_ROOT, 'docs');
343
+ const result = new Map<string, { title: string; description: string }>();
344
+ const walk = (directory: string): void => {
345
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
346
+ const path = join(directory, entry.name);
347
+ if (entry.isDirectory()) {
348
+ walk(path);
349
+ continue;
350
+ }
351
+ if (!entry.isFile() || !/\.mdx?$/.test(entry.name)) continue;
352
+ const slug = relative(base, path).replace(/\\/g, '/');
353
+ if (!isLessonSlug(slug)) continue;
354
+ const id = slug.replace(/\.(md|mdx)$/, '').replace(/\//g, '.');
355
+ const data = (parseFrontmatter(readFileSync(path, 'utf8')).frontmatter ??
356
+ {}) as Record<string, unknown>;
357
+ result.set(id, {
358
+ title: typeof data.title === 'string' ? data.title : id,
359
+ description:
360
+ typeof data.description === 'string' ? data.description : '',
361
+ });
362
+ }
363
+ };
364
+ walk(base);
365
+ return result;
366
+ }
367
+
368
+ /**
369
+ * The engine never imports the course's config file — the caller passes it.
370
+ * `astro.config.mjs` and `scripts/*` import `content/course.config.ts` (they are
371
+ * the wiring layer); tests pass a `CourseConfig` value directly.
372
+ */
373
+ export async function compileManifest(
374
+ courseConfig: CourseConfig,
375
+ ): Promise<Manifest> {
376
+ const [catalog, notationInput] = await Promise.all([
377
+ loadCurriculumCatalog(),
378
+ loadNotationRegistryInput(),
379
+ ]);
380
+
381
+ const registry = buildNotationRegistry(notationInput);
382
+ const definitionsById = new Map(
383
+ registry.definitions.map((definition) => [definition.id, definition]),
384
+ );
385
+ const availableDefinitionIds = new Set(definitionsById.keys());
386
+ const bundlesByLesson = new Map(
387
+ registry.bundles.map((bundle) => [bundle.lessonId, bundle]),
388
+ );
389
+
390
+ const curriculumIssues = validateCurriculum(catalog);
391
+ const alignmentIssues = validateNotationAlignment(registry, catalog);
392
+ const mathIssues = gateContentMath({
393
+ notationInput,
394
+ assessments: catalog.assessments as unknown as {
395
+ id: string;
396
+ items?: { id?: string; prompt?: string; explanation?: string }[];
397
+ }[],
398
+ lessonAssessments: catalog.lessons.map((lesson) => ({
399
+ lessonId: lesson.id,
400
+ assessmentIds: lesson.assessments,
401
+ })),
402
+ });
403
+
404
+ const prerequisitesById = new Map(
405
+ catalog.competencies.map((competency) => [
406
+ competency.id,
407
+ competency.prerequisites ?? [],
408
+ ]),
409
+ );
410
+ const sortEdges = (edges: PrereqEdge[]): PrereqEdge[] =>
411
+ edges.sort(
412
+ (a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to),
413
+ );
414
+ const prereqEdges = sortEdges(
415
+ [...prerequisitesById].flatMap(([to, prerequisites]) =>
416
+ prerequisites.map((from) => ({ from, to })),
417
+ ),
418
+ );
419
+
420
+ const lessonMetaById = new Map(
421
+ catalog.lessons.map((lesson) => [lesson.id, lesson]),
422
+ );
423
+ const frontmatterById = lessonFrontmatterById();
424
+
425
+ const equationValidation = validateEquations(
426
+ notationInput.lessons.map((lesson) => ({
427
+ lessonId: lesson.lessonId,
428
+ slug: lesson.lessonId.replace(/\./g, '/'),
429
+ file: lesson.source.file,
430
+ body: lesson.body,
431
+ })),
432
+ );
433
+ const equationsBySlug = new Map(
434
+ Object.entries(equationValidation.numbersBySlug).map(([slug, table]) => [
435
+ slug,
436
+ Object.entries(table).map(([key, number]) => ({ key, number })),
437
+ ]),
438
+ );
439
+
440
+ const lessons: ManifestLesson[] = [];
441
+ for (const notationLesson of notationInput.lessons) {
442
+ const id = notationLesson.lessonId;
443
+ const meta = lessonMetaById.get(id);
444
+ if (!meta) continue;
445
+ const teaches = meta.teaches;
446
+ const frontmatter = frontmatterById.get(id) ?? {
447
+ title: id,
448
+ description: '',
449
+ };
450
+ lessons.push({
451
+ id,
452
+ slug: id.replace(/\./g, '/'),
453
+ file: notationLesson.source.file,
454
+ title: frontmatter.title,
455
+ description: frontmatter.description,
456
+ status: meta.status,
457
+ teaches: [...teaches],
458
+ requires: [...meta.requires],
459
+ assumptions: [...meta.assumptions],
460
+ assessments: [...meta.assessments],
461
+ sources: deriveSources(notationLesson.body),
462
+ citations: lessonCitations(notationLesson.body),
463
+ equations: equationsBySlug.get(id.replace(/\./g, '/')) ?? [],
464
+ prereqEdges: sortEdges(
465
+ teaches.flatMap((to) =>
466
+ (prerequisitesById.get(to) ?? [])
467
+ .filter((from) => !teaches.includes(from))
468
+ .map((from) => ({ from, to })),
469
+ ),
470
+ ),
471
+ notation: lessonNotationBundle(
472
+ bundlesByLesson.get(id),
473
+ definitionsById,
474
+ lessonNotationResolution(
475
+ notationLesson,
476
+ definitionsById,
477
+ availableDefinitionIds,
478
+ ),
479
+ ),
480
+ });
481
+ }
482
+ lessons.sort((a, b) => a.id.localeCompare(b.id));
483
+
484
+ const consistencyIssues = runConsistencyChecks({
485
+ registry,
486
+ notationInput,
487
+ specs: loadNotationSpecs(),
488
+ catalog,
489
+ lintIgnore: loadLintIgnore(),
490
+ conventions: courseConfig.conventions,
491
+ });
492
+
493
+ const rawNotation = [...loadNotationDefinitions()].sort((a, b) =>
494
+ String(a.key).localeCompare(String(b.key)),
495
+ );
496
+ const competencies = catalog.competencies
497
+ .map((entry) => ({ ...entry }))
498
+ .sort(byId);
499
+ const sources = [...loadSourceRecords()].sort(byId);
500
+ const tracks = catalog.tracks.map((entry) => ({ ...entry })).sort(byId);
501
+
502
+ const lessonSlugs = [...notationInput.lessons]
503
+ .map((lesson) => lesson.lessonId.replace(/\./g, '/'))
504
+ .sort();
505
+ const sidebar = buildSidebar(
506
+ catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
507
+ lessonSlugs,
508
+ courseConfig.sections,
509
+ );
510
+
511
+ return {
512
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
513
+ notation: {
514
+ definitions: registry.definitions,
515
+ bundles: registry.bundles,
516
+ backlinks: registry.backlinks,
517
+ diagnostics: registry.diagnostics,
518
+ raw: rawNotation,
519
+ },
520
+ competencies,
521
+ sources,
522
+ tracks,
523
+ sidebar,
524
+ lessons,
525
+ prereqEdges,
526
+ equations: {
527
+ labels: equationValidation.labels,
528
+ numbersBySlug: equationValidation.numbersBySlug,
529
+ },
530
+ diagnostics: {
531
+ curriculum: curriculumIssues,
532
+ notation: registry.diagnostics,
533
+ alignment: alignmentIssues,
534
+ math: mathIssues,
535
+ consistency: consistencyIssues,
536
+ equations: equationValidation.diagnostics,
537
+ },
538
+ hashes: contentHashes(),
539
+ };
540
+ }
541
+
542
+ function byId(a: Record<string, unknown>, b: Record<string, unknown>): number {
543
+ return String(a.id).localeCompare(String(b.id));
544
+ }
545
+
546
+ // --- serialize / read / write --------------------------------------
547
+
548
+ /** JSON with recursively sorted object keys — the byte-identical contract. */
549
+ export function serializeManifest(manifest: Manifest): string {
550
+ const sortKeys = (value: unknown): unknown => {
551
+ if (Array.isArray(value)) return value.map(sortKeys);
552
+ if (value && typeof value === 'object') {
553
+ return Object.fromEntries(
554
+ Object.keys(value as Record<string, unknown>)
555
+ .sort()
556
+ .map((key) => [
557
+ key,
558
+ sortKeys((value as Record<string, unknown>)[key]),
559
+ ]),
560
+ );
561
+ }
562
+ return value;
563
+ };
564
+ return `${JSON.stringify(sortKeys(manifest), null, 2)}\n`;
565
+ }
566
+
567
+ export function writeManifest(manifest: Manifest): void {
568
+ mkdirSync(dirname(MANIFEST_PATH), { recursive: true });
569
+ writeFileSync(MANIFEST_PATH, serializeManifest(manifest));
570
+ }
571
+
572
+ export function readManifestSync(): Manifest {
573
+ if (!existsSync(MANIFEST_PATH)) {
574
+ throw new Error(
575
+ `Manifest not found at ${relative(REPOSITORY_ROOT, MANIFEST_PATH)}. Run \`pnpm validate:content\` first.`,
576
+ );
577
+ }
578
+ return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Manifest;
579
+ }
580
+
581
+ /**
582
+ * The reader every consumer uses. Reads `build/manifest.json` when it exists —
583
+ * the case for every page component, since `pnpm build` / `pnpm verify` run
584
+ * `validate:content` first. Only a cold `astro dev` misses, and `astro.config.mjs`
585
+ * passes `courseConfig` there so this can compile once; an engine consumer that
586
+ * hits a cold miss with no config gets a clear "run validate:content" error
587
+ * rather than the engine reaching into the course tree.
588
+ */
589
+ export async function loadManifest(
590
+ courseConfig?: CourseConfig,
591
+ ): Promise<Manifest> {
592
+ if (existsSync(MANIFEST_PATH)) return readManifestSync();
593
+ if (!courseConfig) {
594
+ throw new Error(
595
+ `${relative(REPOSITORY_ROOT, MANIFEST_PATH)} not found. Run \`pnpm validate:content\` first.`,
596
+ );
597
+ }
598
+ const manifest = await compileManifest(courseConfig);
599
+ writeManifest(manifest);
600
+ return manifest;
601
+ }