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,552 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
createMarkdownProcessor,
|
|
7
|
+
parseFrontmatter
|
|
8
|
+
} from "@astrojs/markdown-remark";
|
|
9
|
+
import rehypeKatex from "rehype-katex";
|
|
10
|
+
import remarkMath from "remark-math";
|
|
11
|
+
import { createNotationKatexOptions } from "../reference/katex-options.mjs";
|
|
12
|
+
import rehypeFailKatexErrors from "../reference/rehype-fail-katex-errors.mjs";
|
|
13
|
+
import remarkCitation from "../reference/remark-citation.mjs";
|
|
14
|
+
import remarkNotation from "../reference/remark-notation.mjs";
|
|
15
|
+
import { isLessonSlug, lessonIdFromSlug } from "./lesson-derivation";
|
|
16
|
+
const REPOSITORY_ROOT = process.cwd();
|
|
17
|
+
const CONTENT_ROOT = join(REPOSITORY_ROOT, "content");
|
|
18
|
+
const DOCS_ROOT = join(CONTENT_ROOT, "docs");
|
|
19
|
+
const DOMAIN_ROOT = join(CONTENT_ROOT, "domain");
|
|
20
|
+
class ContentCliError extends Error {
|
|
21
|
+
code;
|
|
22
|
+
constructor(code, message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "ContentCliError";
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function mentions(message, lessonId, file) {
|
|
29
|
+
return message.includes(lessonId) || message.includes(file) || message.includes(file.replace(/^content\/docs\//, ""));
|
|
30
|
+
}
|
|
31
|
+
function lessonDiagnostics(manifest, lesson) {
|
|
32
|
+
const out = [];
|
|
33
|
+
const { file, id } = lesson;
|
|
34
|
+
const assessments = new Set(lesson.assessments);
|
|
35
|
+
const withFile = (value) => value === void 0 ? {} : { file: value };
|
|
36
|
+
for (const issue of manifest.diagnostics.curriculum) {
|
|
37
|
+
if (!mentions(issue.message, id, file)) continue;
|
|
38
|
+
out.push({
|
|
39
|
+
code: `curriculum:${issue.kind}`,
|
|
40
|
+
severity: issue.severity === "warning" ? "warning" : "error",
|
|
41
|
+
message: issue.message
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
for (const diagnostic of [
|
|
45
|
+
...manifest.diagnostics.notation.map((d) => ["notation", d]),
|
|
46
|
+
...manifest.diagnostics.alignment.map((d) => ["alignment", d])
|
|
47
|
+
]) {
|
|
48
|
+
const [group, entry] = diagnostic;
|
|
49
|
+
const entryFile = entry.source?.file;
|
|
50
|
+
if (entry.lessonId !== id && entryFile !== file && !mentions(entry.message, id, file)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
out.push({
|
|
54
|
+
code: `${group}:${entry.code}`,
|
|
55
|
+
severity: entry.severity === "warning" ? "warning" : "error",
|
|
56
|
+
message: entry.message,
|
|
57
|
+
...withFile(entryFile)
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
for (const diagnostic of manifest.diagnostics.math) {
|
|
61
|
+
const forLesson = diagnostic.file === file || diagnostic.context === "assessment" && [...assessments].some(
|
|
62
|
+
(assessmentId) => diagnostic.file.startsWith(`${assessmentId} `)
|
|
63
|
+
);
|
|
64
|
+
if (!forLesson) continue;
|
|
65
|
+
out.push({
|
|
66
|
+
code: `math:${diagnostic.context}`,
|
|
67
|
+
severity: diagnostic.severity,
|
|
68
|
+
message: diagnostic.message,
|
|
69
|
+
file: diagnostic.file,
|
|
70
|
+
...diagnostic.line === void 0 ? {} : { line: diagnostic.line }
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
for (const diagnostic of manifest.diagnostics.equations) {
|
|
74
|
+
if (diagnostic.lessonId !== id && diagnostic.file !== file && !mentions(diagnostic.message, id, file)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
out.push({
|
|
78
|
+
code: `equations:${diagnostic.code}`,
|
|
79
|
+
severity: diagnostic.severity === "warning" ? "warning" : "error",
|
|
80
|
+
message: diagnostic.message,
|
|
81
|
+
...withFile(diagnostic.file)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
for (const diagnostic of manifest.diagnostics.consistency) {
|
|
85
|
+
if (diagnostic.file !== file && diagnostic.lessonId !== id) continue;
|
|
86
|
+
out.push({
|
|
87
|
+
code: `consistency:${diagnostic.code}`,
|
|
88
|
+
severity: "warning",
|
|
89
|
+
message: diagnostic.message,
|
|
90
|
+
...withFile(diagnostic.file)
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return out.sort(
|
|
94
|
+
(a, b) => a.code.localeCompare(b.code) || a.message.localeCompare(b.message)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
let processorPromise;
|
|
98
|
+
function processorFor(manifest) {
|
|
99
|
+
processorPromise ??= createMarkdownProcessor({
|
|
100
|
+
syntaxHighlight: false,
|
|
101
|
+
smartypants: false,
|
|
102
|
+
remarkPlugins: [
|
|
103
|
+
remarkMath,
|
|
104
|
+
[
|
|
105
|
+
remarkNotation,
|
|
106
|
+
{
|
|
107
|
+
definitions: manifest.notation.raw,
|
|
108
|
+
equations: manifest.equations.numbersBySlug
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
[remarkCitation, { sources: manifest.sources }]
|
|
112
|
+
],
|
|
113
|
+
rehypePlugins: [
|
|
114
|
+
[rehypeKatex, createNotationKatexOptions()],
|
|
115
|
+
rehypeFailKatexErrors
|
|
116
|
+
]
|
|
117
|
+
});
|
|
118
|
+
return processorPromise;
|
|
119
|
+
}
|
|
120
|
+
function stripEsm(body) {
|
|
121
|
+
return body.replace(/^(?:import|export)\s[^\n]*\n?/gm, "");
|
|
122
|
+
}
|
|
123
|
+
function renderErrorCode(message) {
|
|
124
|
+
if (/Unresolved notation/i.test(message)) return "render:notation";
|
|
125
|
+
if (/KaTeX|ParseError|katex-error/i.test(message)) return "render:katex";
|
|
126
|
+
if (/Unknown notation key|Malformed notation|Invalid notation key/i.test(
|
|
127
|
+
message
|
|
128
|
+
)) {
|
|
129
|
+
return "render:notation";
|
|
130
|
+
}
|
|
131
|
+
if (/equation reference|display equation|equation label eq:/i.test(message)) {
|
|
132
|
+
return "render:equation";
|
|
133
|
+
}
|
|
134
|
+
if (/Unknown (?:source|citation)|Malformed citation/i.test(message)) {
|
|
135
|
+
return "render:citation";
|
|
136
|
+
}
|
|
137
|
+
return "render:mdx";
|
|
138
|
+
}
|
|
139
|
+
async function renderLesson(manifest, input) {
|
|
140
|
+
const processor = await processorFor(manifest);
|
|
141
|
+
const lessonId = lessonIdFromSlug(
|
|
142
|
+
relative(DOCS_ROOT, input.path).replace(/\\/g, "/")
|
|
143
|
+
);
|
|
144
|
+
try {
|
|
145
|
+
await processor.render(stripEsm(input.body), {
|
|
146
|
+
frontmatter: { ...input.frontmatter, lessonId },
|
|
147
|
+
fileURL: pathToFileURL(input.path)
|
|
148
|
+
});
|
|
149
|
+
return { rendered: true, diagnostics: [] };
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
152
|
+
return {
|
|
153
|
+
rendered: false,
|
|
154
|
+
diagnostics: [
|
|
155
|
+
{ code: renderErrorCode(message), severity: "error", message }
|
|
156
|
+
]
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function estimateTokens(text) {
|
|
161
|
+
return Math.ceil(text.length / 4);
|
|
162
|
+
}
|
|
163
|
+
function readIfExists(path) {
|
|
164
|
+
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
165
|
+
}
|
|
166
|
+
function walkFiles(directory) {
|
|
167
|
+
if (!existsSync(directory)) return [];
|
|
168
|
+
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
169
|
+
const path = join(directory, entry.name);
|
|
170
|
+
if (entry.isDirectory()) return walkFiles(path);
|
|
171
|
+
return entry.isFile() ? [path] : [];
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function docSetTokens() {
|
|
175
|
+
return ["README.md", "AGENTS.md", join("docs", "architecture.md")].map(
|
|
176
|
+
(relPath) => estimateTokens(readIfExists(join(REPOSITORY_ROOT, relPath)))
|
|
177
|
+
).reduce((sum, value) => sum + value, 0);
|
|
178
|
+
}
|
|
179
|
+
function corpusTokens() {
|
|
180
|
+
return walkFiles(CONTENT_ROOT).map((path) => estimateTokens(readFileSync(path, "utf8"))).reduce((sum, value) => sum + value, 0);
|
|
181
|
+
}
|
|
182
|
+
function tokenCounts(contextPayload) {
|
|
183
|
+
const context = estimateTokens(JSON.stringify(contextPayload));
|
|
184
|
+
const docSet = docSetTokens();
|
|
185
|
+
const corpus = corpusTokens();
|
|
186
|
+
const baseline = docSet + corpus;
|
|
187
|
+
return {
|
|
188
|
+
context,
|
|
189
|
+
docSet,
|
|
190
|
+
corpus,
|
|
191
|
+
ratio: baseline === 0 ? 0 : Math.round(context / baseline * 1e4) / 1e4
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function normalizeLessonId(raw) {
|
|
195
|
+
const trimmed = raw.trim().replace(/\.mdx?$/, "");
|
|
196
|
+
if (trimmed.includes("/")) return trimmed.replace(/\//g, ".");
|
|
197
|
+
return trimmed;
|
|
198
|
+
}
|
|
199
|
+
function findLesson(manifest, rawId) {
|
|
200
|
+
const id = normalizeLessonId(rawId);
|
|
201
|
+
const lesson = manifest.lessons.find((entry) => entry.id === id);
|
|
202
|
+
if (!lesson) {
|
|
203
|
+
throw new ContentCliError(
|
|
204
|
+
"unknown-lesson",
|
|
205
|
+
`No lesson ${JSON.stringify(id)}. Known ids: ${manifest.lessons.map((entry) => entry.id).join(", ")}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return lesson;
|
|
209
|
+
}
|
|
210
|
+
function lessonAbsolutePath(lesson) {
|
|
211
|
+
return join(REPOSITORY_ROOT, lesson.file);
|
|
212
|
+
}
|
|
213
|
+
function lessonRaw(lesson) {
|
|
214
|
+
const parsed = parseFrontmatter(
|
|
215
|
+
readFileSync(lessonAbsolutePath(lesson), "utf8")
|
|
216
|
+
);
|
|
217
|
+
return {
|
|
218
|
+
frontmatter: parsed.frontmatter ?? {},
|
|
219
|
+
body: parsed.content.replace(/^\n/, "")
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const DOMAIN_DIRS_BY_SECTION = {
|
|
223
|
+
foundations: [".", "rates", "valuation"],
|
|
224
|
+
bonds: ["bonds", "rates"],
|
|
225
|
+
rates: ["rates"],
|
|
226
|
+
derivatives: ["derivatives", "valuation"],
|
|
227
|
+
"bond-options": ["bonds", "derivatives", "valuation"],
|
|
228
|
+
credit: ["credit"],
|
|
229
|
+
cds: ["cds", "credit"]
|
|
230
|
+
};
|
|
231
|
+
const EXPORT_FN = /export\s+(?:async\s+)?function\s+([A-Za-z0-9_]+)\s*(\([\s\S]*?\))\s*(?::\s*([^\n{]+?))?\s*\{/g;
|
|
232
|
+
const EXPORT_ARROW = /export\s+const\s+([A-Za-z0-9_]+)\s*(?::\s*[^=\n]+)?=\s*(?:async\s*)?(\([\s\S]*?\))\s*(?::\s*([^=\n]+?))?\s*=>/g;
|
|
233
|
+
function signaturesInFile(path) {
|
|
234
|
+
const source = readFileSync(path, "utf8");
|
|
235
|
+
const out = [];
|
|
236
|
+
for (const pattern of [EXPORT_FN, EXPORT_ARROW]) {
|
|
237
|
+
pattern.lastIndex = 0;
|
|
238
|
+
let match;
|
|
239
|
+
while (match = pattern.exec(source)) {
|
|
240
|
+
const params = (match[2] ?? "").replace(/\s+/g, " ").trim();
|
|
241
|
+
const ret = (match[3] ?? "").replace(/\s+/g, " ").trim();
|
|
242
|
+
out.push(`${match[1]}${params}${ret ? `: ${ret}` : ""}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return out.sort();
|
|
246
|
+
}
|
|
247
|
+
function domainSignaturesForSection(section) {
|
|
248
|
+
const dirs = DOMAIN_DIRS_BY_SECTION[section] ?? [section];
|
|
249
|
+
const files = /* @__PURE__ */ new Set();
|
|
250
|
+
for (const dir of dirs) {
|
|
251
|
+
const base = dir === "." ? DOMAIN_ROOT : join(DOMAIN_ROOT, dir);
|
|
252
|
+
if (dir === ".") {
|
|
253
|
+
for (const entry of existsSync(base) ? readdirSync(base) : []) {
|
|
254
|
+
if (entry.endsWith(".ts")) files.add(join(base, entry));
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
for (const path of walkFiles(base)) {
|
|
258
|
+
if (path.endsWith(".ts")) files.add(path);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return [...files].sort().map((path) => ({
|
|
263
|
+
file: relative(REPOSITORY_ROOT, path).replace(/\\/g, "/"),
|
|
264
|
+
signatures: signaturesInFile(path)
|
|
265
|
+
})).filter((entry) => entry.signatures.length > 0);
|
|
266
|
+
}
|
|
267
|
+
function contentStatus(manifest) {
|
|
268
|
+
const draftLessons = manifest.lessons.filter((lesson) => lesson.status === "draft").map((lesson) => lesson.id).sort();
|
|
269
|
+
const draftNotation = manifest.notation.definitions.filter((definition) => definition.status === "draft").map((definition) => definition.id).sort();
|
|
270
|
+
const orphans = manifest.notation.diagnostics.filter((diagnostic) => diagnostic.code === "unused-definition").map((diagnostic) => ({
|
|
271
|
+
code: `notation:${diagnostic.code}`,
|
|
272
|
+
severity: diagnostic.severity === "warning" ? "warning" : "error",
|
|
273
|
+
message: diagnostic.message,
|
|
274
|
+
...diagnostic.source?.file === void 0 ? {} : { file: diagnostic.source.file }
|
|
275
|
+
})).sort((a, b) => a.message.localeCompare(b.message));
|
|
276
|
+
const allDiagnostics = [
|
|
277
|
+
...manifest.diagnostics.curriculum.map((d) => d.severity ?? "error"),
|
|
278
|
+
...manifest.diagnostics.notation.map((d) => d.severity),
|
|
279
|
+
...manifest.diagnostics.alignment.map((d) => d.severity),
|
|
280
|
+
...manifest.diagnostics.math.map((d) => d.severity),
|
|
281
|
+
...manifest.diagnostics.equations.map((d) => d.severity),
|
|
282
|
+
...manifest.diagnostics.consistency.map(() => "warning")
|
|
283
|
+
];
|
|
284
|
+
const keyedEquations = manifest.lessons.reduce(
|
|
285
|
+
(count, lesson) => count + lesson.equations.length,
|
|
286
|
+
0
|
|
287
|
+
);
|
|
288
|
+
const assessments = new Set(
|
|
289
|
+
manifest.lessons.flatMap((lesson) => lesson.assessments)
|
|
290
|
+
).size;
|
|
291
|
+
return {
|
|
292
|
+
counts: {
|
|
293
|
+
lessons: manifest.lessons.length,
|
|
294
|
+
competencies: manifest.competencies.length,
|
|
295
|
+
assessments,
|
|
296
|
+
sources: manifest.sources.length,
|
|
297
|
+
tracks: manifest.tracks.length,
|
|
298
|
+
notationDefinitions: manifest.notation.definitions.length,
|
|
299
|
+
keyedEquations
|
|
300
|
+
},
|
|
301
|
+
draftDebt: { lessons: draftLessons, notation: draftNotation },
|
|
302
|
+
orphans,
|
|
303
|
+
diagnostics: {
|
|
304
|
+
errors: allDiagnostics.filter((severity) => severity === "error").length,
|
|
305
|
+
warnings: allDiagnostics.filter((severity) => severity === "warning").length
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function contentContext(manifest, rawId) {
|
|
310
|
+
const lesson = findLesson(manifest, rawId);
|
|
311
|
+
const { frontmatter, body } = lessonRaw(lesson);
|
|
312
|
+
const outcomeById = new Map(
|
|
313
|
+
manifest.competencies.map((competency) => [
|
|
314
|
+
String(competency.id),
|
|
315
|
+
String(competency.outcome ?? "")
|
|
316
|
+
])
|
|
317
|
+
);
|
|
318
|
+
const outcomesFor = (ids) => ids.filter((id) => outcomeById.has(id)).map((id) => ({ id, outcome: outcomeById.get(id) ?? "" }));
|
|
319
|
+
const sourceById = new Map(
|
|
320
|
+
manifest.sources.map((source) => [String(source.id), source])
|
|
321
|
+
);
|
|
322
|
+
const citedLocatorsById = /* @__PURE__ */ new Map();
|
|
323
|
+
for (const citation of lesson.citations) {
|
|
324
|
+
const list = citedLocatorsById.get(citation.id) ?? [];
|
|
325
|
+
if (citation.locator) list.push(citation.locator);
|
|
326
|
+
citedLocatorsById.set(citation.id, list);
|
|
327
|
+
}
|
|
328
|
+
const contextWithoutTokens = {
|
|
329
|
+
lesson: {
|
|
330
|
+
id: lesson.id,
|
|
331
|
+
slug: lesson.slug,
|
|
332
|
+
file: lesson.file,
|
|
333
|
+
title: lesson.title,
|
|
334
|
+
description: lesson.description,
|
|
335
|
+
status: lesson.status,
|
|
336
|
+
teaches: lesson.teaches,
|
|
337
|
+
requires: lesson.requires,
|
|
338
|
+
assumptions: lesson.assumptions,
|
|
339
|
+
notationLocal: frontmatter.notation?.local ?? [],
|
|
340
|
+
body
|
|
341
|
+
},
|
|
342
|
+
taughtOutcomes: outcomesFor(lesson.teaches),
|
|
343
|
+
prerequisiteOutcomes: outcomesFor(lesson.requires),
|
|
344
|
+
notation: lesson.notation.definitions.map((definition) => ({
|
|
345
|
+
key: definition.key,
|
|
346
|
+
latex: definition.notation,
|
|
347
|
+
meaning: definition.summary,
|
|
348
|
+
scope: definition.scope,
|
|
349
|
+
...definition.units === void 0 ? {} : { units: definition.units },
|
|
350
|
+
...definition.formula === void 0 ? {} : { formula: definition.formula }
|
|
351
|
+
})),
|
|
352
|
+
equations: lesson.equations.map((equation) => ({ ...equation })),
|
|
353
|
+
sources: lesson.sources.map((id) => {
|
|
354
|
+
const record = sourceById.get(id);
|
|
355
|
+
return {
|
|
356
|
+
id,
|
|
357
|
+
title: String(record?.title ?? id),
|
|
358
|
+
type: String(record?.type ?? "unknown"),
|
|
359
|
+
...typeof record?.locator === "string" ? { recordLocator: record.locator } : {},
|
|
360
|
+
citedLocators: citedLocatorsById.get(id) ?? []
|
|
361
|
+
};
|
|
362
|
+
}),
|
|
363
|
+
domainSignatures: domainSignaturesForSection(
|
|
364
|
+
lesson.slug.split("/")[0] ?? ""
|
|
365
|
+
),
|
|
366
|
+
diagnostics: lessonDiagnostics(manifest, lesson)
|
|
367
|
+
};
|
|
368
|
+
return {
|
|
369
|
+
...contextWithoutTokens,
|
|
370
|
+
tokens: tokenCounts(contextWithoutTokens)
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
async function checkOneLesson(manifest, lesson) {
|
|
374
|
+
const manifestDiagnostics = lessonDiagnostics(manifest, lesson);
|
|
375
|
+
const { frontmatter, body } = lessonRaw(lesson);
|
|
376
|
+
const render = await renderLesson(manifest, {
|
|
377
|
+
body,
|
|
378
|
+
frontmatter,
|
|
379
|
+
path: lessonAbsolutePath(lesson)
|
|
380
|
+
});
|
|
381
|
+
const diagnostics = [...manifestDiagnostics, ...render.diagnostics].sort(
|
|
382
|
+
(a, b) => a.code.localeCompare(b.code) || a.message.localeCompare(b.message)
|
|
383
|
+
);
|
|
384
|
+
return {
|
|
385
|
+
lesson: lesson.id,
|
|
386
|
+
file: lesson.file,
|
|
387
|
+
ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
|
|
388
|
+
rendered: render.rendered,
|
|
389
|
+
diagnostics
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function changedLessonIds() {
|
|
393
|
+
let output;
|
|
394
|
+
try {
|
|
395
|
+
const tracked = execFileSync(
|
|
396
|
+
"git",
|
|
397
|
+
["diff", "--name-only", "HEAD", "--", "content/docs"],
|
|
398
|
+
{ cwd: REPOSITORY_ROOT, encoding: "utf8" }
|
|
399
|
+
);
|
|
400
|
+
const untracked = execFileSync(
|
|
401
|
+
"git",
|
|
402
|
+
["ls-files", "--others", "--exclude-standard", "--", "content/docs"],
|
|
403
|
+
{ cwd: REPOSITORY_ROOT, encoding: "utf8" }
|
|
404
|
+
);
|
|
405
|
+
output = `${tracked}
|
|
406
|
+
${untracked}`;
|
|
407
|
+
} catch {
|
|
408
|
+
throw new ContentCliError(
|
|
409
|
+
"no-git",
|
|
410
|
+
"Could not read git status for --changed. Run inside a git working tree."
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
const ids = /* @__PURE__ */ new Set();
|
|
414
|
+
for (const line of output.split("\n")) {
|
|
415
|
+
const path = line.trim();
|
|
416
|
+
if (!path) continue;
|
|
417
|
+
const slug = path.replace(/^content\/docs\//, "").replace(/\.checks\.yml$/, ".mdx").replace(/\.mdx?$/, "");
|
|
418
|
+
if (!isLessonSlug(slug)) continue;
|
|
419
|
+
ids.add(slug.replace(/\//g, "."));
|
|
420
|
+
}
|
|
421
|
+
return [...ids].sort();
|
|
422
|
+
}
|
|
423
|
+
async function contentCheck(manifest, options) {
|
|
424
|
+
let targets;
|
|
425
|
+
if (options.changed) {
|
|
426
|
+
const ids = changedLessonIds();
|
|
427
|
+
targets = manifest.lessons.filter((lesson) => ids.includes(lesson.id));
|
|
428
|
+
} else {
|
|
429
|
+
if (!options.lesson) {
|
|
430
|
+
throw new ContentCliError(
|
|
431
|
+
"usage",
|
|
432
|
+
"content check needs a <lesson> id or --changed."
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
targets = [findLesson(manifest, options.lesson)];
|
|
436
|
+
}
|
|
437
|
+
const lessons = await Promise.all(
|
|
438
|
+
targets.map((lesson) => checkOneLesson(manifest, lesson))
|
|
439
|
+
);
|
|
440
|
+
return { ok: lessons.every((entry) => entry.ok), lessons };
|
|
441
|
+
}
|
|
442
|
+
const ID_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
443
|
+
function scaffoldLesson(rawId) {
|
|
444
|
+
const slug = rawId.trim().replace(/\.mdx?$/, "").replace(/\./g, "/");
|
|
445
|
+
if (!slug.includes("/")) {
|
|
446
|
+
throw new ContentCliError(
|
|
447
|
+
"invalid-id",
|
|
448
|
+
`A lesson id is <section>/<slug> (or <section>.<slug>), got ${JSON.stringify(rawId)}.`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (!isLessonSlug(slug) || slug.split("/").some((part) => !ID_PATTERN.test(part))) {
|
|
452
|
+
throw new ContentCliError(
|
|
453
|
+
"invalid-id",
|
|
454
|
+
`Lesson slug ${JSON.stringify(slug)} is not a valid <section>/<slug>.`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
const relPath = `content/docs/${slug}.mdx`;
|
|
458
|
+
const checksPath = `content/docs/${slug}.checks.yml`;
|
|
459
|
+
if (existsSync(join(REPOSITORY_ROOT, relPath))) {
|
|
460
|
+
throw new ContentCliError(
|
|
461
|
+
"exists",
|
|
462
|
+
`${relPath} already exists; not overwriting.`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
const title = (slug.split("/").at(-1) ?? slug).split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
466
|
+
const contents = `---
|
|
467
|
+
title: ${title}
|
|
468
|
+
description: One sentence on what the reader can do after this lesson.
|
|
469
|
+
editorialStatus: draft
|
|
470
|
+
teaches: []
|
|
471
|
+
assumptions: []
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
import CompactExample from '../../../components/examples/CompactExample.astro';
|
|
475
|
+
import AssessmentSet from '../../../components/assessments/AssessmentSet.astro';
|
|
476
|
+
|
|
477
|
+
## What you will be able to do
|
|
478
|
+
|
|
479
|
+
After this lesson, you should be able to:
|
|
480
|
+
|
|
481
|
+
- <!-- one observable outcome per teaches entry -->
|
|
482
|
+
|
|
483
|
+
## Intuition
|
|
484
|
+
|
|
485
|
+
<!-- draft prose; every symbol introduced with [[key]] before its first math use -->
|
|
486
|
+
|
|
487
|
+
## Model
|
|
488
|
+
|
|
489
|
+
<!-- the reproducible worked example; every numeral tagged to a content/domain/ call or [@source; locator] -->
|
|
490
|
+
`;
|
|
491
|
+
return {
|
|
492
|
+
path: relPath,
|
|
493
|
+
contents,
|
|
494
|
+
companions: [
|
|
495
|
+
{
|
|
496
|
+
path: checksPath,
|
|
497
|
+
contents: `# Assessment sets for this lesson. Ids resolve against content/assessments/.
|
|
498
|
+
[]
|
|
499
|
+
`
|
|
500
|
+
}
|
|
501
|
+
]
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
function scaffoldTerm(rawKey) {
|
|
505
|
+
const key = rawKey.trim();
|
|
506
|
+
if (!ID_PATTERN.test(key)) {
|
|
507
|
+
throw new ContentCliError(
|
|
508
|
+
"invalid-id",
|
|
509
|
+
`A notation key is lower-case, dot/hyphen separated, got ${JSON.stringify(rawKey)}.`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
const relPath = `content/notation/${key}.md`;
|
|
513
|
+
if (existsSync(join(REPOSITORY_ROOT, relPath))) {
|
|
514
|
+
throw new ContentCliError(
|
|
515
|
+
"exists",
|
|
516
|
+
`${relPath} already exists; not overwriting.`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const contents = `---
|
|
520
|
+
key: ${key}
|
|
521
|
+
latex: 'x'
|
|
522
|
+
meaning: Describe the quantity this symbol denotes.
|
|
523
|
+
domain: general
|
|
524
|
+
units: stated currency
|
|
525
|
+
sources: []
|
|
526
|
+
seeAlso: []
|
|
527
|
+
alignment:
|
|
528
|
+
kind: general
|
|
529
|
+
rationale: Draft scaffold \u2014 set a real alignment before review.
|
|
530
|
+
editorialStatus: draft
|
|
531
|
+
aiAssisted: true
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
Draft definition for [[${key}]]. Replace this body and the placeholder
|
|
535
|
+
frontmatter before moving the entry past \`draft\`.
|
|
536
|
+
`;
|
|
537
|
+
return { path: relPath, contents, companions: [] };
|
|
538
|
+
}
|
|
539
|
+
export {
|
|
540
|
+
ContentCliError,
|
|
541
|
+
changedLessonIds,
|
|
542
|
+
contentCheck,
|
|
543
|
+
contentContext,
|
|
544
|
+
contentStatus,
|
|
545
|
+
estimateTokens,
|
|
546
|
+
findLesson,
|
|
547
|
+
lessonDiagnostics,
|
|
548
|
+
normalizeLessonId,
|
|
549
|
+
renderLesson,
|
|
550
|
+
scaffoldLesson,
|
|
551
|
+
scaffoldTerm
|
|
552
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { CurriculumCatalog } from '../curriculum/validation.js';
|
|
2
|
+
import { NotationReferenceInput, NotationRegistryInput } from '../reference/types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single path from content files to typed data.
|
|
6
|
+
*
|
|
7
|
+
* Every consumer — the `validate:content` CLI, the Astro build (`astro.config.mjs`),
|
|
8
|
+
* the Starlight footer, the lab math scope, and the tests — reads content through
|
|
9
|
+
* this module. It walks `src/content/` once and parses frontmatter with Astro's
|
|
10
|
+
* own `parseFrontmatter` (the same parser the content collections use), so there
|
|
11
|
+
* is no second Markdown/YAML reader and `gray-matter` is no longer a dependency.
|
|
12
|
+
*
|
|
13
|
+
* Replaces `scripts/curriculum-files.ts`, `scripts/notation-files.ts`, and the
|
|
14
|
+
* inline `readdirSync`/`gray-matter` notation + source loads in `astro.config.mjs`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Notation references (`[[key]]` in prose, `\explain{key}` in math) in a body,
|
|
19
|
+
* ignoring code fences and inline code, sorted by position.
|
|
20
|
+
*/
|
|
21
|
+
declare function extractNotationReferences(text: string, file: string, kind: NotationReferenceInput['kind'] | undefined): NotationReferenceInput[];
|
|
22
|
+
declare function loadCurriculumCatalog(): Promise<CurriculumCatalog>;
|
|
23
|
+
declare function loadNotationRegistryInput(): Promise<NotationRegistryInput>;
|
|
24
|
+
/**
|
|
25
|
+
* Notation frontmatter objects for `remark-notation`'s `definitions` option.
|
|
26
|
+
* Returned synchronously because the remark transform reads the loader inline.
|
|
27
|
+
*/
|
|
28
|
+
declare function loadNotationDefinitions(): Record<string, unknown>[];
|
|
29
|
+
/** Parsed `src/content/sources/*.json` records for `remark-citation`. */
|
|
30
|
+
declare function loadSourceRecords(): Record<string, unknown>[];
|
|
31
|
+
/**
|
|
32
|
+
* Assessment ids for one lesson, from its colocated `<name>.checks.yml`. The
|
|
33
|
+
* lesson layout uses this to auto-place `<AssessmentSet>`; nothing is authored
|
|
34
|
+
* in the MDX body.
|
|
35
|
+
*/
|
|
36
|
+
declare function loadLessonChecks(lessonId: string): string[];
|
|
37
|
+
/**
|
|
38
|
+
* Flat view of every notation entry (shared + page-local) for the consistency
|
|
39
|
+
* checks (Step D6): the fields those checks read that the registry input drops
|
|
40
|
+
* or normalizes — the raw `dimensionless` marker and the per-source locator
|
|
41
|
+
* shape. One walk of the same files the loader already parses.
|
|
42
|
+
*/
|
|
43
|
+
interface NotationSpec {
|
|
44
|
+
/** `shared:<key>` or `page:<lessonId>:<key>` — matches the registry id. */
|
|
45
|
+
readonly id: string;
|
|
46
|
+
readonly key: string;
|
|
47
|
+
readonly scope: 'shared' | 'local';
|
|
48
|
+
readonly lessonId?: string;
|
|
49
|
+
readonly file: string;
|
|
50
|
+
readonly latex: string;
|
|
51
|
+
readonly units?: string;
|
|
52
|
+
readonly dimensionless: boolean;
|
|
53
|
+
readonly formula?: string;
|
|
54
|
+
readonly sources: readonly {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly hasLocator: boolean;
|
|
57
|
+
}[];
|
|
58
|
+
}
|
|
59
|
+
declare function loadNotationSpecs(): NotationSpec[];
|
|
60
|
+
/**
|
|
61
|
+
* Notation `sources` entries still written as a bare id rather than
|
|
62
|
+
* `{ id, locator }` (Phase C1b shape). Formalized as the `notation-source-locator`
|
|
63
|
+
* consistency check in Step D6; kept here for the CLI summary line.
|
|
64
|
+
*/
|
|
65
|
+
declare function notationSourceLocatorGaps(): {
|
|
66
|
+
bare: number;
|
|
67
|
+
total: number;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export { type NotationSpec, extractNotationReferences, loadCurriculumCatalog, loadLessonChecks, loadNotationDefinitions, loadNotationRegistryInput, loadNotationSpecs, loadSourceRecords, notationSourceLocatorGaps };
|