explico 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +108 -0
  2. package/dist/analytics/AnalyticsEmitter.d.ts +22 -0
  3. package/dist/analytics/AnalyticsEmitter.js +5 -0
  4. package/dist/compiler/cli.d.ts +189 -0
  5. package/dist/compiler/cli.js +552 -0
  6. package/dist/compiler/collections.d.ts +70 -0
  7. package/dist/compiler/collections.js +383 -0
  8. package/dist/compiler/course-config.d.ts +31 -0
  9. package/dist/compiler/course-config.js +0 -0
  10. package/dist/compiler/lesson-derivation.d.ts +89 -0
  11. package/dist/compiler/lesson-derivation.js +91 -0
  12. package/dist/compiler/manifest.d.ts +178 -0
  13. package/dist/compiler/manifest.js +345 -0
  14. package/dist/compiler/resolution-report.d.ts +45 -0
  15. package/dist/compiler/resolution-report.js +97 -0
  16. package/dist/curriculum/validation.d.ts +76 -0
  17. package/dist/curriculum/validation.js +320 -0
  18. package/dist/progress/ProgressRepository.d.ts +53 -0
  19. package/dist/progress/ProgressRepository.js +12 -0
  20. package/dist/reference/base-library.mjs +27 -0
  21. package/dist/reference/citation-format.mjs +133 -0
  22. package/dist/reference/consistency.d.ts +64 -0
  23. package/dist/reference/consistency.js +234 -0
  24. package/dist/reference/curriculum-alignment.d.ts +7 -0
  25. package/dist/reference/curriculum-alignment.js +166 -0
  26. package/dist/reference/equations-validate.d.ts +50 -0
  27. package/dist/reference/equations-validate.js +90 -0
  28. package/dist/reference/equations.mjs +144 -0
  29. package/dist/reference/gate-math.d.ts +41 -0
  30. package/dist/reference/gate-math.js +151 -0
  31. package/dist/reference/katex-options.mjs +91 -0
  32. package/dist/reference/lab-math-scope.d.ts +21 -0
  33. package/dist/reference/lab-math-scope.js +28 -0
  34. package/dist/reference/label.d.ts +11 -0
  35. package/dist/reference/label.js +38 -0
  36. package/dist/reference/lint-ignore.d.ts +32 -0
  37. package/dist/reference/lint-ignore.js +39 -0
  38. package/dist/reference/math-glyphs.mjs +830 -0
  39. package/dist/reference/prose.d.ts +15 -0
  40. package/dist/reference/prose.js +12 -0
  41. package/dist/reference/references.d.ts +17 -0
  42. package/dist/reference/references.js +42 -0
  43. package/dist/reference/registry.d.ts +6 -0
  44. package/dist/reference/registry.js +548 -0
  45. package/dist/reference/rehype-fail-katex-errors.mjs +55 -0
  46. package/dist/reference/remark-citation.mjs +0 -0
  47. package/dist/reference/remark-notation.mjs +753 -0
  48. package/dist/reference/render-lab-math.d.ts +21 -0
  49. package/dist/reference/render-lab-math.js +58 -0
  50. package/dist/reference/types.d.ts +151 -0
  51. package/dist/reference/types.js +0 -0
  52. package/dist/reference/units-vocab.d.ts +26 -0
  53. package/dist/reference/units-vocab.js +119 -0
  54. package/dist/session/PreferenceStore.d.ts +28 -0
  55. package/dist/session/PreferenceStore.js +30 -0
  56. package/dist/session/user-context.d.ts +7 -0
  57. package/dist/session/user-context.js +10 -0
  58. package/dist/session/user.d.ts +21 -0
  59. package/dist/session/user.js +8 -0
  60. package/package.json +53 -0
  61. package/src/analytics/AnalyticsEmitter.ts +23 -0
  62. package/src/compiler/cli.ts +866 -0
  63. package/src/compiler/collections.ts +554 -0
  64. package/src/compiler/course-config.ts +32 -0
  65. package/src/compiler/lesson-derivation.ts +186 -0
  66. package/src/compiler/manifest.ts +601 -0
  67. package/src/compiler/resolution-report.ts +150 -0
  68. package/src/components/CurriculumMap.astro +208 -0
  69. package/src/components/HoverPanel.astro +67 -0
  70. package/src/components/assessments/AssessmentRunner.tsx +320 -0
  71. package/src/components/assessments/AssessmentSet.astro +54 -0
  72. package/src/components/citation/CitationLayer.astro +173 -0
  73. package/src/components/examples/CompactExample.astro +34 -0
  74. package/src/components/examples/CompactExamples.astro +253 -0
  75. package/src/components/hover-panel.ts +273 -0
  76. package/src/components/notation/NotationGlossary.astro +412 -0
  77. package/src/components/notation/NotationLayer.astro +594 -0
  78. package/src/components/notation/NotationMath.astro +24 -0
  79. package/src/components/notation/SafeMathNode.astro +23 -0
  80. package/src/components/notation/render-notation-math.ts +223 -0
  81. package/src/components/starlight/EquationEnhancer.astro +72 -0
  82. package/src/components/starlight/LayoutHeader.astro +102 -0
  83. package/src/components/starlight/LayoutPageSidebar.astro +33 -0
  84. package/src/components/starlight/LayoutSidebar.astro +29 -0
  85. package/src/components/starlight/LessonFooter.astro +58 -0
  86. package/src/content-config.ts +259 -0
  87. package/src/curriculum/validation.ts +481 -0
  88. package/src/progress/ProgressRepository.ts +64 -0
  89. package/src/reference/base-library.mjs +27 -0
  90. package/src/reference/citation-format.mjs +133 -0
  91. package/src/reference/consistency.ts +375 -0
  92. package/src/reference/curriculum-alignment.ts +228 -0
  93. package/src/reference/equations-validate.ts +167 -0
  94. package/src/reference/equations.mjs +144 -0
  95. package/src/reference/gate-math.ts +250 -0
  96. package/src/reference/katex-options.mjs +91 -0
  97. package/src/reference/lab-math-scope.ts +54 -0
  98. package/src/reference/label.ts +47 -0
  99. package/src/reference/lint-ignore.ts +75 -0
  100. package/src/reference/math-glyphs.mjs +830 -0
  101. package/src/reference/prose.ts +21 -0
  102. package/src/reference/references.ts +82 -0
  103. package/src/reference/registry.ts +723 -0
  104. package/src/reference/rehype-fail-katex-errors.mjs +55 -0
  105. package/src/reference/remark-citation.mjs +0 -0
  106. package/src/reference/remark-notation.mjs +753 -0
  107. package/src/reference/render-lab-math.ts +99 -0
  108. package/src/reference/types.ts +182 -0
  109. package/src/reference/units-vocab.ts +143 -0
  110. package/src/session/PreferenceStore.ts +54 -0
  111. package/src/session/user-context.ts +18 -0
  112. package/src/session/user.ts +24 -0
  113. package/src/styles/global.css +763 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Equation-identity validation (Step D7), run inside `compileManifest()`.
3
+ *
4
+ * - `eq-duplicate-key` (Tier 1, error): an `eq:` key is unique per lesson.
5
+ * - `eq-ref-resolves` (Tier 1, error): every `[[eq-key]]` / `[[slug#eq-key]]`
6
+ * points at a display equation that carries `\label{eq:key}`.
7
+ * - `eq-key-unused` (Tier 3, warning): a labelled equation never referenced.
8
+ *
9
+ * The remark pass enforces the same rules on the render path; this gives the
10
+ * fast `pnpm validate:content` the same coverage and feeds the manifest.
11
+ */
12
+
13
+ import { parseEquationRef, scanEquationLabels } from './equations.mjs';
14
+
15
+ export interface EquationLabelRecord {
16
+ readonly key: string;
17
+ readonly number: string;
18
+ readonly lessonId: string;
19
+ readonly slug: string;
20
+ readonly section: number;
21
+ readonly indexInSection: number;
22
+ }
23
+
24
+ export interface EquationRefRecord {
25
+ readonly key: string;
26
+ readonly lessonId: string;
27
+ /** Target lesson slug for a cross-page reference; `undefined` = same page. */
28
+ readonly targetSlug?: string;
29
+ }
30
+
31
+ export type EquationDiagnosticCode =
32
+ 'eq-duplicate-key' | 'eq-ref-resolves' | 'eq-key-unused';
33
+
34
+ export interface EquationDiagnostic {
35
+ readonly code: EquationDiagnosticCode;
36
+ readonly severity: 'error' | 'warning';
37
+ readonly message: string;
38
+ readonly file?: string;
39
+ readonly lessonId?: string;
40
+ readonly key?: string;
41
+ }
42
+
43
+ export interface EquationValidation {
44
+ /** Every labelled equation, sorted by lesson then appearance. */
45
+ readonly labels: readonly EquationLabelRecord[];
46
+ /** `slug -> { eqKey: number }` — the render path's cross-page lookup table. */
47
+ readonly numbersBySlug: Record<string, Record<string, string>>;
48
+ readonly diagnostics: readonly EquationDiagnostic[];
49
+ }
50
+
51
+ export interface EquationLessonInput {
52
+ readonly lessonId: string;
53
+ readonly slug: string;
54
+ readonly file: string;
55
+ readonly body: string;
56
+ }
57
+
58
+ const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
59
+
60
+ function stripCode(text: string): string {
61
+ return text
62
+ .replace(/```[\s\S]*?```/g, ' ')
63
+ .replace(/~~~[\s\S]*?~~~/g, ' ')
64
+ .replace(/`[^`\n]*`/g, ' ');
65
+ }
66
+
67
+ function bodyEquationRefs(lesson: EquationLessonInput): EquationRefRecord[] {
68
+ const refs: EquationRefRecord[] = [];
69
+ for (const match of stripCode(lesson.body).matchAll(BRACKET_REF)) {
70
+ const parsed = parseEquationRef((match[1] ?? '').trim());
71
+ if (!parsed) continue;
72
+ refs.push({
73
+ key: parsed.key,
74
+ lessonId: lesson.lessonId,
75
+ ...(parsed.lesson ? { targetSlug: parsed.lesson } : {}),
76
+ });
77
+ }
78
+ return refs;
79
+ }
80
+
81
+ export function validateEquations(
82
+ lessons: readonly EquationLessonInput[],
83
+ ): EquationValidation {
84
+ const labels: EquationLabelRecord[] = [];
85
+ const diagnostics: EquationDiagnostic[] = [];
86
+ const numbersBySlug: Record<string, Record<string, string>> = {};
87
+
88
+ for (const lesson of [...lessons].sort((a, b) =>
89
+ a.lessonId.localeCompare(b.lessonId),
90
+ )) {
91
+ const seen = new Set<string>();
92
+ const table: Record<string, string> = {};
93
+ for (const raw of scanEquationLabels(lesson.body) as {
94
+ key: string;
95
+ number: string;
96
+ section: number;
97
+ indexInSection: number;
98
+ }[]) {
99
+ if (seen.has(raw.key)) {
100
+ diagnostics.push({
101
+ code: 'eq-duplicate-key',
102
+ severity: 'error',
103
+ message: `${lesson.lessonId} labels two display equations eq:${raw.key}; an eq: key is unique per lesson`,
104
+ file: lesson.file,
105
+ lessonId: lesson.lessonId,
106
+ key: raw.key,
107
+ });
108
+ continue;
109
+ }
110
+ seen.add(raw.key);
111
+ table[raw.key] = raw.number;
112
+ labels.push({
113
+ key: raw.key,
114
+ number: raw.number,
115
+ lessonId: lesson.lessonId,
116
+ slug: lesson.slug,
117
+ section: raw.section,
118
+ indexInSection: raw.indexInSection,
119
+ });
120
+ }
121
+ numbersBySlug[lesson.slug] = table;
122
+ }
123
+
124
+ const referenced = new Set<string>();
125
+ for (const lesson of lessons) {
126
+ const slug = lesson.slug;
127
+ for (const ref of bodyEquationRefs(lesson)) {
128
+ const targetSlug = ref.targetSlug ?? slug;
129
+ const number = numbersBySlug[targetSlug]?.[ref.key];
130
+ referenced.add(`${targetSlug}#${ref.key}`);
131
+ if (number === undefined) {
132
+ diagnostics.push({
133
+ code: 'eq-ref-resolves',
134
+ severity: 'error',
135
+ message: ref.targetSlug
136
+ ? `${lesson.lessonId} references [[${ref.targetSlug}#eq-${ref.key}]] but that lesson has no display equation labelled eq:${ref.key}`
137
+ : `${lesson.lessonId} references [[eq-${ref.key}]] but no display equation on the page carries \\label{eq:${ref.key}}`,
138
+ file: lesson.file,
139
+ lessonId: lesson.lessonId,
140
+ key: ref.key,
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ for (const label of labels) {
147
+ if (referenced.has(`${label.slug}#${label.key}`)) continue;
148
+ diagnostics.push({
149
+ code: 'eq-key-unused',
150
+ severity: 'warning',
151
+ message: `${label.lessonId} labels eq:${label.key} (${label.number}) but nothing references it with [[eq-${label.key}]]`,
152
+ file: label.slug,
153
+ lessonId: label.lessonId,
154
+ key: label.key,
155
+ });
156
+ }
157
+
158
+ diagnostics.sort(
159
+ (a, b) =>
160
+ a.code.localeCompare(b.code) ||
161
+ (a.lessonId ?? '').localeCompare(b.lessonId ?? '') ||
162
+ (a.key ?? '').localeCompare(b.key ?? '') ||
163
+ a.message.localeCompare(b.message),
164
+ );
165
+
166
+ return { labels, numbersBySlug, diagnostics };
167
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Equation identity (Step D7) — shared, framework-free helpers.
3
+ *
4
+ * An author marks a display equation by putting a trailing `\label{eq:<key>}`
5
+ * inside its `$$…$$` block (inside math, so the `:` is safe and the form
6
+ * matches amsmath). The compiler assigns the *visible* number from appearance
7
+ * order, scoped to the enclosing `##` section (`(2.4)` = fourth keyed equation
8
+ * under the second section). The number is never authored; unkeyed display
9
+ * equations render clean and stay un-referenceable. Prose refers to an
10
+ * equation with the reserved `eq-` prefix (a bare `:` in `[[…]]` prose would
11
+ * be eaten by `remark-directive`):
12
+ *
13
+ * [[eq-discount-factor-def]] same page → "(2.4)" linked to #eq-…
14
+ * [[foundations/present-value#eq-pv]] cross page → number from the manifest
15
+ *
16
+ * `scanEquationLabels` (raw Markdown) and `collectEquationLabels` (mdast tree)
17
+ * MUST agree — `tests/notation/equations.test.ts` checks that on the real
18
+ * corpus. Both are here so `manifest.ts` and `remark-notation.mjs` share one
19
+ * numbering algorithm.
20
+ */
21
+
22
+ /** `eq:` key grammar — the notation-key shape without the dotted namespaces. */
23
+ export const EQ_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
24
+
25
+ /** A `\label{eq:<key>}` anywhere in a math source. Global; `key` in group 1. */
26
+ export const EQ_LABEL_PATTERN =
27
+ /\\label\{\s*eq:([a-z0-9]+(?:-[a-z0-9]+)*)\s*\}/g;
28
+
29
+ /**
30
+ * A `[[ … ]]` payload that targets an equation: `eq-<key>` on the current page,
31
+ * or `<lesson-slug>#eq-<key>` across pages. `lesson` (group 1) is a `/`-slug.
32
+ * The `eq-` prefix is reserved — a notation key may not start with it.
33
+ */
34
+ export const EQ_REF_PATTERN =
35
+ /^(?:([a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*)#)?eq-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
36
+
37
+ /** `{ lesson?, key }` when `raw` is an equation reference, else `undefined`. */
38
+ export function parseEquationRef(raw) {
39
+ const match = EQ_REF_PATTERN.exec(String(raw).trim());
40
+ if (!match) return undefined;
41
+ return match[1] ? { lesson: match[1], key: match[2] } : { key: match[2] };
42
+ }
43
+
44
+ /** Remove every `\label{eq:…}` (and the whitespace it leaves) from a math source. */
45
+ export function stripEquationLabels(latex) {
46
+ return String(latex)
47
+ .replace(EQ_LABEL_PATTERN, '')
48
+ .replace(/[ \t]+\n/g, '\n')
49
+ .replace(/\n{3,}/g, '\n\n')
50
+ .replace(/[ \t]{2,}/g, ' ')
51
+ .trim();
52
+ }
53
+
54
+ /** The visible number for the `index`-th keyed equation under `section`. */
55
+ function equationNumber(section, index) {
56
+ return section > 0 ? `${section}.${index}` : String(index);
57
+ }
58
+
59
+ function stripFencedCode(markdown) {
60
+ return String(markdown)
61
+ .replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
62
+ .replace(/~~~[\s\S]*?~~~/g, (block) => block.replace(/[^\n]/g, ' '))
63
+ .replace(/(^|[^`])`[^`\n]*`/g, (match) => match.replace(/[^\n]/g, ' '));
64
+ }
65
+
66
+ /**
67
+ * Every keyed display equation in a Markdown body, in appearance order, with
68
+ * its assigned number. `$$…$$` blocks only; `##` headings between them advance
69
+ * the section counter.
70
+ */
71
+ export function scanEquationLabels(markdown) {
72
+ const text = stripFencedCode(markdown);
73
+ const tokens = [];
74
+ const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
75
+ const display = /\$\$([\s\S]+?)\$\$/g;
76
+ for (const match of text.matchAll(heading)) {
77
+ tokens.push({ kind: 'section', index: match.index });
78
+ }
79
+ for (const match of text.matchAll(display)) {
80
+ EQ_LABEL_PATTERN.lastIndex = 0;
81
+ const label = EQ_LABEL_PATTERN.exec(match[1]);
82
+ if (label) {
83
+ tokens.push({ kind: 'equation', index: match.index, key: label[1] });
84
+ }
85
+ }
86
+ tokens.sort((a, b) => a.index - b.index);
87
+
88
+ const labels = [];
89
+ let section = 0;
90
+ let indexInSection = 0;
91
+ for (const token of tokens) {
92
+ if (token.kind === 'section') {
93
+ section += 1;
94
+ indexInSection = 0;
95
+ continue;
96
+ }
97
+ indexInSection += 1;
98
+ labels.push({
99
+ key: token.key,
100
+ number: equationNumber(section, indexInSection),
101
+ section,
102
+ indexInSection,
103
+ });
104
+ }
105
+ return labels;
106
+ }
107
+
108
+ /**
109
+ * The mdast equivalent of `scanEquationLabels`: walk the tree in document
110
+ * order, advancing the section counter on every depth-2 heading and emitting a
111
+ * label for every `math` node whose source carries `\label{eq:…}`.
112
+ */
113
+ export function collectEquationLabels(tree) {
114
+ const labels = [];
115
+ let section = 0;
116
+ let indexInSection = 0;
117
+
118
+ const walk = (node) => {
119
+ if (!node || typeof node !== 'object') return;
120
+ if (node.type === 'heading' && node.depth === 2) {
121
+ section += 1;
122
+ indexInSection = 0;
123
+ return;
124
+ }
125
+ if (node.type === 'math') {
126
+ EQ_LABEL_PATTERN.lastIndex = 0;
127
+ const label = EQ_LABEL_PATTERN.exec(String(node.value ?? ''));
128
+ if (label) {
129
+ indexInSection += 1;
130
+ labels.push({
131
+ key: label[1],
132
+ number: equationNumber(section, indexInSection),
133
+ section,
134
+ indexInSection,
135
+ node,
136
+ });
137
+ }
138
+ return;
139
+ }
140
+ if (Array.isArray(node.children)) node.children.forEach(walk);
141
+ };
142
+ walk(tree);
143
+ return labels;
144
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The completeness gate, run from the CLI (`pnpm validate:content`) over every
3
+ * rendered-math context — lesson body `$…$` / `$$…$$` (including math inside
4
+ * component slots), each `notation.local` `formula`, and any `$…$` in an
5
+ * assessment `prompt` / `explanation`. It reuses the one resolver the Astro
6
+ * remark pass uses (`resolveMathGlyphs`), so there is a single glyph table and
7
+ * a single diagnostic shape; `astro build` re-runs the same resolver on the
8
+ * render path as a backstop.
9
+ *
10
+ * Every failure names `file:line: "token"` and the fix.
11
+ */
12
+ import { resolveMathGlyphs, GlyphResolutionError } from './math-glyphs.mjs';
13
+ import { stripEquationLabels } from './equations.mjs';
14
+ import type {
15
+ NotationLessonInput,
16
+ NotationRegistryInput,
17
+ SharedNotationDefinitionInput,
18
+ } from './types';
19
+
20
+ export interface MathGateDiagnostic {
21
+ readonly context: 'body' | 'formula' | 'assessment';
22
+ /**
23
+ * Body / component-slot math blocks the build (the render path needs every
24
+ * glyph resolved for the KaTeX trust callback). `formula` and `assessment`
25
+ * math enter as warnings (Tier 3) and are promoted once existing content is
26
+ * brought into line — they surface pre-existing ambiguities that are a
27
+ * separate reviewed content pass (D6/D13/D14), not a D3 codemod.
28
+ */
29
+ readonly severity: 'error' | 'warning';
30
+ readonly file: string;
31
+ readonly line?: number;
32
+ readonly token?: string;
33
+ readonly message: string;
34
+ }
35
+
36
+ const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
37
+ body: 'error',
38
+ formula: 'warning',
39
+ assessment: 'warning',
40
+ };
41
+
42
+ interface AssessmentItemLike {
43
+ readonly id?: string;
44
+ readonly prompt?: string;
45
+ readonly explanation?: string;
46
+ }
47
+ interface AssessmentLike {
48
+ readonly id: string;
49
+ readonly items?: readonly AssessmentItemLike[];
50
+ }
51
+ interface LessonAssessmentLink {
52
+ readonly lessonId: string;
53
+ readonly assessmentIds: readonly string[];
54
+ }
55
+
56
+ const TERM_KEY = /\[\[\s*([a-z0-9]+(?:[.-][a-z0-9]+)*)\s*\]\]/g;
57
+ const EXPLAIN_KEY = /\\explain\s*\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}/g;
58
+ const DISPLAY_MATH = /\$\$([\s\S]+?)\$\$/g;
59
+ const INLINE_MATH = /(?<![\\$])\$(?!\s)([^$\n]+?)(?<![\\\s])\$(?!\d)/g;
60
+
61
+ function stripCode(text: string): string {
62
+ return text
63
+ .replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
64
+ .replace(/(^|[^`])`[^`\n]*`/g, (match) => match.replace(/[^\n]/g, ' '));
65
+ }
66
+
67
+ function lineOf(text: string, index: number): number {
68
+ return text.slice(0, index).split('\n').length;
69
+ }
70
+
71
+ interface MathSpan {
72
+ readonly latex: string;
73
+ readonly line: number;
74
+ }
75
+
76
+ function mathSpans(body: string): MathSpan[] {
77
+ const searchable = stripCode(body);
78
+ const spans: MathSpan[] = [];
79
+ for (const pattern of [DISPLAY_MATH, INLINE_MATH]) {
80
+ pattern.lastIndex = 0;
81
+ let match: RegExpExecArray | null;
82
+ while ((match = pattern.exec(searchable))) {
83
+ spans.push({
84
+ // D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
85
+ latex: stripEquationLabels(match[1] ?? ''),
86
+ line: lineOf(body, match.index),
87
+ });
88
+ }
89
+ }
90
+ return spans;
91
+ }
92
+
93
+ type ScopeEntry = { key: string; notation: string };
94
+
95
+ function pageScope(
96
+ lesson: NotationLessonInput,
97
+ sharedByKey: ReadonlyMap<string, SharedNotationDefinitionInput>,
98
+ ): ScopeEntry[] {
99
+ const local = lesson.localDefinitions.map((definition) => ({
100
+ key: definition.key,
101
+ notation: definition.notation,
102
+ }));
103
+ const localKeys = new Set(local.map((entry) => entry.key));
104
+
105
+ const referenced = new Set<string>();
106
+ for (const pattern of [TERM_KEY, EXPLAIN_KEY]) {
107
+ for (const match of lesson.body.matchAll(pattern)) referenced.add(match[1]);
108
+ }
109
+
110
+ const scope = [...local];
111
+ for (const key of referenced) {
112
+ if (localKeys.has(key)) continue;
113
+ const shared = sharedByKey.get(key);
114
+ if (shared) scope.push({ key: shared.key, notation: shared.notation });
115
+ }
116
+ return scope;
117
+ }
118
+
119
+ function resolveInto(
120
+ latex: string,
121
+ scope: readonly ScopeEntry[],
122
+ context: MathGateDiagnostic['context'],
123
+ file: string,
124
+ line: number | undefined,
125
+ fix: string,
126
+ out: MathGateDiagnostic[],
127
+ ): void {
128
+ let result;
129
+ try {
130
+ result = resolveMathGlyphs(latex, [...scope]);
131
+ } catch (error) {
132
+ out.push({
133
+ context,
134
+ severity: SEVERITY[context],
135
+ file,
136
+ line,
137
+ message:
138
+ error instanceof GlyphResolutionError
139
+ ? error.message
140
+ : `Could not compile math ${JSON.stringify(latex)}: ${
141
+ error instanceof Error ? error.message : String(error)
142
+ }`,
143
+ });
144
+ return;
145
+ }
146
+ for (const { token } of result.unresolved) {
147
+ out.push({
148
+ context,
149
+ severity: SEVERITY[context],
150
+ file,
151
+ line,
152
+ token,
153
+ message: `Unresolved notation ${JSON.stringify(token)} in ${context} math. ${fix}`,
154
+ });
155
+ }
156
+ }
157
+
158
+ const BODY_FIX =
159
+ 'Introduce the symbol with [[key]], define it in notation.local, or wrap it in \\explain{key}{latex}.';
160
+ const FORMULA_FIX =
161
+ 'Every right-hand-side variable of a notation.local formula must resolve to the entry key, another page symbol, or the base library.';
162
+ const ASSESSMENT_FIX =
163
+ "Introduce the symbol in the owning lesson's prose with [[key]], or wrap it in \\explain{key}{latex}.";
164
+
165
+ /**
166
+ * Resolve every rendered-math context against the page glyph table. Returns one
167
+ * diagnostic per unresolved token; an empty array means the gate passed.
168
+ */
169
+ export function gateContentMath(input: {
170
+ readonly notationInput: NotationRegistryInput;
171
+ readonly assessments: readonly AssessmentLike[];
172
+ readonly lessonAssessments: readonly LessonAssessmentLink[];
173
+ }): MathGateDiagnostic[] {
174
+ const { notationInput, assessments, lessonAssessments } = input;
175
+ const sharedByKey = new Map(
176
+ notationInput.sharedDefinitions.map((definition) => [
177
+ definition.key,
178
+ definition,
179
+ ]),
180
+ );
181
+ const lessonById = new Map(
182
+ notationInput.lessons.map((lesson) => [lesson.lessonId, lesson]),
183
+ );
184
+ const assessmentById = new Map(
185
+ assessments.map((assessment) => [assessment.id, assessment]),
186
+ );
187
+
188
+ const diagnostics: MathGateDiagnostic[] = [];
189
+
190
+ for (const lesson of notationInput.lessons) {
191
+ const scope = pageScope(lesson, sharedByKey);
192
+ const file = lesson.source.file;
193
+
194
+ for (const span of mathSpans(lesson.body)) {
195
+ resolveInto(
196
+ span.latex,
197
+ scope,
198
+ 'body',
199
+ file,
200
+ span.line,
201
+ BODY_FIX,
202
+ diagnostics,
203
+ );
204
+ }
205
+
206
+ for (const definition of lesson.localDefinitions) {
207
+ if (!definition.formula) continue;
208
+ // `scope` already carries every page-local entry, including this one, so
209
+ // the formula's LHS key resolves against itself.
210
+ resolveInto(
211
+ definition.formula,
212
+ scope,
213
+ 'formula',
214
+ file,
215
+ undefined,
216
+ FORMULA_FIX,
217
+ diagnostics,
218
+ );
219
+ }
220
+ }
221
+
222
+ for (const link of lessonAssessments) {
223
+ const lesson = lessonById.get(link.lessonId);
224
+ if (!lesson) continue;
225
+ const scope = pageScope(lesson, sharedByKey);
226
+
227
+ for (const assessmentId of link.assessmentIds) {
228
+ const assessment = assessmentById.get(assessmentId);
229
+ if (!assessment?.items) continue;
230
+ for (const item of assessment.items) {
231
+ for (const field of [item.prompt, item.explanation]) {
232
+ if (typeof field !== 'string') continue;
233
+ for (const span of mathSpans(field)) {
234
+ resolveInto(
235
+ span.latex,
236
+ scope,
237
+ 'assessment',
238
+ `${assessmentId} (${item.id ?? '?'})`,
239
+ undefined,
240
+ ASSESSMENT_FIX,
241
+ diagnostics,
242
+ );
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ return diagnostics;
250
+ }
@@ -0,0 +1,91 @@
1
+ import { NOTATION_KEY_PATTERN } from './remark-notation.mjs';
2
+
3
+ export const EXPLAIN_MACRO = String.raw`\htmlData{notation-key=#1}{#2}`;
4
+
5
+ // Interactive labs render developer-authored math templates that carry two
6
+ // kinds of inert marker: `\explain` for a notation key (as above) and `\slot`
7
+ // for a numeric hole the island rewrites at runtime. Both compile to a single
8
+ // `data-*` attribute and nothing else.
9
+ export const SLOT_MACRO = String.raw`\htmlData{lab-slot=#1}{#2}`;
10
+ const LAB_SLOT_PATTERN = /^[a-z][a-z0-9]*$/;
11
+
12
+ /**
13
+ * KaTeX trust callback that permits exactly one inert marker attribute:
14
+ * `data-notation-key` (a registry key) or `data-lab-slot` (a lab value hole).
15
+ * Links, styles, classes, IDs, protocols, and every other data attribute stay
16
+ * untrusted. Key existence is checked by the remark adapter, the lab-math
17
+ * renderer, and the workspace validator before KaTeX runs.
18
+ */
19
+ export function trustNotationMarker(context) {
20
+ if (context?.command !== '\\htmlData') return false;
21
+ if (!context.attributes || typeof context.attributes !== 'object') {
22
+ return false;
23
+ }
24
+
25
+ const entries = Object.entries(context.attributes);
26
+ if (entries.length !== 1) return false;
27
+ const [name, value] = entries[0];
28
+ if (typeof value !== 'string') return false;
29
+
30
+ if (name === 'data-notation-key') return NOTATION_KEY_PATTERN.test(value);
31
+ if (name === 'data-lab-slot') return LAB_SLOT_PATTERN.test(value);
32
+ return false;
33
+ }
34
+
35
+ function strictNotation(errorCode) {
36
+ // The trusted macros intentionally use KaTeX's HTML extension. Keep normal
37
+ // strict-mode warnings for all other non-LaTeX constructs.
38
+ return errorCode === 'htmlExtension' ? 'ignore' : 'warn';
39
+ }
40
+
41
+ export const notationKatexOptions = Object.freeze({
42
+ output: 'htmlAndMathml',
43
+ throwOnError: true,
44
+ strict: strictNotation,
45
+ trust: trustNotationMarker,
46
+ macros: Object.freeze({
47
+ '\\explain': EXPLAIN_MACRO,
48
+ '\\slot': SLOT_MACRO,
49
+ }),
50
+ });
51
+
52
+ /**
53
+ * Add non-security KaTeX settings while keeping the notation macros and trust
54
+ * boundary non-overridable.
55
+ */
56
+ export function createNotationKatexOptions(overrides = {}) {
57
+ if ('output' in overrides) {
58
+ throw new TypeError(
59
+ 'The notation KaTeX output mode cannot be overridden; HTML and MathML are required.',
60
+ );
61
+ }
62
+ if ('throwOnError' in overrides) {
63
+ throw new TypeError(
64
+ 'The notation KaTeX error mode cannot be overridden; rendering errors must fail the build.',
65
+ );
66
+ }
67
+ if ('trust' in overrides) {
68
+ throw new TypeError(
69
+ 'The notation KaTeX trust callback cannot be overridden.',
70
+ );
71
+ }
72
+ if (overrides.macros?.['\\explain'] !== undefined) {
73
+ throw new TypeError('The \\explain KaTeX macro cannot be overridden.');
74
+ }
75
+ if (overrides.macros?.['\\slot'] !== undefined) {
76
+ throw new TypeError('The \\slot KaTeX macro cannot be overridden.');
77
+ }
78
+
79
+ return {
80
+ ...notationKatexOptions,
81
+ ...overrides,
82
+ output: notationKatexOptions.output,
83
+ throwOnError: notationKatexOptions.throwOnError,
84
+ trust: trustNotationMarker,
85
+ macros: {
86
+ ...(overrides.macros ?? {}),
87
+ '\\explain': EXPLAIN_MACRO,
88
+ '\\slot': SLOT_MACRO,
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,54 @@
1
+ import { loadManifest } from '../compiler/manifest';
2
+ import type { LabMathScopeDefinition } from './render-lab-math';
3
+
4
+ interface LessonNotationFrontmatter {
5
+ readonly local?: ReadonlyArray<{
6
+ readonly key: string;
7
+ readonly latex: string;
8
+ }>;
9
+ }
10
+
11
+ const TERM_KEY = /\[\[\s*([a-z0-9]+(?:[.-][a-z0-9]+)*)\s*\]\]/g;
12
+ const EXPLAIN_KEY = /\\explain\s*\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}/g;
13
+
14
+ /**
15
+ * The notation a lab's math template may reference: exactly the same page glyph
16
+ * scope the remark adapter gives lesson `$$` math — this page's
17
+ * `notation.local` plus the shared entries it names in the body with `[[key]]`
18
+ * (or `\explain{key}{…}`). No transitive closure, so island math cannot
19
+ * quietly reach beyond what the lesson introduces.
20
+ *
21
+ * Shared definitions are read from the one compiler manifest (Phase D5); this
22
+ * module never re-reads the content tree.
23
+ */
24
+ export async function labMathScope(
25
+ notation: LessonNotationFrontmatter | undefined,
26
+ body = '',
27
+ ): Promise<LabMathScopeDefinition[]> {
28
+ const local = notation?.local ?? [];
29
+ const scope: LabMathScopeDefinition[] = local.map((definition) => ({
30
+ key: definition.key,
31
+ notation: definition.latex,
32
+ }));
33
+
34
+ const referenced = new Set<string>();
35
+ for (const pattern of [TERM_KEY, EXPLAIN_KEY]) {
36
+ for (const match of body.matchAll(pattern)) referenced.add(match[1]);
37
+ }
38
+
39
+ const localKeys = new Set(local.map((definition) => definition.key));
40
+ const manifest = await loadManifest();
41
+ const sharedByKey = new Map(
42
+ manifest.notation.definitions
43
+ .filter((definition) => definition.kind === 'shared')
44
+ .map((definition) => [definition.key, definition]),
45
+ );
46
+
47
+ for (const key of referenced) {
48
+ if (localKeys.has(key)) continue;
49
+ const shared = sharedByKey.get(key);
50
+ if (shared) scope.push({ key: shared.key, notation: shared.notation });
51
+ }
52
+
53
+ return scope;
54
+ }