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,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single path from content files to typed data.
|
|
3
|
+
*
|
|
4
|
+
* Every consumer — the `validate:content` CLI, the Astro build (`astro.config.mjs`),
|
|
5
|
+
* the Starlight footer, the lab math scope, and the tests — reads content through
|
|
6
|
+
* this module. It walks `src/content/` once and parses frontmatter with Astro's
|
|
7
|
+
* own `parseFrontmatter` (the same parser the content collections use), so there
|
|
8
|
+
* is no second Markdown/YAML reader and `gray-matter` is no longer a dependency.
|
|
9
|
+
*
|
|
10
|
+
* Replaces `scripts/curriculum-files.ts`, `scripts/notation-files.ts`, and the
|
|
11
|
+
* inline `readdirSync`/`gray-matter` notation + source loads in `astro.config.mjs`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { basename, join, relative } from 'node:path';
|
|
16
|
+
import { parseFrontmatter } from '@astrojs/markdown-remark';
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
AssessmentDefinition,
|
|
20
|
+
CompetencyDefinition,
|
|
21
|
+
CurriculumCatalog,
|
|
22
|
+
LessonDefinition,
|
|
23
|
+
SourceDefinition,
|
|
24
|
+
TrackDefinition,
|
|
25
|
+
} from '../curriculum/validation';
|
|
26
|
+
import type {
|
|
27
|
+
EditorialStatus,
|
|
28
|
+
LocalNotationDefinitionInput,
|
|
29
|
+
NotationAlignment,
|
|
30
|
+
NotationLessonInput,
|
|
31
|
+
NotationReferenceInput,
|
|
32
|
+
NotationRegistryInput,
|
|
33
|
+
SharedNotationDefinitionInput,
|
|
34
|
+
SourceSpan,
|
|
35
|
+
} from '../reference/types';
|
|
36
|
+
import { isSubstantiveMeaning } from '../reference/prose';
|
|
37
|
+
import { parseEquationRef } from '../reference/equations.mjs';
|
|
38
|
+
import { resolveLabel } from '../reference/label';
|
|
39
|
+
import {
|
|
40
|
+
deriveRequires,
|
|
41
|
+
deriveSources,
|
|
42
|
+
isLessonSlug,
|
|
43
|
+
lessonIdFromSlug,
|
|
44
|
+
parseCheckList,
|
|
45
|
+
} from './lesson-derivation';
|
|
46
|
+
|
|
47
|
+
// Every invocation — the CLI, `astro build`/`check`, and vitest — runs with the
|
|
48
|
+
// repository root as the working directory. Content is the top-level `content/`
|
|
49
|
+
// folder (Phase F); the engine names only this convention, never a course path.
|
|
50
|
+
const REPOSITORY_ROOT = process.cwd();
|
|
51
|
+
const CONTENT_ROOT = join(REPOSITORY_ROOT, 'content');
|
|
52
|
+
|
|
53
|
+
/** Every file with one of the given extensions below `directory`, sorted. */
|
|
54
|
+
function filesBelow(
|
|
55
|
+
directory: string,
|
|
56
|
+
extensions: readonly string[],
|
|
57
|
+
): string[] {
|
|
58
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
59
|
+
return entries
|
|
60
|
+
.flatMap((entry) => {
|
|
61
|
+
const path = join(directory, entry.name);
|
|
62
|
+
if (entry.isDirectory()) return filesBelow(path, extensions);
|
|
63
|
+
return entry.isFile() &&
|
|
64
|
+
extensions.some((extension) => entry.name.endsWith(extension))
|
|
65
|
+
? [path]
|
|
66
|
+
: [];
|
|
67
|
+
})
|
|
68
|
+
.sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ParsedDocument {
|
|
72
|
+
/** Path relative to the repository root, e.g. `src/content/docs/foo.md`. */
|
|
73
|
+
readonly file: string;
|
|
74
|
+
readonly data: Record<string, unknown>;
|
|
75
|
+
/** Markdown body, byte-identical to the previous `gray-matter` output. */
|
|
76
|
+
readonly body: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Astro's `parseFrontmatter` prepends one newline to the body that `gray-matter`
|
|
81
|
+
* did not; strip it so body offsets in diagnostics are unchanged.
|
|
82
|
+
*/
|
|
83
|
+
function parseDocument(path: string): ParsedDocument {
|
|
84
|
+
const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
|
|
85
|
+
return {
|
|
86
|
+
file: relative(REPOSITORY_ROOT, path),
|
|
87
|
+
data: (parsed.frontmatter ?? {}) as Record<string, unknown>,
|
|
88
|
+
body: parsed.content.replace(/^\n/, ''),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- shared value coercions (ported from the deleted loaders) --------------
|
|
93
|
+
|
|
94
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
95
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
96
|
+
throw new TypeError(`${label} must be an object`);
|
|
97
|
+
}
|
|
98
|
+
return value as Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function string(value: unknown, label: string): string {
|
|
102
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
103
|
+
throw new TypeError(`${label} must be a non-empty string`);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function optionalString(value: unknown, label: string): string | undefined {
|
|
109
|
+
return value === undefined ? undefined : string(value, label);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A notation `meaning`: a non-empty string that clears the C1b safeguard. */
|
|
113
|
+
function meaningText(value: unknown, label: string): string {
|
|
114
|
+
const text = string(value, label);
|
|
115
|
+
if (!isSubstantiveMeaning(text)) {
|
|
116
|
+
throw new TypeError(
|
|
117
|
+
`${label} must be substantive prose (at least four words, not a placeholder)`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return text;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function strings(value: unknown, label: string): string[] {
|
|
124
|
+
if (value === undefined) return [];
|
|
125
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
126
|
+
throw new TypeError(`${label} must be an array of strings`);
|
|
127
|
+
}
|
|
128
|
+
return [...value] as string[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Notation `sources`: a bare id (legacy) or `{id, locator}` (Phase C1b). The
|
|
133
|
+
* locator is not yet carried into the registry input — see D6/D13.
|
|
134
|
+
*/
|
|
135
|
+
function sourceIds(value: unknown, label: string): string[] {
|
|
136
|
+
if (value === undefined) return [];
|
|
137
|
+
if (!Array.isArray(value)) {
|
|
138
|
+
throw new TypeError(`${label} must be an array`);
|
|
139
|
+
}
|
|
140
|
+
return value.map((item, index) => {
|
|
141
|
+
if (typeof item === 'string') return item;
|
|
142
|
+
if (item !== null && typeof item === 'object' && 'id' in item) {
|
|
143
|
+
return string((item as { id: unknown }).id, `${label}[${index}].id`);
|
|
144
|
+
}
|
|
145
|
+
throw new TypeError(`${label}[${index}] must be an id or { id, locator }`);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function boolean(value: unknown, label: string): boolean {
|
|
150
|
+
if (typeof value !== 'boolean') {
|
|
151
|
+
throw new TypeError(`${label} must be a boolean`);
|
|
152
|
+
}
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function status(value: unknown, label: string): EditorialStatus {
|
|
157
|
+
if (value !== 'draft' && value !== 'in-review' && value !== 'reviewed') {
|
|
158
|
+
throw new TypeError(`${label} must be draft, in-review, or reviewed`);
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function alignment(value: unknown, label: string): NotationAlignment {
|
|
164
|
+
const data = record(value, label);
|
|
165
|
+
if (data.kind === 'general') {
|
|
166
|
+
return {
|
|
167
|
+
kind: 'general',
|
|
168
|
+
rationale: string(data.rationale, `${label}.rationale`),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (data.kind === 'competency') {
|
|
172
|
+
return {
|
|
173
|
+
kind: 'competency',
|
|
174
|
+
introducedByCompetency: string(
|
|
175
|
+
data.introducedByCompetency,
|
|
176
|
+
`${label}.introducedByCompetency`,
|
|
177
|
+
),
|
|
178
|
+
introducedInLesson: string(
|
|
179
|
+
data.introducedInLesson,
|
|
180
|
+
`${label}.introducedInLesson`,
|
|
181
|
+
),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
throw new TypeError(`${label}.kind must be competency or general`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- notation reference extraction ----------------------------------------
|
|
188
|
+
|
|
189
|
+
function sourceSpan(file: string, text?: string, index?: number): SourceSpan {
|
|
190
|
+
if (text === undefined || index === undefined) return { file };
|
|
191
|
+
const before = text.slice(0, index);
|
|
192
|
+
const lines = before.split('\n');
|
|
193
|
+
return {
|
|
194
|
+
file,
|
|
195
|
+
line: lines.length,
|
|
196
|
+
column: (lines.at(-1)?.length ?? 0) + 1,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function maskMarkdownLiterals(text: string): string {
|
|
201
|
+
const mask = (value: string) => value.replace(/[^\n]/g, ' ');
|
|
202
|
+
return text
|
|
203
|
+
.replace(
|
|
204
|
+
/(^|\n)[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n[ \t]{0,3}\2[^\n]*(?=\n|$)|$)/g,
|
|
205
|
+
mask,
|
|
206
|
+
)
|
|
207
|
+
.replace(/^(?: {4}|\t).*$/gm, mask)
|
|
208
|
+
.replace(/(`+)(?!`)([^\n]*?)\1/g, mask);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Notation references (`[[key]]` in prose, `\explain{key}` in math) in a body,
|
|
213
|
+
* ignoring code fences and inline code, sorted by position.
|
|
214
|
+
*/
|
|
215
|
+
export function extractNotationReferences(
|
|
216
|
+
text: string,
|
|
217
|
+
file: string,
|
|
218
|
+
kind: NotationReferenceInput['kind'] | undefined,
|
|
219
|
+
): NotationReferenceInput[] {
|
|
220
|
+
const references: NotationReferenceInput[] = [];
|
|
221
|
+
const searchable = maskMarkdownLiterals(text);
|
|
222
|
+
const patterns: readonly [RegExp, NotationReferenceInput['kind']][] = [
|
|
223
|
+
[/\[\[\s*([^\][]+?)\s*\]\]/g, kind ?? 'prose'],
|
|
224
|
+
[/\\explain\{([^{}]+)\}/g, kind ?? 'math'],
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
for (const [pattern, referenceKind] of patterns) {
|
|
228
|
+
for (const match of searchable.matchAll(pattern)) {
|
|
229
|
+
const key = (match[1] ?? '').trim();
|
|
230
|
+
// `[[eq:key]]` / `[[slug#eq:key]]` are equation references (D7), resolved
|
|
231
|
+
// by the remark pass and the manifest, not notation keys.
|
|
232
|
+
if (parseEquationRef(key)) continue;
|
|
233
|
+
references.push({
|
|
234
|
+
key,
|
|
235
|
+
kind: referenceKind,
|
|
236
|
+
source: sourceSpan(file, text, match.index),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return references.sort(
|
|
242
|
+
(left, right) =>
|
|
243
|
+
(left.source.line ?? 0) - (right.source.line ?? 0) ||
|
|
244
|
+
(left.source.column ?? 0) - (right.source.column ?? 0) ||
|
|
245
|
+
left.key.localeCompare(right.key),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- curriculum catalog --------------------------------------------------
|
|
250
|
+
|
|
251
|
+
function jsonEntries<T extends { readonly id: string }>(
|
|
252
|
+
directory: string,
|
|
253
|
+
): T[] {
|
|
254
|
+
return filesBelow(directory, ['.json']).map((path) => {
|
|
255
|
+
const entry = JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
256
|
+
const filenameId = basename(path, '.json');
|
|
257
|
+
if (entry.id !== filenameId) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`${relative(REPOSITORY_ROOT, path)} declares id ${entry.id}; expected filename ${filenameId}.json`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return entry;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Path relative to `src/content/docs`, POSIX-separated, e.g. `bonds/yield.mdx`. */
|
|
267
|
+
function docSlug(path: string): string {
|
|
268
|
+
return relative(join(CONTENT_ROOT, 'docs'), path).replace(/\\/g, '/');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Assessment ids from a lesson's colocated `<name>.checks.yml`, or `[]`. */
|
|
272
|
+
function lessonCheckIds(lessonPath: string): string[] {
|
|
273
|
+
const checksPath = lessonPath.replace(/\.mdx?$/, '.checks.yml');
|
|
274
|
+
return existsSync(checksPath)
|
|
275
|
+
? parseCheckList(readFileSync(checksPath, 'utf8'))
|
|
276
|
+
: [];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function lessonEntries(
|
|
280
|
+
prerequisitesById: ReadonlyMap<string, readonly string[]>,
|
|
281
|
+
): LessonDefinition[] {
|
|
282
|
+
const lessons: LessonDefinition[] = [];
|
|
283
|
+
for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
|
|
284
|
+
const slug = docSlug(path);
|
|
285
|
+
if (!isLessonSlug(slug)) continue;
|
|
286
|
+
const { data, body } = parseDocument(path);
|
|
287
|
+
const teaches = (data.teaches as string[] | undefined) ?? [];
|
|
288
|
+
lessons.push({
|
|
289
|
+
id: lessonIdFromSlug(slug),
|
|
290
|
+
status: (data.editorialStatus as LessonDefinition['status']) ?? 'draft',
|
|
291
|
+
requires: deriveRequires(teaches, prerequisitesById),
|
|
292
|
+
teaches,
|
|
293
|
+
assessments: lessonCheckIds(path),
|
|
294
|
+
sources: deriveSources(body),
|
|
295
|
+
assumptions: (data.assumptions as string[] | undefined) ?? [],
|
|
296
|
+
body,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return lessons;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function loadCurriculumCatalog(): Promise<CurriculumCatalog> {
|
|
303
|
+
const competencies = jsonEntries<CompetencyDefinition>(
|
|
304
|
+
join(CONTENT_ROOT, 'competencies'),
|
|
305
|
+
);
|
|
306
|
+
const prerequisitesById = new Map(
|
|
307
|
+
competencies.map((competency) => [
|
|
308
|
+
competency.id,
|
|
309
|
+
competency.prerequisites ?? [],
|
|
310
|
+
]),
|
|
311
|
+
);
|
|
312
|
+
return {
|
|
313
|
+
competencies,
|
|
314
|
+
lessons: lessonEntries(prerequisitesById),
|
|
315
|
+
assessments: jsonEntries<AssessmentDefinition>(
|
|
316
|
+
join(CONTENT_ROOT, 'assessments'),
|
|
317
|
+
),
|
|
318
|
+
sources: jsonEntries<SourceDefinition>(join(CONTENT_ROOT, 'sources')),
|
|
319
|
+
tracks: jsonEntries<TrackDefinition>(join(CONTENT_ROOT, 'tracks')),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- notation registry input -------------------------------------------
|
|
324
|
+
|
|
325
|
+
function localDefinition(
|
|
326
|
+
raw: unknown,
|
|
327
|
+
file: string,
|
|
328
|
+
index: number,
|
|
329
|
+
): LocalNotationDefinitionInput {
|
|
330
|
+
const at = `${file} notation.local[${index}]`;
|
|
331
|
+
const data = record(raw, at);
|
|
332
|
+
const key = string(data.key, `${at}.key`);
|
|
333
|
+
const latex = string(data.latex, `${at}.latex`);
|
|
334
|
+
const meaning = meaningText(data.meaning, `${at}.meaning`);
|
|
335
|
+
const formula = optionalString(data.formula, `${at}.formula`);
|
|
336
|
+
const units = optionalString(data.units, `${at}.units`);
|
|
337
|
+
const definitionText = [meaning, formula]
|
|
338
|
+
.filter((value): value is string => value !== undefined)
|
|
339
|
+
.join('\n');
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
key,
|
|
343
|
+
notation: latex,
|
|
344
|
+
label: resolveLabel(key, optionalString(data.label, `${at}.label`)),
|
|
345
|
+
summary: meaning,
|
|
346
|
+
sources: sourceIds(data.sources, `${at}.sources`),
|
|
347
|
+
seeAlso: strings(data.seeAlso, `${at}.seeAlso`),
|
|
348
|
+
alignment:
|
|
349
|
+
data.alignment === undefined
|
|
350
|
+
? { kind: 'general', rationale: 'Lesson-local symbol.' }
|
|
351
|
+
: alignment(data.alignment, `${at}.alignment`),
|
|
352
|
+
references: extractNotationReferences(definitionText, file, 'definition'),
|
|
353
|
+
source: { file },
|
|
354
|
+
...(formula === undefined ? {} : { formula }),
|
|
355
|
+
...(units === undefined ? {} : { units }),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function sharedNotationEntries(): SharedNotationDefinitionInput[] {
|
|
360
|
+
const directory = join(CONTENT_ROOT, 'notation');
|
|
361
|
+
return filesBelow(directory, ['.md']).map((path) => {
|
|
362
|
+
const { file, data, body } = parseDocument(path);
|
|
363
|
+
const key = string(data.key, `${file} key`);
|
|
364
|
+
const filenameKey = basename(path, '.md');
|
|
365
|
+
if (key !== filenameKey) {
|
|
366
|
+
throw new Error(`${file} declares key ${key}; expected ${filenameKey}`);
|
|
367
|
+
}
|
|
368
|
+
const units = optionalString(data.units, `${file} units`);
|
|
369
|
+
const formula = optionalString(data.formula, `${file} formula`);
|
|
370
|
+
const meaning = meaningText(data.meaning, `${file} meaning`);
|
|
371
|
+
return {
|
|
372
|
+
key,
|
|
373
|
+
notation: string(data.latex, `${file} latex`),
|
|
374
|
+
label: resolveLabel(key, optionalString(data.label, `${file} label`)),
|
|
375
|
+
summary: meaning,
|
|
376
|
+
aliases: strings(data.aliases, `${file} aliases`),
|
|
377
|
+
domain: string(data.domain, `${file} domain`),
|
|
378
|
+
sources: sourceIds(data.sources, `${file} sources`),
|
|
379
|
+
seeAlso: strings(data.seeAlso, `${file} seeAlso`),
|
|
380
|
+
alignment: alignment(data.alignment, `${file} alignment`),
|
|
381
|
+
status: status(data.editorialStatus, `${file} editorialStatus`),
|
|
382
|
+
aiAssisted: boolean(data.aiAssisted, `${file} aiAssisted`),
|
|
383
|
+
body,
|
|
384
|
+
references: extractNotationReferences(body, file, undefined),
|
|
385
|
+
source: { file },
|
|
386
|
+
...(formula === undefined ? {} : { formula }),
|
|
387
|
+
...(units === undefined ? {} : { units }),
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function notationLessonEntries(): NotationLessonInput[] {
|
|
393
|
+
const lessons: NotationLessonInput[] = [];
|
|
394
|
+
for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
|
|
395
|
+
const slug = docSlug(path);
|
|
396
|
+
if (!isLessonSlug(slug)) continue;
|
|
397
|
+
const { file, data, body } = parseDocument(path);
|
|
398
|
+
const notation = record(data.notation ?? {}, `${file} notation`);
|
|
399
|
+
const local = Array.isArray(notation.local) ? notation.local : [];
|
|
400
|
+
lessons.push({
|
|
401
|
+
lessonId: lessonIdFromSlug(slug),
|
|
402
|
+
status: status(data.editorialStatus, `${file} editorialStatus`),
|
|
403
|
+
localDefinitions: local.map((entry, index) =>
|
|
404
|
+
localDefinition(entry, file, index),
|
|
405
|
+
),
|
|
406
|
+
references: extractNotationReferences(body, file, undefined),
|
|
407
|
+
body,
|
|
408
|
+
source: { file },
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return lessons;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function loadNotationRegistryInput(): Promise<NotationRegistryInput> {
|
|
415
|
+
return {
|
|
416
|
+
sharedDefinitions: sharedNotationEntries(),
|
|
417
|
+
lessons: notationLessonEntries(),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// --- raw collection records for the Astro markdown pipeline ---------------
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Notation frontmatter objects for `remark-notation`'s `definitions` option.
|
|
425
|
+
* Returned synchronously because the remark transform reads the loader inline.
|
|
426
|
+
*/
|
|
427
|
+
export function loadNotationDefinitions(): Record<string, unknown>[] {
|
|
428
|
+
return filesBelow(join(CONTENT_ROOT, 'notation'), ['.md']).map(
|
|
429
|
+
(path) => parseDocument(path).data,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Parsed `src/content/sources/*.json` records for `remark-citation`. */
|
|
434
|
+
export function loadSourceRecords(): Record<string, unknown>[] {
|
|
435
|
+
return jsonEntries<SourceDefinition & { readonly id: string }>(
|
|
436
|
+
join(CONTENT_ROOT, 'sources'),
|
|
437
|
+
) as unknown as Record<string, unknown>[];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Assessment ids for one lesson, from its colocated `<name>.checks.yml`. The
|
|
442
|
+
* lesson layout uses this to auto-place `<AssessmentSet>`; nothing is authored
|
|
443
|
+
* in the MDX body.
|
|
444
|
+
*/
|
|
445
|
+
export function loadLessonChecks(lessonId: string): string[] {
|
|
446
|
+
const slug = lessonId.replace(/\./g, '/');
|
|
447
|
+
for (const extension of ['.mdx', '.md']) {
|
|
448
|
+
const lessonPath = join(CONTENT_ROOT, 'docs', `${slug}${extension}`);
|
|
449
|
+
if (existsSync(lessonPath)) return lessonCheckIds(lessonPath);
|
|
450
|
+
}
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Flat view of every notation entry (shared + page-local) for the consistency
|
|
456
|
+
* checks (Step D6): the fields those checks read that the registry input drops
|
|
457
|
+
* or normalizes — the raw `dimensionless` marker and the per-source locator
|
|
458
|
+
* shape. One walk of the same files the loader already parses.
|
|
459
|
+
*/
|
|
460
|
+
export interface NotationSpec {
|
|
461
|
+
/** `shared:<key>` or `page:<lessonId>:<key>` — matches the registry id. */
|
|
462
|
+
readonly id: string;
|
|
463
|
+
readonly key: string;
|
|
464
|
+
readonly scope: 'shared' | 'local';
|
|
465
|
+
readonly lessonId?: string;
|
|
466
|
+
readonly file: string;
|
|
467
|
+
readonly latex: string;
|
|
468
|
+
readonly units?: string;
|
|
469
|
+
readonly dimensionless: boolean;
|
|
470
|
+
readonly formula?: string;
|
|
471
|
+
readonly sources: readonly {
|
|
472
|
+
readonly id: string;
|
|
473
|
+
readonly hasLocator: boolean;
|
|
474
|
+
}[];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function specSources(value: unknown): NotationSpec['sources'] {
|
|
478
|
+
if (!Array.isArray(value)) return [];
|
|
479
|
+
return value.map((entry) => {
|
|
480
|
+
if (typeof entry === 'string') return { id: entry, hasLocator: false };
|
|
481
|
+
const record = (entry ?? {}) as { id?: unknown; locator?: unknown };
|
|
482
|
+
return {
|
|
483
|
+
id: typeof record.id === 'string' ? record.id : '',
|
|
484
|
+
hasLocator:
|
|
485
|
+
typeof record.locator === 'string' && record.locator.trim().length > 0,
|
|
486
|
+
};
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function loadNotationSpecs(): NotationSpec[] {
|
|
491
|
+
const specs: NotationSpec[] = [];
|
|
492
|
+
|
|
493
|
+
for (const path of filesBelow(join(CONTENT_ROOT, 'notation'), ['.md'])) {
|
|
494
|
+
const { file, data } = parseDocument(path);
|
|
495
|
+
const key = typeof data.key === 'string' ? data.key : basename(path, '.md');
|
|
496
|
+
specs.push({
|
|
497
|
+
id: `shared:${key}`,
|
|
498
|
+
key,
|
|
499
|
+
scope: 'shared',
|
|
500
|
+
file,
|
|
501
|
+
latex: typeof data.latex === 'string' ? data.latex : '',
|
|
502
|
+
...(typeof data.units === 'string' ? { units: data.units } : {}),
|
|
503
|
+
dimensionless: data.dimensionless === true,
|
|
504
|
+
...(typeof data.formula === 'string' ? { formula: data.formula } : {}),
|
|
505
|
+
sources: specSources(data.sources),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
|
|
510
|
+
const slug = docSlug(path);
|
|
511
|
+
if (!isLessonSlug(slug)) continue;
|
|
512
|
+
const { file, data } = parseDocument(path);
|
|
513
|
+
const lessonId = lessonIdFromSlug(slug);
|
|
514
|
+
const local = (data as { notation?: { local?: unknown } }).notation?.local;
|
|
515
|
+
if (!Array.isArray(local)) continue;
|
|
516
|
+
for (const raw of local) {
|
|
517
|
+
const entry = (raw ?? {}) as Record<string, unknown>;
|
|
518
|
+
const key = typeof entry.key === 'string' ? entry.key : '';
|
|
519
|
+
specs.push({
|
|
520
|
+
id: `page:${lessonId}:${key}`,
|
|
521
|
+
key,
|
|
522
|
+
scope: 'local',
|
|
523
|
+
lessonId,
|
|
524
|
+
file,
|
|
525
|
+
latex: typeof entry.latex === 'string' ? entry.latex : '',
|
|
526
|
+
...(typeof entry.units === 'string' ? { units: entry.units } : {}),
|
|
527
|
+
dimensionless: entry.dimensionless === true,
|
|
528
|
+
...(typeof entry.formula === 'string'
|
|
529
|
+
? { formula: entry.formula }
|
|
530
|
+
: {}),
|
|
531
|
+
sources: specSources(entry.sources),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return specs.sort((left, right) => left.id.localeCompare(right.id));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Notation `sources` entries still written as a bare id rather than
|
|
541
|
+
* `{ id, locator }` (Phase C1b shape). Formalized as the `notation-source-locator`
|
|
542
|
+
* consistency check in Step D6; kept here for the CLI summary line.
|
|
543
|
+
*/
|
|
544
|
+
export function notationSourceLocatorGaps(): { bare: number; total: number } {
|
|
545
|
+
let bare = 0;
|
|
546
|
+
let total = 0;
|
|
547
|
+
for (const spec of loadNotationSpecs()) {
|
|
548
|
+
for (const source of spec.sources) {
|
|
549
|
+
total += 1;
|
|
550
|
+
if (!source.hasLocator) bare += 1;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return { bare, total };
|
|
554
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape `content/course.config.ts` must satisfy (Phase F).
|
|
3
|
+
*
|
|
4
|
+
* The engine never imports the course's config file; the wiring layer
|
|
5
|
+
* (`astro.config.mjs`, `scripts/*`, `src/content.config.ts`) imports the plain
|
|
6
|
+
* data value and passes it here, typed against `CourseConfig`. A second course
|
|
7
|
+
* supplies its own value of this shape.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface CourseSection {
|
|
11
|
+
/** Lesson-slug prefix, e.g. `bonds` for `bonds/yield-to-maturity`. */
|
|
12
|
+
readonly dir: string;
|
|
13
|
+
/** Sidebar group heading. */
|
|
14
|
+
readonly label: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CourseConventions {
|
|
18
|
+
/** Notation key a lesson's stated sign convention must resolve to. */
|
|
19
|
+
readonly signConventionKey: string;
|
|
20
|
+
/** Competency id that also counts as declaring the cash-flow perspective. */
|
|
21
|
+
readonly cashFlowPerspectiveCompetency: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CourseConfig {
|
|
25
|
+
readonly title: string;
|
|
26
|
+
readonly description: string;
|
|
27
|
+
/** Sidebar section groups, in order. */
|
|
28
|
+
readonly sections: readonly CourseSection[];
|
|
29
|
+
/** Namespaces used by competency / notation `domain` fields. */
|
|
30
|
+
readonly domains: readonly string[];
|
|
31
|
+
readonly conventions: CourseConventions;
|
|
32
|
+
}
|