explico 0.3.0 → 0.5.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/CHANGELOG.md +344 -1
- package/README.md +117 -3
- package/dist/compiler/astro.d.ts +38 -0
- package/dist/compiler/astro.js +258 -0
- package/dist/compiler/cli.d.ts +13 -0
- package/dist/compiler/cli.js +32 -12
- package/dist/compiler/collections.d.ts +3 -2
- package/dist/compiler/collections.js +28 -1
- package/dist/compiler/course-config.d.ts +86 -4
- package/dist/compiler/course-config.js +39 -0
- package/dist/compiler/exports/standalone-html.d.ts +34 -0
- package/dist/compiler/exports/standalone-html.js +803 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +112 -7
- package/dist/compiler/manifest.js +205 -40
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-abbreviations.d.mts +30 -0
- package/dist/reference/base-abbreviations.mjs +60 -0
- package/dist/reference/base-operators.d.mts +15 -0
- package/dist/reference/base-operators.mjs +40 -0
- package/dist/reference/base-roman-labels.d.mts +35 -0
- package/dist/reference/base-roman-labels.mjs +43 -0
- package/dist/reference/consistency.d.ts +8 -2
- package/dist/reference/consistency.js +50 -3
- package/dist/reference/diagrams-validate.d.ts +21 -0
- package/dist/reference/diagrams-validate.js +13 -0
- package/dist/reference/equations-validate.d.ts +8 -3
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +66 -35
- package/dist/reference/equations.mjs +142 -40
- package/dist/reference/gate-abbreviations.d.ts +17 -0
- package/dist/reference/gate-abbreviations.js +64 -0
- package/dist/reference/gate-math.d.ts +28 -5
- package/dist/reference/gate-math.js +5 -2
- package/dist/reference/gate-prose-notation.d.ts +60 -0
- package/dist/reference/gate-prose-notation.js +125 -0
- package/dist/reference/keyed-float-validate.d.ts +14 -8
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/keyed-jsx-floats.d.mts +24 -21
- package/dist/reference/keyed-jsx-floats.mjs +90 -41
- package/dist/reference/knowledge-checks-validate.d.ts +35 -0
- package/dist/reference/knowledge-checks-validate.js +78 -0
- package/dist/reference/knowledge-checks.d.mts +25 -0
- package/dist/reference/knowledge-checks.mjs +45 -0
- package/dist/reference/lab-math-scope.d.ts +9 -1
- package/dist/reference/lab-math-scope.js +32 -4
- package/dist/reference/math-glyphs.d.mts +30 -0
- package/dist/reference/math-glyphs.mjs +367 -0
- package/dist/reference/mdx-attributes.d.mts +13 -0
- package/dist/reference/mdx-attributes.mjs +38 -0
- package/dist/reference/mixed-content.d.ts +17 -0
- package/dist/reference/mixed-content.js +29 -0
- package/dist/reference/page-notation.d.ts +52 -0
- package/dist/reference/page-notation.js +171 -0
- package/dist/reference/raw-floats-validate.d.ts +18 -0
- package/dist/reference/raw-floats-validate.js +53 -0
- package/dist/reference/raw-floats.d.mts +18 -0
- package/dist/reference/raw-floats.mjs +129 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -1
- package/dist/reference/rehype-heading-anchors.mjs +107 -1
- package/dist/reference/rehype-table-header-labels.d.mts +26 -0
- package/dist/reference/rehype-table-header-labels.mjs +152 -0
- package/dist/reference/remark-abbreviations.d.mts +7 -0
- package/dist/reference/remark-abbreviations.mjs +174 -0
- package/dist/reference/remark-memo.d.mts +16 -0
- package/dist/reference/remark-memo.mjs +339 -0
- package/dist/reference/remark-notation.d.mts +102 -5
- package/dist/reference/remark-notation.mjs +506 -50
- package/dist/reference/remark-short-note.d.mts +4 -0
- package/dist/reference/remark-short-note.mjs +237 -0
- package/dist/reference/types.d.ts +16 -1
- package/dist/reference/units-vocab.js +4 -0
- package/docs/VERSIONING.md +33 -4
- package/docs/authoring/README.md +12 -0
- package/docs/authoring/agents.md +17 -2
- package/docs/authoring/components.md +135 -0
- package/docs/authoring/migrating.md +154 -7
- package/docs/authoring/notation.md +568 -8
- package/docs/authoring/structure.md +251 -0
- package/docs/authoring/visualizations.md +36 -3
- package/package.json +5 -2
- package/src/compiler/astro.ts +332 -0
- package/src/compiler/cli.ts +33 -10
- package/src/compiler/collections.ts +45 -4
- package/src/compiler/course-config.ts +131 -3
- package/src/compiler/exports/standalone-html.ts +963 -0
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +367 -49
- package/src/components/HoverPanel.astro +45 -4
- package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
- package/src/components/assessments/AssessmentRunner.tsx +52 -17
- package/src/components/assessments/AssessmentSet.astro +98 -14
- package/src/components/assessments/RenderedContent.tsx +55 -0
- package/src/components/assessments/assessment-randomization.ts +16 -0
- package/src/components/diagram/Diagram.astro +444 -83
- package/src/components/disclosure/hover-preview.ts +202 -0
- package/src/components/disclosure/persisted-disclosure.ts +66 -0
- package/src/components/examples/CompactExamples.astro +2 -0
- package/src/components/figure/Figure.astro +279 -10
- package/src/components/memo/Memo.astro +453 -0
- package/src/components/memo/ShortNote.astro +223 -0
- package/src/components/notation/NotationFormula.astro +18 -1
- package/src/components/notation/NotationLayer.astro +116 -8
- package/src/components/notation/render-notation-math.ts +39 -2
- package/src/components/notation/safe-math-to-react.ts +50 -0
- package/src/components/reference/FloatRefLayer.astro +233 -15
- package/src/components/starlight/CollapsibleSections.astro +461 -0
- package/src/components/starlight/DiagramEnhancer.astro +385 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
- package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
- package/src/components/starlight/LayoutHeader.astro +50 -0
- package/src/components/starlight/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LayoutSocialIcons.astro +41 -2
- package/src/components/starlight/LessonExportActions.astro +436 -0
- package/src/components/starlight/LessonFooter.astro +38 -2
- package/src/components/starlight/section-parts.ts +31 -0
- package/src/components/table/Table.astro +296 -9
- package/src/components/table/TableOverflowExpander.astro +489 -0
- package/src/content-config.ts +20 -0
- package/src/reference/base-abbreviations.mjs +60 -0
- package/src/reference/base-operators.mjs +40 -0
- package/src/reference/base-roman-labels.mjs +43 -0
- package/src/reference/consistency.ts +73 -2
- package/src/reference/diagrams-validate.ts +40 -0
- package/src/reference/equations-validate.ts +6 -4
- package/src/reference/equations.mjs +142 -40
- package/src/reference/gate-abbreviations.ts +128 -0
- package/src/reference/gate-math.ts +27 -7
- package/src/reference/gate-prose-notation.ts +224 -0
- package/src/reference/keyed-float-validate.ts +12 -9
- package/src/reference/keyed-jsx-floats.mjs +90 -41
- package/src/reference/knowledge-checks-validate.ts +130 -0
- package/src/reference/knowledge-checks.mjs +45 -0
- package/src/reference/lab-math-scope.ts +45 -3
- package/src/reference/math-glyphs.mjs +367 -0
- package/src/reference/mdx-attributes.mjs +38 -0
- package/src/reference/mixed-content.ts +69 -0
- package/src/reference/page-notation.ts +252 -0
- package/src/reference/raw-floats-validate.ts +103 -0
- package/src/reference/raw-floats.mjs +129 -0
- package/src/reference/rehype-heading-anchors.mjs +107 -1
- package/src/reference/rehype-table-header-labels.mjs +152 -0
- package/src/reference/remark-abbreviations.mjs +174 -0
- package/src/reference/remark-memo.mjs +339 -0
- package/src/reference/remark-notation.mjs +506 -50
- package/src/reference/remark-short-note.mjs +237 -0
- package/src/reference/types.ts +16 -0
- package/src/reference/units-vocab.ts +4 -0
- package/src/styles/global.css +1125 -50
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain-text-notation gate (rule N9), run from the CLI alongside the
|
|
3
|
+
* completeness gate (`gate-math.ts`). Where that gate asks "does every glyph
|
|
4
|
+
* *inside* `$…$` resolve", this one asks the complementary question: "is
|
|
5
|
+
* anything that reads as math sitting *outside* `$…$` in the first place" —
|
|
6
|
+
* a unit abbreviation, a division of two variable-like symbols, an escaped
|
|
7
|
+
* absolute-value bar, a hand-typed sub/superscript. Both gates share one
|
|
8
|
+
* `MathGateDiagnostic` shape and one output array (`manifest.diagnostics.math`)
|
|
9
|
+
* so the CLI needs no separate wiring.
|
|
10
|
+
*
|
|
11
|
+
* Two entry points, one set of checks (`findingsIn`): `gateProseNotation`
|
|
12
|
+
* scans `lesson.body` with fenced/inline code and every `$…$` / `$$…$$` span
|
|
13
|
+
* already blanked out (same `stripCode` + math-span erasure `gate-math.ts`
|
|
14
|
+
* uses), so it only ever sees prose and table cells. `gateProseNotationInAssessments`
|
|
15
|
+
* scans an assessment item's `prompt` / `explanation` the same way — a plain
|
|
16
|
+
* JSON string field, not Markdown, but one that renders through the same
|
|
17
|
+
* KaTeX pipeline as a lesson body since `AssessmentRunner` picked it up
|
|
18
|
+
* (`mixed-content.ts`), so the same rule applies.
|
|
19
|
+
*/
|
|
20
|
+
import type { MathGateDiagnostic } from './gate-math';
|
|
21
|
+
import type { NotationLessonInput } from './types';
|
|
22
|
+
|
|
23
|
+
interface AssessmentItemLike {
|
|
24
|
+
readonly id?: string;
|
|
25
|
+
readonly prompt?: string;
|
|
26
|
+
readonly explanation?: string;
|
|
27
|
+
}
|
|
28
|
+
interface AssessmentLike {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly items?: readonly AssessmentItemLike[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CODE_FENCE = /```[\s\S]*?```/g;
|
|
34
|
+
const INLINE_CODE = /(^|[^`])`[^`\n]*`/g;
|
|
35
|
+
const DISPLAY_MATH = /\$\$([\s\S]+?)\$\$/g;
|
|
36
|
+
const INLINE_MATH = /(?<![\\$])\$(?!\s)([^$\n]+?)(?<![\\\s])\$(?!\d)/g;
|
|
37
|
+
// MDX `import … from '…/…'` lines: the path's `/` is a module separator, not
|
|
38
|
+
// a division of two symbols.
|
|
39
|
+
const IMPORT_LINE = /^[ \t]*import\b[^\n]*$/gm;
|
|
40
|
+
// `[[key]]` / `[[slug/lesson#anchor]]` reference syntax: never prose math,
|
|
41
|
+
// and a cross-page slug legitimately contains a `/`.
|
|
42
|
+
const KEY_REFERENCE = /\[\[[^\]\n]*\]\]/g;
|
|
43
|
+
// A JSX tag, opening through its matching `>` — may span several lines when
|
|
44
|
+
// each prop is on its own line. Component prop strings (`caption="…"`,
|
|
45
|
+
// `title="…"`) are plain display text with no math rendering at all (Figure
|
|
46
|
+
// and Table hand their `caption` straight to the DOM as a string), so N9
|
|
47
|
+
// does not apply inside one — enforcing "use $…$" somewhere it can't render
|
|
48
|
+
// would just break the caption instead of fixing it.
|
|
49
|
+
const JSX_TAG = /<\/?[A-Za-z][^<>]*>/g;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Blank code, math, reference, and JSX-tag spans so offsets don't shift.
|
|
53
|
+
* Exported so `gate-abbreviations.ts` (N14) scans exactly the same "is this
|
|
54
|
+
* really prose" surface as N9, instead of drifting with a second copy of
|
|
55
|
+
* these regexes.
|
|
56
|
+
*/
|
|
57
|
+
export function proseOnly(body: string): string {
|
|
58
|
+
const blank = (match: string) => match.replace(/[^\n]/g, ' ');
|
|
59
|
+
return body
|
|
60
|
+
.replace(CODE_FENCE, blank)
|
|
61
|
+
.replace(INLINE_CODE, (match, lead: string) => lead + blank(match.slice(lead.length)))
|
|
62
|
+
.replace(DISPLAY_MATH, blank)
|
|
63
|
+
.replace(INLINE_MATH, blank)
|
|
64
|
+
.replace(IMPORT_LINE, blank)
|
|
65
|
+
.replace(KEY_REFERENCE, blank)
|
|
66
|
+
.replace(JSX_TAG, blank);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Exported for the same reason as {@link proseOnly}. */
|
|
70
|
+
export function lineOf(text: string, index: number): number {
|
|
71
|
+
return text.slice(0, index).split('\n').length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Symbolic/abbreviated unit tokens that must be typeset in `$…$`, longest
|
|
75
|
+
// first so the alternation can't match a prefix of a longer unit.
|
|
76
|
+
const UNIT = String.raw`m/s²|m/s|km/h|rad/s|m²/s²|J/kg|m²|s⁻¹|kg|N|J|W|Hz|deg|°|rad|m|s`;
|
|
77
|
+
|
|
78
|
+
const NUMERAL_UNIT = new RegExp(
|
|
79
|
+
String.raw`(?<![\w.])-?\d+(?:\.\d+)?\s?(?:${UNIT})(?![A-Za-z])`,
|
|
80
|
+
'g',
|
|
81
|
+
);
|
|
82
|
+
const BARE_UNIT_PARENS = new RegExp(String.raw`\((?:${UNIT})\)`, 'g');
|
|
83
|
+
|
|
84
|
+
// Idiomatic English slash constructs that are prose, not a division of two
|
|
85
|
+
// symbols — reviewed and extended in the same PR that adds a new one, not by
|
|
86
|
+
// a per-page suppression (there is no lint-ignore.yml entry for this gate;
|
|
87
|
+
// see N9's "everything, no exceptions" scope).
|
|
88
|
+
const SLASH_IDIOMS = new Set([
|
|
89
|
+
'and/or',
|
|
90
|
+
'his/her',
|
|
91
|
+
'him/her',
|
|
92
|
+
'he/she',
|
|
93
|
+
'she/he',
|
|
94
|
+
'on/off',
|
|
95
|
+
'yes/no',
|
|
96
|
+
'pass/fail',
|
|
97
|
+
'input/output',
|
|
98
|
+
'output/input',
|
|
99
|
+
'read/write',
|
|
100
|
+
'either/or',
|
|
101
|
+
'client/server',
|
|
102
|
+
'pre/post',
|
|
103
|
+
'a/b',
|
|
104
|
+
]);
|
|
105
|
+
const SLASH_TOKENS = /\b([A-Za-z]{1,6})\/([A-Za-z]{1,6})\b/g;
|
|
106
|
+
|
|
107
|
+
// A `_` or `^` sitting directly between two alphanumerics reads as a
|
|
108
|
+
// hand-typed sub/superscript that was never wrapped in `$…$`.
|
|
109
|
+
const FAKE_SCRIPT = /[A-Za-z0-9][_^][A-Za-z0-9{]/g;
|
|
110
|
+
|
|
111
|
+
interface Finding {
|
|
112
|
+
readonly index: number;
|
|
113
|
+
readonly token: string;
|
|
114
|
+
readonly message: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function findAll(text: string, pattern: RegExp, toFinding: (m: RegExpExecArray) => Finding): Finding[] {
|
|
118
|
+
const out: Finding[] = [];
|
|
119
|
+
const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
120
|
+
let match: RegExpExecArray | null;
|
|
121
|
+
while ((match = re.exec(text))) {
|
|
122
|
+
out.push(toFinding(match));
|
|
123
|
+
if (match[0].length === 0) re.lastIndex++;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const FIX = 'Wrap it in $…$ (or $$…$$) instead — see docs/authoring/notation.md#n9.';
|
|
129
|
+
|
|
130
|
+
/** Every N9 finding in one already-prose-only string, in source order. */
|
|
131
|
+
function findingsIn(text: string): Finding[] {
|
|
132
|
+
return [
|
|
133
|
+
...findAll(text, /\\\|/g, (m) => ({
|
|
134
|
+
index: m.index,
|
|
135
|
+
token: m[0],
|
|
136
|
+
message: `Escaped pipe ${JSON.stringify(m[0])} outside math. Use \\lvert…\\rvert for an absolute value or norm inside $…$, not an escaped table pipe. ${FIX}`,
|
|
137
|
+
})),
|
|
138
|
+
...findAll(text, NUMERAL_UNIT, (m) => ({
|
|
139
|
+
index: m.index,
|
|
140
|
+
token: m[0].trim(),
|
|
141
|
+
message: `Numeral with a plain-text unit ${JSON.stringify(m[0].trim())} outside math. ${FIX}`,
|
|
142
|
+
})),
|
|
143
|
+
...findAll(text, BARE_UNIT_PARENS, (m) => ({
|
|
144
|
+
index: m.index,
|
|
145
|
+
token: m[0],
|
|
146
|
+
message: `Plain-text unit ${JSON.stringify(m[0])} outside math. ${FIX}`,
|
|
147
|
+
})),
|
|
148
|
+
...findAll(text, FAKE_SCRIPT, (m) => ({
|
|
149
|
+
index: m.index,
|
|
150
|
+
token: m[0],
|
|
151
|
+
message: `${JSON.stringify(m[0])} looks like a hand-typed sub/superscript outside math. ${FIX}`,
|
|
152
|
+
})),
|
|
153
|
+
...findAll(text, SLASH_TOKENS, (m) => {
|
|
154
|
+
if (SLASH_IDIOMS.has(m[0].toLowerCase())) return null as unknown as Finding;
|
|
155
|
+
return {
|
|
156
|
+
index: m.index,
|
|
157
|
+
token: m[0],
|
|
158
|
+
message: `${JSON.stringify(m[0])} outside math reads as a division of two symbols/units, not English prose. If it really is prose, add the idiom to SLASH_IDIOMS in gate-prose-notation.ts (reviewed like any other rule change). ${FIX}`,
|
|
159
|
+
};
|
|
160
|
+
}).filter(Boolean),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Scan every lesson body for plain-text math notation outside `$…$`. Returns
|
|
166
|
+
* one `MathGateDiagnostic` (context `'prose'`, always an error — N9 has no
|
|
167
|
+
* escape hatch) per finding.
|
|
168
|
+
*/
|
|
169
|
+
export function gateProseNotation(
|
|
170
|
+
lessons: readonly NotationLessonInput[],
|
|
171
|
+
): MathGateDiagnostic[] {
|
|
172
|
+
const diagnostics: MathGateDiagnostic[] = [];
|
|
173
|
+
|
|
174
|
+
for (const lesson of lessons) {
|
|
175
|
+
const text = proseOnly(lesson.body);
|
|
176
|
+
for (const finding of findingsIn(text)) {
|
|
177
|
+
diagnostics.push({
|
|
178
|
+
context: 'prose',
|
|
179
|
+
severity: 'error',
|
|
180
|
+
file: lesson.source.file,
|
|
181
|
+
line: lineOf(lesson.body, finding.index),
|
|
182
|
+
token: finding.token,
|
|
183
|
+
message: finding.message,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return diagnostics;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Scan every assessment `prompt` / `explanation` for the same plain-text
|
|
193
|
+
* notation N9 forbids in a lesson body — `AssessmentRunner` renders both
|
|
194
|
+
* through the same KaTeX pipeline now (`mixed-content.ts`), so text sitting
|
|
195
|
+
* outside `$…$` there is just as invisible to the reader's hover cards as it
|
|
196
|
+
* would be in a lesson. No line number: an assessment item is one JSON string
|
|
197
|
+
* field, not a multi-line source file, so `file` names the item instead —
|
|
198
|
+
* same `"<assessmentId> (<itemId>)"` shape `gate-math.ts` already uses for
|
|
199
|
+
* this context.
|
|
200
|
+
*/
|
|
201
|
+
export function gateProseNotationInAssessments(
|
|
202
|
+
assessments: readonly AssessmentLike[],
|
|
203
|
+
): MathGateDiagnostic[] {
|
|
204
|
+
const diagnostics: MathGateDiagnostic[] = [];
|
|
205
|
+
|
|
206
|
+
for (const assessment of assessments) {
|
|
207
|
+
for (const item of assessment.items ?? []) {
|
|
208
|
+
for (const field of [item.prompt, item.explanation]) {
|
|
209
|
+
if (typeof field !== 'string') continue;
|
|
210
|
+
for (const finding of findingsIn(proseOnly(field))) {
|
|
211
|
+
diagnostics.push({
|
|
212
|
+
context: 'prose',
|
|
213
|
+
severity: 'error',
|
|
214
|
+
file: `${assessment.id} (${item.id ?? '?'})`,
|
|
215
|
+
token: finding.token,
|
|
216
|
+
message: finding.message,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return diagnostics;
|
|
224
|
+
}
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* - `<prefix>-invalid-key` (error): an `id` must match the key grammar.
|
|
7
7
|
* - `<prefix>-duplicate-key` (error): an `id` is unique per lesson.
|
|
8
8
|
* - `<prefix>-ref-resolves` (error): every `[[<prefix>-key]]` /
|
|
9
|
-
* `[[slug#<prefix>-key]]` points at
|
|
9
|
+
* `[[slug#<prefix>-key]]` points at the matching keyed float with that `id`.
|
|
10
10
|
* - `<prefix>-key-unused` (warning): a keyed float never referenced.
|
|
11
11
|
*
|
|
12
|
-
* `tables-validate.ts` and `
|
|
13
|
-
* so callers import
|
|
14
|
-
* narrow diagnostic-code types, while `equations-validate.ts` stays
|
|
15
|
-
*
|
|
12
|
+
* `tables-validate.ts`, `figures-validate.ts`, and `diagrams-validate.ts` are
|
|
13
|
+
* thin wrappers over this so callers import a kind-specific validator with
|
|
14
|
+
* narrow diagnostic-code types, while `equations-validate.ts` stays untouched
|
|
15
|
+
* — equations predate this and keep their own dedicated path.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import {
|
|
@@ -69,10 +69,13 @@ export interface KeyedFloatLessonInput {
|
|
|
69
69
|
readonly file: string;
|
|
70
70
|
readonly body: string;
|
|
71
71
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
72
|
+
* `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
|
|
73
|
+
* and how many floats of this kind earlier lessons of that part already used.
|
|
74
|
+
* When set, floats are numbered `‹part›.‹offset + n›`; when omitted, the
|
|
75
|
+
* legacy `##`-section scheme. Each kind counts on its own, so the caller
|
|
76
|
+
* passes a different `offset` for `<Table>` than for `<Figure>`.
|
|
74
77
|
*/
|
|
75
|
-
readonly
|
|
78
|
+
readonly numbering?: { readonly prefix: number; readonly offset: number };
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
@@ -117,7 +120,7 @@ export function validateKeyedFloats(
|
|
|
117
120
|
const table: Record<string, string> = {};
|
|
118
121
|
for (const raw of scanKeyedJsxFloats(lesson.body, {
|
|
119
122
|
tagName,
|
|
120
|
-
|
|
123
|
+
numbering: lesson.numbering,
|
|
121
124
|
}) as {
|
|
122
125
|
key: string | null;
|
|
123
126
|
number: string;
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Keyed-float identity for a JSX-wrapped table or
|
|
3
|
-
* `<Table
|
|
2
|
+
* Keyed-float identity for a JSX-wrapped table, figure, or diagram, shared
|
|
3
|
+
* between `<Table>`, `<Figure>`, and `<Diagram>`.
|
|
4
4
|
*
|
|
5
5
|
* This mirrors `equations.mjs`, but the key comes from a plain `id` prop on
|
|
6
6
|
* the wrapper component rather than a `\label{eq:key}` embedded in shared
|
|
7
|
-
* LaTeX source — a `<Table>`/`<Figure>` is purpose-built, so there is no
|
|
7
|
+
* LaTeX source — a `<Table>`/`<Figure>`/`<Diagram>` is purpose-built, so there is no
|
|
8
8
|
* ambiguity an embedded marker would need to resolve. An author writes the
|
|
9
9
|
* bare key (`id="kinematics-summary"`); the rendered DOM id and the `[[…]]`
|
|
10
10
|
* reference prefix are computed the same way `\label{eq:key}` becomes
|
|
11
|
-
* `#eq-key` — `keyedFloatAnchorId` prepends `tab-`/`fig-`.
|
|
11
|
+
* `#eq-key` — `keyedFloatAnchorId` prepends `tab-`/`fig-`/`dia-`.
|
|
12
12
|
*
|
|
13
|
-
* Only top-level occurrences count, exactly like `collectDisplayEquations
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Only top-level occurrences count, exactly like `collectDisplayEquations`,
|
|
14
|
+
* except that `<Memo>` is transparent: a float in a memo remains a side-note
|
|
15
|
+
* in the lesson's reading order. A keyed float nested inside a
|
|
16
|
+
* `<CompactExample>` (or any other component) is illustrative, not part of the
|
|
17
|
+
* official sequence.
|
|
18
18
|
*
|
|
19
19
|
* `scanKeyedJsxFloats` (raw Markdown, for the manifest builder) and
|
|
20
20
|
* `collectKeyedJsxFloats` (mdast, for `remark-notation.mjs`) MUST agree —
|
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
* `equations.test.ts` holds equations to.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
resolveNumbering,
|
|
27
|
+
stripFencedCode,
|
|
28
|
+
stripJsxComponents,
|
|
29
|
+
} from './equations.mjs';
|
|
26
30
|
|
|
27
31
|
/** Same shape as `EQ_KEY_PATTERN` — the notation-key grammar without dots. */
|
|
28
32
|
export const KEYED_FLOAT_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -33,15 +37,22 @@ function floatNumber(section, index) {
|
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
|
-
* The visible number for one float. With `
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
+
* The visible number for one float. With a `prefix` (the part's ordinal) the
|
|
41
|
+
* scheme is `‹part›.‹offset + indexInLesson›` and `##` headings are ignored;
|
|
42
|
+
* without it, the legacy `##`-section scheme. Mirrors `displayEquationNumber`
|
|
43
|
+
* in `equations.mjs`. Each kind of float counts on its own — a `<Table>` never
|
|
44
|
+
* advances the `<Figure>` or `<Diagram>` sequence — so `offset` is per kind too.
|
|
40
45
|
*/
|
|
41
|
-
function keyedFloatNumber({
|
|
42
|
-
|
|
46
|
+
function keyedFloatNumber({
|
|
47
|
+
prefix,
|
|
48
|
+
offset = 0,
|
|
49
|
+
section,
|
|
50
|
+
indexInSection,
|
|
51
|
+
indexInLesson,
|
|
52
|
+
}) {
|
|
53
|
+
return prefix == null
|
|
43
54
|
? floatNumber(section, indexInSection)
|
|
44
|
-
: `${
|
|
55
|
+
: `${prefix}.${offset + indexInLesson}`;
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
/** The anchor id for a float, e.g. `{prefix:'tab', key:'foo'}` → `tab-foo`. */
|
|
@@ -71,14 +82,19 @@ function readIdAttribute(attributesSource) {
|
|
|
71
82
|
/**
|
|
72
83
|
* The raw-text equivalent of `collectKeyedJsxFloats`, mirroring
|
|
73
84
|
* `scanEquationLabels`: every top-level `<TagName …>` in appearance order.
|
|
74
|
-
* With `
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
85
|
+
* With `numbering` (`{ prefix, offset }` — the part's ordinal and the count of
|
|
86
|
+
* this kind of float in earlier lessons of that part) the number is
|
|
87
|
+
* `‹part›.‹offset + n›`, `n` sequential over the whole lesson; without it, `##`
|
|
88
|
+
* headings advance a section counter. Other JSX components' bodies are blanked
|
|
89
|
+
* first so a `<Table>` nested inside e.g. `<CompactExample>` (and any `##`-like
|
|
90
|
+
* text inside an unrelated component) is never counted.
|
|
79
91
|
*/
|
|
80
|
-
export function scanKeyedJsxFloats(markdown, { tagName,
|
|
81
|
-
const
|
|
92
|
+
export function scanKeyedJsxFloats(markdown, { tagName, numbering }) {
|
|
93
|
+
const { prefix, offset } = resolveNumbering(numbering);
|
|
94
|
+
const text = stripJsxComponents(stripFencedCode(markdown), tagName, [
|
|
95
|
+
'Memo',
|
|
96
|
+
'ShortNote',
|
|
97
|
+
]);
|
|
82
98
|
const tokens = [];
|
|
83
99
|
const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
|
|
84
100
|
const opening = new RegExp(`<${tagName}\\b([^>]*)>`, 'g');
|
|
@@ -106,7 +122,8 @@ export function scanKeyedJsxFloats(markdown, { tagName, chapter }) {
|
|
|
106
122
|
labels.push({
|
|
107
123
|
key: token.key,
|
|
108
124
|
number: keyedFloatNumber({
|
|
109
|
-
|
|
125
|
+
prefix,
|
|
126
|
+
offset,
|
|
110
127
|
section,
|
|
111
128
|
indexInSection,
|
|
112
129
|
indexInLesson,
|
|
@@ -119,26 +136,30 @@ export function scanKeyedJsxFloats(markdown, { tagName, chapter }) {
|
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* depth-2 heading and emitting a record for every `mdxJsxFlowElement` named
|
|
125
|
-
* `tagName` — keyed or not, exactly like `collectDisplayEquations`. `chapter`
|
|
126
|
-
* is threaded the same way as in `scanKeyedJsxFloats`.
|
|
139
|
+
* How many floats of this kind a lesson body holds — the input to the next
|
|
140
|
+
* lesson's `offset` within the same part.
|
|
127
141
|
*/
|
|
128
|
-
export function
|
|
142
|
+
export function countKeyedJsxFloats(markdown, { tagName }) {
|
|
143
|
+
return scanKeyedJsxFloats(markdown, { tagName }).length;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The mdast equivalent of `scanKeyedJsxFloats`: walk the tree's top-level
|
|
148
|
+
* children (and through `<Memo>` bodies) in document order, advancing the
|
|
149
|
+
* section counter on every top-level depth-2 heading and emitting a record for
|
|
150
|
+
* every `mdxJsxFlowElement` named `tagName` — keyed or not, exactly like
|
|
151
|
+
* `collectDisplayEquations`. `numbering` is threaded the same way as in
|
|
152
|
+
* `scanKeyedJsxFloats`.
|
|
153
|
+
*/
|
|
154
|
+
export function collectKeyedJsxFloats(tree, { tagName, numbering }) {
|
|
155
|
+
const { prefix, offset } = resolveNumbering(numbering);
|
|
129
156
|
const floats = [];
|
|
130
157
|
let section = 0;
|
|
131
158
|
let indexInSection = 0;
|
|
132
159
|
let indexInLesson = 0;
|
|
133
160
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
if (!node || typeof node !== 'object') continue;
|
|
137
|
-
if (node.type === 'heading' && node.depth === 2) {
|
|
138
|
-
section += 1;
|
|
139
|
-
indexInSection = 0;
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
161
|
+
const collectFromDetail = (node) => {
|
|
162
|
+
if (!node || typeof node !== 'object') return;
|
|
142
163
|
if (node.type === 'mdxJsxFlowElement' && node.name === tagName) {
|
|
143
164
|
indexInSection += 1;
|
|
144
165
|
indexInLesson += 1;
|
|
@@ -151,7 +172,8 @@ export function collectKeyedJsxFloats(tree, { tagName, chapter }) {
|
|
|
151
172
|
floats.push({
|
|
152
173
|
key: key ?? null,
|
|
153
174
|
number: keyedFloatNumber({
|
|
154
|
-
|
|
175
|
+
prefix,
|
|
176
|
+
offset,
|
|
155
177
|
section,
|
|
156
178
|
indexInSection,
|
|
157
179
|
indexInLesson,
|
|
@@ -160,8 +182,35 @@ export function collectKeyedJsxFloats(tree, { tagName, chapter }) {
|
|
|
160
182
|
indexInSection,
|
|
161
183
|
node,
|
|
162
184
|
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
node.type === 'mdxJsxFlowElement' &&
|
|
189
|
+
node.name !== 'Memo' &&
|
|
190
|
+
node.name !== 'ShortNote'
|
|
191
|
+
) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!Array.isArray(node.children)) return;
|
|
195
|
+
for (const child of node.children) collectFromDetail(child);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const children = Array.isArray(tree?.children) ? tree.children : [];
|
|
199
|
+
for (const node of children) {
|
|
200
|
+
if (!node || typeof node !== 'object') continue;
|
|
201
|
+
if (node.type === 'heading' && node.depth === 2) {
|
|
202
|
+
section += 1;
|
|
203
|
+
indexInSection = 0;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (node.type === 'mdxJsxFlowElement' && node.name === tagName) {
|
|
207
|
+
collectFromDetail(node);
|
|
208
|
+
} else if (
|
|
209
|
+
node.type === 'mdxJsxFlowElement' &&
|
|
210
|
+
(node.name === 'Memo' || node.name === 'ShortNote')
|
|
211
|
+
) {
|
|
212
|
+
collectFromDetail(node);
|
|
163
213
|
}
|
|
164
214
|
}
|
|
165
215
|
return floats;
|
|
166
216
|
}
|
|
167
|
-
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Build-time identity and reference validation for knowledge checks. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
collectKnowledgeChecks,
|
|
5
|
+
KNOWLEDGE_CHECK_KEY_PATTERN,
|
|
6
|
+
parseKnowledgeCheckRef,
|
|
7
|
+
} from './knowledge-checks.mjs';
|
|
8
|
+
|
|
9
|
+
export interface KnowledgeCheckLessonInput {
|
|
10
|
+
readonly lessonId: string;
|
|
11
|
+
readonly slug: string;
|
|
12
|
+
readonly file: string;
|
|
13
|
+
readonly body: string;
|
|
14
|
+
readonly assessmentIds: readonly string[];
|
|
15
|
+
readonly numbering?: { readonly prefix: number; readonly offset: number };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface KnowledgeCheckLabelRecord {
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly number: string;
|
|
21
|
+
readonly lessonId: string;
|
|
22
|
+
readonly slug: string;
|
|
23
|
+
readonly indexInLesson: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface KnowledgeCheckDiagnostic {
|
|
27
|
+
readonly code:
|
|
28
|
+
| 'check-invalid-key'
|
|
29
|
+
| 'check-duplicate-key'
|
|
30
|
+
| 'check-ref-resolves';
|
|
31
|
+
readonly severity: 'error';
|
|
32
|
+
readonly message: string;
|
|
33
|
+
readonly file?: string;
|
|
34
|
+
readonly lessonId?: string;
|
|
35
|
+
readonly key?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface KnowledgeCheckValidation {
|
|
39
|
+
readonly labels: readonly KnowledgeCheckLabelRecord[];
|
|
40
|
+
readonly numbersBySlug: Record<string, Record<string, string>>;
|
|
41
|
+
readonly diagnostics: readonly KnowledgeCheckDiagnostic[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
45
|
+
|
|
46
|
+
function stripCode(text: string): string {
|
|
47
|
+
return text
|
|
48
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
49
|
+
.replace(/~~~[\s\S]*?~~~/g, ' ')
|
|
50
|
+
.replace(/`[^`\n]*`/g, ' ');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateKnowledgeChecks(
|
|
54
|
+
lessons: readonly KnowledgeCheckLessonInput[],
|
|
55
|
+
): KnowledgeCheckValidation {
|
|
56
|
+
const labels: KnowledgeCheckLabelRecord[] = [];
|
|
57
|
+
const diagnostics: KnowledgeCheckDiagnostic[] = [];
|
|
58
|
+
const numbersBySlug: Record<string, Record<string, string>> = {};
|
|
59
|
+
|
|
60
|
+
for (const lesson of [...lessons].sort((a, b) =>
|
|
61
|
+
a.lessonId.localeCompare(b.lessonId),
|
|
62
|
+
)) {
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
const table: Record<string, string> = {};
|
|
65
|
+
for (const check of collectKnowledgeChecks(
|
|
66
|
+
lesson.assessmentIds,
|
|
67
|
+
lesson.numbering,
|
|
68
|
+
)) {
|
|
69
|
+
if (!KNOWLEDGE_CHECK_KEY_PATTERN.test(check.key)) {
|
|
70
|
+
diagnostics.push({
|
|
71
|
+
code: 'check-invalid-key',
|
|
72
|
+
severity: 'error',
|
|
73
|
+
message: `${lesson.lessonId} has a knowledge-check id "${check.key}" that is not a valid content key.`,
|
|
74
|
+
file: lesson.file,
|
|
75
|
+
lessonId: lesson.lessonId,
|
|
76
|
+
key: check.key,
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (seen.has(check.key)) {
|
|
81
|
+
diagnostics.push({
|
|
82
|
+
code: 'check-duplicate-key',
|
|
83
|
+
severity: 'error',
|
|
84
|
+
message: `${lesson.lessonId} lists knowledge check "${check.key}" twice; an assessment id is unique per lesson.`,
|
|
85
|
+
file: lesson.file,
|
|
86
|
+
lessonId: lesson.lessonId,
|
|
87
|
+
key: check.key,
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
seen.add(check.key);
|
|
92
|
+
table[check.key] = check.number;
|
|
93
|
+
labels.push({
|
|
94
|
+
...check,
|
|
95
|
+
lessonId: lesson.lessonId,
|
|
96
|
+
slug: lesson.slug,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
numbersBySlug[lesson.slug] = table;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const lesson of lessons) {
|
|
103
|
+
for (const match of stripCode(lesson.body).matchAll(BRACKET_REF)) {
|
|
104
|
+
const ref = parseKnowledgeCheckRef((match[1] ?? '').trim());
|
|
105
|
+
if (!ref) continue;
|
|
106
|
+
const targetSlug = ref.lesson ?? lesson.slug;
|
|
107
|
+
if (numbersBySlug[targetSlug]?.[ref.key] !== undefined) continue;
|
|
108
|
+
diagnostics.push({
|
|
109
|
+
code: 'check-ref-resolves',
|
|
110
|
+
severity: 'error',
|
|
111
|
+
message: ref.lesson
|
|
112
|
+
? `${lesson.lessonId} references [[${ref.lesson}#check-${ref.key}]] but that lesson has no knowledge check "${ref.key}"`
|
|
113
|
+
: `${lesson.lessonId} references [[check-${ref.key}]] but its .checks.yml does not list "${ref.key}"`,
|
|
114
|
+
file: lesson.file,
|
|
115
|
+
lessonId: lesson.lessonId,
|
|
116
|
+
key: ref.key,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
diagnostics.sort(
|
|
122
|
+
(a, b) =>
|
|
123
|
+
a.code.localeCompare(b.code) ||
|
|
124
|
+
(a.lessonId ?? '').localeCompare(b.lessonId ?? '') ||
|
|
125
|
+
(a.key ?? '').localeCompare(b.key ?? '') ||
|
|
126
|
+
a.message.localeCompare(b.message),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return { labels, numbersBySlug, diagnostics };
|
|
130
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable identity and numbering helpers for lesson knowledge checks.
|
|
3
|
+
*
|
|
4
|
+
* A course owns check titles and questions in `content/assessments/*.json`,
|
|
5
|
+
* then places their ids in a lesson's colocated `.checks.yml`. That ordered
|
|
6
|
+
* list is the check sequence: numbers run independently across each part,
|
|
7
|
+
* exactly like the equation/table/figure/diagram streams.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Assessment ids use the content key grammar, including optional dots. */
|
|
11
|
+
export const KNOWLEDGE_CHECK_KEY_PATTERN =
|
|
12
|
+
/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
13
|
+
|
|
14
|
+
/** `[[check-id]]`, or `[[lesson/slug#check-id]]` across pages. */
|
|
15
|
+
export function parseKnowledgeCheckRef(raw) {
|
|
16
|
+
const match =
|
|
17
|
+
/^(?:([a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*)#)?check-([a-z0-9]+(?:[.-][a-z0-9]+)*)$/.exec(
|
|
18
|
+
String(raw).trim(),
|
|
19
|
+
);
|
|
20
|
+
if (!match) return undefined;
|
|
21
|
+
return match[1] ? { lesson: match[1], key: match[2] } : { key: match[2] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The stable DOM fragment for a knowledge check. */
|
|
25
|
+
export function knowledgeCheckAnchorId(key) {
|
|
26
|
+
return `check-${key}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Number an ordered assessment-id list. With manifest placement, the number
|
|
31
|
+
* is `part.offset + index`; without it, the list remains usefully page-local.
|
|
32
|
+
*/
|
|
33
|
+
export function collectKnowledgeChecks(assessmentIds, numbering) {
|
|
34
|
+
const prefix = Number.isInteger(numbering?.prefix) && numbering.prefix > 0
|
|
35
|
+
? numbering.prefix
|
|
36
|
+
: undefined;
|
|
37
|
+
const offset = Number.isInteger(numbering?.offset) && numbering.offset >= 0
|
|
38
|
+
? numbering.offset
|
|
39
|
+
: 0;
|
|
40
|
+
return assessmentIds.map((key, index) => ({
|
|
41
|
+
key,
|
|
42
|
+
number: prefix == null ? String(index + 1) : `${prefix}.${offset + index + 1}`,
|
|
43
|
+
indexInLesson: index + 1,
|
|
44
|
+
}));
|
|
45
|
+
}
|