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.
- package/README.md +108 -0
- package/dist/analytics/AnalyticsEmitter.d.ts +22 -0
- package/dist/analytics/AnalyticsEmitter.js +5 -0
- package/dist/compiler/cli.d.ts +189 -0
- package/dist/compiler/cli.js +552 -0
- package/dist/compiler/collections.d.ts +70 -0
- package/dist/compiler/collections.js +383 -0
- package/dist/compiler/course-config.d.ts +31 -0
- package/dist/compiler/course-config.js +0 -0
- package/dist/compiler/lesson-derivation.d.ts +89 -0
- package/dist/compiler/lesson-derivation.js +91 -0
- package/dist/compiler/manifest.d.ts +178 -0
- package/dist/compiler/manifest.js +345 -0
- package/dist/compiler/resolution-report.d.ts +45 -0
- package/dist/compiler/resolution-report.js +97 -0
- package/dist/curriculum/validation.d.ts +76 -0
- package/dist/curriculum/validation.js +320 -0
- package/dist/progress/ProgressRepository.d.ts +53 -0
- package/dist/progress/ProgressRepository.js +12 -0
- package/dist/reference/base-library.mjs +27 -0
- package/dist/reference/citation-format.mjs +133 -0
- package/dist/reference/consistency.d.ts +64 -0
- package/dist/reference/consistency.js +234 -0
- package/dist/reference/curriculum-alignment.d.ts +7 -0
- package/dist/reference/curriculum-alignment.js +166 -0
- package/dist/reference/equations-validate.d.ts +50 -0
- package/dist/reference/equations-validate.js +90 -0
- package/dist/reference/equations.mjs +144 -0
- package/dist/reference/gate-math.d.ts +41 -0
- package/dist/reference/gate-math.js +151 -0
- package/dist/reference/katex-options.mjs +91 -0
- package/dist/reference/lab-math-scope.d.ts +21 -0
- package/dist/reference/lab-math-scope.js +28 -0
- package/dist/reference/label.d.ts +11 -0
- package/dist/reference/label.js +38 -0
- package/dist/reference/lint-ignore.d.ts +32 -0
- package/dist/reference/lint-ignore.js +39 -0
- package/dist/reference/math-glyphs.mjs +830 -0
- package/dist/reference/prose.d.ts +15 -0
- package/dist/reference/prose.js +12 -0
- package/dist/reference/references.d.ts +17 -0
- package/dist/reference/references.js +42 -0
- package/dist/reference/registry.d.ts +6 -0
- package/dist/reference/registry.js +548 -0
- package/dist/reference/rehype-fail-katex-errors.mjs +55 -0
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-notation.mjs +753 -0
- package/dist/reference/render-lab-math.d.ts +21 -0
- package/dist/reference/render-lab-math.js +58 -0
- package/dist/reference/types.d.ts +151 -0
- package/dist/reference/types.js +0 -0
- package/dist/reference/units-vocab.d.ts +26 -0
- package/dist/reference/units-vocab.js +119 -0
- package/dist/session/PreferenceStore.d.ts +28 -0
- package/dist/session/PreferenceStore.js +30 -0
- package/dist/session/user-context.d.ts +7 -0
- package/dist/session/user-context.js +10 -0
- package/dist/session/user.d.ts +21 -0
- package/dist/session/user.js +8 -0
- package/package.json +53 -0
- package/src/analytics/AnalyticsEmitter.ts +23 -0
- package/src/compiler/cli.ts +866 -0
- package/src/compiler/collections.ts +554 -0
- package/src/compiler/course-config.ts +32 -0
- package/src/compiler/lesson-derivation.ts +186 -0
- package/src/compiler/manifest.ts +601 -0
- package/src/compiler/resolution-report.ts +150 -0
- package/src/components/CurriculumMap.astro +208 -0
- package/src/components/HoverPanel.astro +67 -0
- package/src/components/assessments/AssessmentRunner.tsx +320 -0
- package/src/components/assessments/AssessmentSet.astro +54 -0
- package/src/components/citation/CitationLayer.astro +173 -0
- package/src/components/examples/CompactExample.astro +34 -0
- package/src/components/examples/CompactExamples.astro +253 -0
- package/src/components/hover-panel.ts +273 -0
- package/src/components/notation/NotationGlossary.astro +412 -0
- package/src/components/notation/NotationLayer.astro +594 -0
- package/src/components/notation/NotationMath.astro +24 -0
- package/src/components/notation/SafeMathNode.astro +23 -0
- package/src/components/notation/render-notation-math.ts +223 -0
- package/src/components/starlight/EquationEnhancer.astro +72 -0
- package/src/components/starlight/LayoutHeader.astro +102 -0
- package/src/components/starlight/LayoutPageSidebar.astro +33 -0
- package/src/components/starlight/LayoutSidebar.astro +29 -0
- package/src/components/starlight/LessonFooter.astro +58 -0
- package/src/content-config.ts +259 -0
- package/src/curriculum/validation.ts +481 -0
- package/src/progress/ProgressRepository.ts +64 -0
- package/src/reference/base-library.mjs +27 -0
- package/src/reference/citation-format.mjs +133 -0
- package/src/reference/consistency.ts +375 -0
- package/src/reference/curriculum-alignment.ts +228 -0
- package/src/reference/equations-validate.ts +167 -0
- package/src/reference/equations.mjs +144 -0
- package/src/reference/gate-math.ts +250 -0
- package/src/reference/katex-options.mjs +91 -0
- package/src/reference/lab-math-scope.ts +54 -0
- package/src/reference/label.ts +47 -0
- package/src/reference/lint-ignore.ts +75 -0
- package/src/reference/math-glyphs.mjs +830 -0
- package/src/reference/prose.ts +21 -0
- package/src/reference/references.ts +82 -0
- package/src/reference/registry.ts +723 -0
- package/src/reference/rehype-fail-katex-errors.mjs +55 -0
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-notation.mjs +753 -0
- package/src/reference/render-lab-math.ts +99 -0
- package/src/reference/types.ts +182 -0
- package/src/reference/units-vocab.ts +143 -0
- package/src/session/PreferenceStore.ts +54 -0
- package/src/session/user-context.ts +18 -0
- package/src/session/user.ts +24 -0
- 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
|
+
}
|