explico 0.8.0 → 0.11.1
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 +1257 -1
- package/README.md +60 -15
- package/dist/compiler/astro.d.ts +6 -1
- package/dist/compiler/cli.d.ts +46 -2
- package/dist/compiler/cli.js +70 -4
- package/dist/compiler/collections.d.ts +35 -2
- package/dist/compiler/collections.js +68 -5
- package/dist/compiler/course-config.d.ts +3 -194
- package/dist/compiler/course-config.js +78 -5
- package/dist/compiler/exports/standalone-html.d.ts +6 -1
- package/dist/compiler/knowledge-version.js +1 -1
- package/dist/compiler/lesson-derivation.d.ts +27 -10
- package/dist/compiler/lesson-derivation.js +23 -8
- package/dist/compiler/logging.d.ts +4 -1
- package/dist/compiler/logging.js +4 -1
- package/dist/compiler/manifest.d.ts +204 -31
- package/dist/compiler/manifest.js +270 -55
- package/dist/compiler/resolution-report.d.ts +6 -1
- package/dist/course-config-Dsnri9Tk.d.ts +464 -0
- package/dist/reference/base-accents.d.mts +26 -0
- package/dist/reference/base-accents.mjs +31 -0
- package/dist/reference/base-roman-labels.d.mts +2 -2
- package/dist/reference/base-roman-labels.mjs +2 -2
- package/dist/reference/consistency.d.ts +14 -2
- package/dist/reference/consistency.js +108 -4
- package/dist/reference/delimiter-size.d.ts +3 -2
- package/dist/reference/diagrams-validate.d.ts +7 -2
- package/dist/reference/diagrams-validate.js +9 -1
- package/dist/reference/equations-validate.d.ts +8 -5
- package/dist/reference/equations-validate.js +1 -0
- package/dist/reference/equations.d.mts +60 -31
- package/dist/reference/equations.mjs +83 -43
- package/dist/reference/examples-validate.d.ts +31 -0
- package/dist/reference/examples-validate.js +48 -0
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/figures-validate.js +4 -1
- package/dist/reference/folds-validate.d.ts +33 -0
- package/dist/reference/folds-validate.js +32 -0
- package/dist/reference/gate-abbreviations.d.ts +3 -1
- package/dist/reference/gate-abbreviations.js +1 -0
- package/dist/reference/gate-course-rules.d.ts +32 -0
- package/dist/reference/gate-course-rules.js +85 -0
- package/dist/reference/gate-heading-length.d.ts +5 -2
- package/dist/reference/gate-heading-length.js +45 -21
- package/dist/reference/gate-heading-structure.d.ts +41 -0
- package/dist/reference/gate-heading-structure.js +113 -0
- package/dist/reference/gate-math-punctuation.d.ts +71 -0
- package/dist/reference/gate-math-punctuation.js +251 -0
- package/dist/reference/gate-math.d.ts +22 -6
- package/dist/reference/gate-math.js +42 -8
- package/dist/reference/gate-part-pages.d.ts +50 -0
- package/dist/reference/gate-part-pages.js +118 -0
- package/dist/reference/gate-prose-notation.d.ts +21 -11
- package/dist/reference/gate-prose-notation.js +25 -7
- package/dist/reference/katex-delimiters.mjs +20 -7
- package/dist/reference/keyed-float-validate.d.ts +27 -10
- package/dist/reference/keyed-float-validate.js +15 -1
- package/dist/reference/keyed-jsx-floats.d.mts +11 -5
- package/dist/reference/keyed-jsx-floats.mjs +73 -20
- package/dist/reference/knowledge-checks-validate.d.ts +1 -1
- package/dist/reference/knowledge-checks.d.mts +3 -1
- package/dist/reference/knowledge-checks.mjs +9 -4
- package/dist/reference/lab-math-scope.d.ts +1 -0
- package/dist/reference/lab-math-slot.d.ts +34 -0
- package/dist/reference/lab-math-slot.js +6 -0
- package/dist/reference/math-glyphs.d.mts +80 -4
- package/dist/reference/math-glyphs.mjs +572 -37
- package/dist/reference/page-notation.d.ts +6 -1
- package/dist/reference/raw-floats-validate.js +1 -1
- package/dist/reference/raw-floats.mjs +1 -1
- package/dist/reference/rehype-heading-anchors.d.mts +14 -0
- package/dist/reference/rehype-heading-anchors.mjs +116 -17
- package/dist/reference/rehype-table-header-labels.d.mts +6 -1
- package/dist/reference/rehype-table-header-labels.mjs +21 -3
- package/dist/reference/remark-asides.d.mts +3 -0
- package/dist/reference/remark-asides.mjs +180 -0
- package/dist/reference/remark-memo.d.mts +3 -1
- package/dist/reference/remark-memo.mjs +42 -11
- package/dist/reference/remark-notation.d.mts +60 -10
- package/dist/reference/remark-notation.mjs +387 -36
- package/dist/reference/render-lab-math.d.ts +4 -26
- package/dist/reference/render-lab-math.js +4 -4
- package/dist/reference/sections-validate.d.ts +27 -0
- package/dist/reference/sections-validate.js +41 -0
- package/dist/reference/sections.d.mts +97 -0
- package/dist/reference/sections.mjs +306 -0
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +17 -1
- package/dist/reference/unit-words.d.ts +37 -0
- package/dist/reference/unit-words.js +170 -0
- package/dist/rules/catalog.d.ts +176 -0
- package/dist/rules/catalog.js +983 -0
- package/dist/rules/course-rules.d.ts +115 -0
- package/dist/rules/course-rules.js +292 -0
- package/dist/rules/course-rules.schema.json +138 -0
- package/dist/rules/in-effect.d.ts +53 -0
- package/dist/rules/in-effect.js +142 -0
- package/dist/rules/rules.schema.json +1072 -0
- package/dist/rules/settings.d.ts +3 -0
- package/dist/rules/settings.js +290 -0
- package/dist/rules/waivers.d.ts +53 -0
- package/dist/rules/waivers.js +132 -0
- package/dist/session/PreferenceStore.d.ts +5 -0
- package/dist/session/PreferenceStore.js +9 -0
- package/docs/VERSIONING.md +5 -3
- package/docs/authoring/README.md +232 -40
- package/docs/authoring/agents.md +55 -9
- package/docs/authoring/components.md +178 -22
- package/docs/authoring/migrating.md +1152 -2
- package/docs/authoring/notation.md +576 -322
- package/docs/authoring/prose.md +551 -0
- package/docs/authoring/retired.md +35 -0
- package/docs/authoring/structure-rules.md +422 -40
- package/docs/authoring/structure.md +445 -62
- package/docs/authoring/versioning.md +20 -28
- package/docs/authoring/visualizations.md +142 -20
- package/package.json +2 -2
- package/src/compiler/cli.ts +116 -6
- package/src/compiler/collections.ts +111 -7
- package/src/compiler/course-config.ts +273 -34
- package/src/compiler/knowledge-version.ts +1 -1
- package/src/compiler/lesson-derivation.ts +54 -15
- package/src/compiler/logging.ts +8 -2
- package/src/compiler/manifest.ts +573 -104
- package/src/components/BlockHeading.astro +113 -0
- package/src/components/CourseContents.astro +221 -0
- package/src/components/CurriculumMap.astro +5 -6
- package/src/components/FloatIndex.astro +200 -0
- package/src/components/HoverPanel.astro +431 -79
- package/src/components/PartBridge.astro +27 -0
- package/src/components/PartOverview.astro +401 -0
- package/src/components/PartScope.astro +27 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
- package/src/components/assessments/AssessmentRunner.tsx +8 -5
- package/src/components/assessments/AssessmentSet.astro +23 -26
- package/src/components/block-heading.ts +146 -0
- package/src/components/card-arrow.ts +243 -0
- package/src/components/card-drag.ts +631 -0
- package/src/components/card-jump.ts +45 -0
- package/src/components/card-manager.ts +1589 -0
- package/src/components/card-memory.ts +177 -0
- package/src/components/card-placement.ts +504 -0
- package/src/components/card-side.ts +336 -0
- package/src/components/card-travel.ts +197 -0
- package/src/components/citation/CitationLayer.astro +77 -76
- package/src/components/diagram/Diagram.astro +161 -316
- package/src/components/disclosure/course-disclosure.ts +24 -0
- package/src/components/disclosure/hover-preview.ts +122 -42
- package/src/components/disclosure/persisted-disclosure.ts +3 -3
- package/src/components/disclosure/print-as-shown.ts +252 -0
- package/src/components/examples/CompactExample.astro +3 -1
- package/src/components/examples/CompactExamples.astro +101 -94
- package/src/components/figure/Figure.astro +174 -274
- package/src/components/figure/FloatCaption.astro +87 -0
- package/src/components/figure/figure-state.ts +58 -0
- package/src/components/figure/figure-tools.ts +87 -0
- package/src/components/float-name.ts +101 -0
- package/src/components/fold/Fold.astro +125 -0
- package/src/components/fold/fold-id.ts +40 -0
- package/src/components/memo/Memo.astro +162 -231
- package/src/components/memo/ShortNote.astro +6 -6
- package/src/components/notation/NotationFormula.astro +5 -1
- package/src/components/notation/NotationGlossary.astro +3 -4
- package/src/components/notation/NotationLayer.astro +169 -162
- package/src/components/notation/NotationMath.astro +10 -1
- package/src/components/reference/FloatRefLayer.astro +485 -423
- package/src/components/starlight/AboutPanel.astro +5 -4
- package/src/components/starlight/CollapsibleSections.astro +207 -93
- package/src/components/starlight/DiagramEnhancer.astro +15 -138
- package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
- package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
- package/src/components/starlight/LayoutHeader.astro +298 -19
- package/src/components/starlight/LayoutPageSidebar.astro +99 -12
- package/src/components/starlight/LayoutPageTitle.astro +120 -0
- package/src/components/starlight/LayoutSidebar.astro +229 -11
- package/src/components/starlight/LessonExportActions.astro +53 -72
- package/src/components/starlight/LessonFooter.astro +26 -4
- package/src/components/starlight/section-parts.ts +15 -2
- package/src/components/starlight/section-summary.ts +181 -0
- package/src/components/table/Table.astro +95 -76
- package/src/components/table/TableOverflowExpander.astro +65 -28
- package/src/content-config.ts +40 -4
- package/src/reference/base-accents.mjs +31 -0
- package/src/reference/base-roman-labels.mjs +2 -2
- package/src/reference/consistency.ts +176 -5
- package/src/reference/delimiter-size.ts +3 -2
- package/src/reference/diagrams-validate.ts +17 -1
- package/src/reference/equations-validate.ts +10 -5
- package/src/reference/equations.mjs +83 -43
- package/src/reference/examples-validate.ts +97 -0
- package/src/reference/figures-validate.ts +6 -1
- package/src/reference/folds-validate.ts +65 -0
- package/src/reference/gate-abbreviations.ts +1 -0
- package/src/reference/gate-course-rules.ts +134 -0
- package/src/reference/gate-heading-length.ts +66 -35
- package/src/reference/gate-heading-structure.ts +193 -0
- package/src/reference/gate-math-punctuation.ts +381 -0
- package/src/reference/gate-math.ts +69 -10
- package/src/reference/gate-part-pages.ts +217 -0
- package/src/reference/gate-prose-notation.ts +59 -12
- package/src/reference/katex-delimiters.mjs +20 -7
- package/src/reference/keyed-float-validate.ts +45 -11
- package/src/reference/keyed-jsx-floats.mjs +73 -20
- package/src/reference/knowledge-checks-validate.ts +1 -1
- package/src/reference/knowledge-checks.mjs +9 -4
- package/src/reference/lab-math-slot.ts +36 -0
- package/src/reference/math-glyphs.mjs +572 -37
- package/src/reference/raw-floats-validate.ts +1 -1
- package/src/reference/raw-floats.mjs +1 -1
- package/src/reference/rehype-heading-anchors.mjs +116 -17
- package/src/reference/rehype-table-header-labels.mjs +21 -3
- package/src/reference/remark-asides.mjs +180 -0
- package/src/reference/remark-memo.mjs +42 -11
- package/src/reference/remark-notation.mjs +387 -36
- package/src/reference/render-lab-math.ts +16 -31
- package/src/reference/sections-validate.ts +81 -0
- package/src/reference/sections.mjs +306 -0
- package/src/reference/tables-validate.ts +2 -1
- package/src/reference/types.ts +17 -0
- package/src/reference/unit-words.ts +233 -0
- package/src/rules/catalog.ts +1298 -0
- package/src/rules/course-rules.ts +456 -0
- package/src/rules/in-effect.ts +235 -0
- package/src/rules/settings.ts +448 -0
- package/src/rules/waivers.ts +249 -0
- package/src/session/PreferenceStore.ts +15 -0
- package/src/styles/global.css +1467 -163
- package/src/styles/surfaces.css +182 -0
- package/src/components/hover-panel.ts +0 -339
- package/src/components/popup-stack.ts +0 -345
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The part-page gate (rule S2): a part's overview page
|
|
3
|
+
* (`content/docs/<dir>/index.mdx`) states, in two authored paragraphs, how
|
|
4
|
+
* the course got here and where this part goes —
|
|
5
|
+
*
|
|
6
|
+
* - `<PartBridge>` — how the earlier parts lead into this one. Required on
|
|
7
|
+
* every part after the first; optional on the first, which has no earlier
|
|
8
|
+
* part to link from.
|
|
9
|
+
* - `<PartScope>` — what this part itself covers. Required on every part page.
|
|
10
|
+
*
|
|
11
|
+
* Each must appear once, hold exactly one paragraph, and fall inside the
|
|
12
|
+
* course's rule-S2 word range (`rules.S2.*`, see `partPageLimits`); the bridge comes
|
|
13
|
+
* first. What a paragraph *says* is Review-only — the gate checks that the
|
|
14
|
+
* slot is there and sized, not that its prose is about the right thing. See
|
|
15
|
+
* docs/authoring/structure-rules.md#s2.
|
|
16
|
+
*
|
|
17
|
+
* A part without an overview page is not in scope: overview pages stay
|
|
18
|
+
* opt-in, and this gate only shapes the ones a course authors. Ships straight
|
|
19
|
+
* as an error, same as S1.
|
|
20
|
+
*/
|
|
21
|
+
import type { NormalizedPartPages } from '../compiler/course-config';
|
|
22
|
+
import { stripFencedCode } from './equations.mjs';
|
|
23
|
+
import type { MathGateDiagnostic } from './gate-math';
|
|
24
|
+
import { lessonLine } from './gate-prose-notation';
|
|
25
|
+
|
|
26
|
+
/** One part overview page, as the manifest compiler loads it. */
|
|
27
|
+
export interface PartPageInput {
|
|
28
|
+
readonly dir: string;
|
|
29
|
+
/** 1-based position in `courseConfig.parts`. */
|
|
30
|
+
readonly ordinal: number;
|
|
31
|
+
readonly file: string;
|
|
32
|
+
readonly body: string;
|
|
33
|
+
readonly bodyLine: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Slot = 'bridge' | 'scope';
|
|
37
|
+
|
|
38
|
+
const TAG_BY_SLOT: Record<Slot, string> = {
|
|
39
|
+
bridge: 'PartBridge',
|
|
40
|
+
scope: 'PartScope',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const SUBJECT_BY_SLOT: Record<Slot, string> = {
|
|
44
|
+
bridge: 'how the earlier parts lead into this one',
|
|
45
|
+
scope: 'what this part covers',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
interface Occurrence {
|
|
49
|
+
readonly index: number;
|
|
50
|
+
/** `undefined` for a self-closing `<PartBridge />`. */
|
|
51
|
+
readonly content: string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Blank MDX comments (`{/* … *\/}`) and code, keeping every newline. */
|
|
55
|
+
function scannable(body: string): string {
|
|
56
|
+
return stripFencedCode(body).replace(/\{\/\*[\s\S]*?\*\/\}/g, (comment) =>
|
|
57
|
+
comment.replace(/[^\n]/g, ' '),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function occurrencesOf(text: string, tag: string): Occurrence[] {
|
|
62
|
+
const pattern = new RegExp(
|
|
63
|
+
`<${tag}\\b[^>]*?(?:\\/>|>([\\s\\S]*?)<\\/${tag}\\s*>)`,
|
|
64
|
+
'g',
|
|
65
|
+
);
|
|
66
|
+
return [...text.matchAll(pattern)].map((match) => ({
|
|
67
|
+
index: match.index ?? 0,
|
|
68
|
+
content: match[1],
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The blank-line-separated blocks of a slot's content. */
|
|
73
|
+
function paragraphsOf(content: string): string[] {
|
|
74
|
+
return content
|
|
75
|
+
.trim()
|
|
76
|
+
.split(/\n[ \t]*\n/)
|
|
77
|
+
.filter((block) => block.trim().length > 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Words a reader reads in a paragraph of MDX prose. A math span, a `[[key]]`
|
|
82
|
+
* reference, or a link each count by what they render as: one token for the
|
|
83
|
+
* first two, the link text for the third. Citations and markup count nothing.
|
|
84
|
+
*/
|
|
85
|
+
export function proseWordCount(markdown: string): number {
|
|
86
|
+
return markdown
|
|
87
|
+
.replace(/\$\$[\s\S]*?\$\$/g, ' math ')
|
|
88
|
+
.replace(/\$[^$\n]+\$/g, ' math ')
|
|
89
|
+
.replace(/\[@[^\]]*\]/g, ' ')
|
|
90
|
+
.replace(/\[\[[^\][]+\]\]/g, ' term ')
|
|
91
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, ' $1 ')
|
|
92
|
+
.replace(/<\/?[A-Za-z][^>]*>/g, ' ')
|
|
93
|
+
.split(/\s+/)
|
|
94
|
+
.filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function slotFindings(
|
|
98
|
+
page: PartPageInput,
|
|
99
|
+
text: string,
|
|
100
|
+
slot: Slot,
|
|
101
|
+
limits: NormalizedPartPages,
|
|
102
|
+
): { index: number; token: string; message: string }[] {
|
|
103
|
+
const tag = TAG_BY_SLOT[slot];
|
|
104
|
+
const found = occurrencesOf(text, tag);
|
|
105
|
+
const required = slot === 'scope' || page.ordinal > 1;
|
|
106
|
+
const out: { index: number; token: string; message: string }[] = [];
|
|
107
|
+
|
|
108
|
+
if (found.length === 0) {
|
|
109
|
+
if (required) {
|
|
110
|
+
out.push({
|
|
111
|
+
index: 0,
|
|
112
|
+
token: `<${tag}>`,
|
|
113
|
+
message:
|
|
114
|
+
`Part ${page.ordinal} ("${page.dir}") has an overview page with no ` +
|
|
115
|
+
`<${tag}> paragraph (rule S2). Add one paragraph on ` +
|
|
116
|
+
`${SUBJECT_BY_SLOT[slot]}, wrapped in <${tag}>…</${tag}>.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const extra of found.slice(1)) {
|
|
123
|
+
out.push({
|
|
124
|
+
index: extra.index,
|
|
125
|
+
token: `<${tag}>`,
|
|
126
|
+
message:
|
|
127
|
+
`A part overview page carries one <${tag}> (rule S2); this is ` +
|
|
128
|
+
'another. Merge the two into one paragraph.',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const [first] = found;
|
|
133
|
+
const paragraphs = paragraphsOf(first.content ?? '');
|
|
134
|
+
if (paragraphs.length === 0) {
|
|
135
|
+
out.push({
|
|
136
|
+
index: first.index,
|
|
137
|
+
token: `<${tag}>`,
|
|
138
|
+
message:
|
|
139
|
+
`<${tag}> is empty (rule S2). Write one paragraph on ` +
|
|
140
|
+
`${SUBJECT_BY_SLOT[slot]} inside it.`,
|
|
141
|
+
});
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
if (paragraphs.length > 1) {
|
|
145
|
+
out.push({
|
|
146
|
+
index: first.index,
|
|
147
|
+
token: `<${tag}>`,
|
|
148
|
+
message:
|
|
149
|
+
`<${tag}> holds ${paragraphs.length} paragraphs; rule S2 allows one. ` +
|
|
150
|
+
'Join them, or move the rest of the prose outside the slot.',
|
|
151
|
+
});
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { minWords, maxWords } = limits[slot];
|
|
156
|
+
const words = proseWordCount(paragraphs[0]);
|
|
157
|
+
if (words < minWords || words > maxWords) {
|
|
158
|
+
out.push({
|
|
159
|
+
index: first.index,
|
|
160
|
+
token: `<${tag}>`,
|
|
161
|
+
message:
|
|
162
|
+
`<${tag}> is ${words} words; rule S2 wants ${minWords}–${maxWords} ` +
|
|
163
|
+
`(rules.S2.${slot}MinWords / ${slot}MaxWords in the course settings). ` +
|
|
164
|
+
(words < minWords
|
|
165
|
+
? `Say more about ${SUBJECT_BY_SLOT[slot]}.`
|
|
166
|
+
: 'Cut it to the connections a reader needs before the chapter list.'),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check every part overview page for its S2 paragraphs. Returns one
|
|
174
|
+
* `MathGateDiagnostic` (context `'structure'`, code `part-page-paragraph`,
|
|
175
|
+
* always an error) per finding.
|
|
176
|
+
*/
|
|
177
|
+
export function gatePartPages(
|
|
178
|
+
pages: readonly PartPageInput[],
|
|
179
|
+
limits: NormalizedPartPages,
|
|
180
|
+
): MathGateDiagnostic[] {
|
|
181
|
+
const diagnostics: MathGateDiagnostic[] = [];
|
|
182
|
+
|
|
183
|
+
for (const page of pages) {
|
|
184
|
+
const text = scannable(page.body);
|
|
185
|
+
const findings = [
|
|
186
|
+
...slotFindings(page, text, 'bridge', limits),
|
|
187
|
+
...slotFindings(page, text, 'scope', limits),
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
const [bridge] = occurrencesOf(text, TAG_BY_SLOT.bridge);
|
|
191
|
+
const [scope] = occurrencesOf(text, TAG_BY_SLOT.scope);
|
|
192
|
+
if (bridge && scope && bridge.index > scope.index) {
|
|
193
|
+
findings.push({
|
|
194
|
+
index: bridge.index,
|
|
195
|
+
token: '<PartBridge>',
|
|
196
|
+
message:
|
|
197
|
+
'<PartBridge> comes after <PartScope> (rule S2). Move it first: ' +
|
|
198
|
+
'the reader needs the link from the earlier parts before this ' +
|
|
199
|
+
"part's own scope.",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const finding of findings.sort((a, b) => a.index - b.index)) {
|
|
204
|
+
diagnostics.push({
|
|
205
|
+
context: 'structure',
|
|
206
|
+
severity: 'error',
|
|
207
|
+
code: 'part-page-paragraph',
|
|
208
|
+
file: page.file,
|
|
209
|
+
line: lessonLine(page, finding.index),
|
|
210
|
+
token: finding.token,
|
|
211
|
+
message: finding.message,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return diagnostics;
|
|
217
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* completeness gate (`gate-math.ts`). Where that gate asks "does every glyph
|
|
4
4
|
* *inside* `$…$` resolve", this one asks the complementary question: "is
|
|
5
5
|
* anything that reads as math sitting *outside* `$…$` in the first place" —
|
|
6
|
-
* a unit abbreviation, a
|
|
6
|
+
* a unit abbreviation, a unit spelled out as a word next to a number or
|
|
7
|
+
* `per` (`unit-words.ts`), a division of two variable-like symbols, an escaped
|
|
7
8
|
* absolute-value bar, a hand-typed sub/superscript. Both gates share one
|
|
8
9
|
* `MathGateDiagnostic` shape and one output array (`manifest.diagnostics.math`)
|
|
9
10
|
* so the CLI needs no separate wiring.
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
*/
|
|
20
21
|
import type { MathGateDiagnostic } from './gate-math';
|
|
21
22
|
import type { NotationLessonInput } from './types';
|
|
23
|
+
import { findSpelledUnits } from './unit-words';
|
|
22
24
|
|
|
23
25
|
interface AssessmentItemLike {
|
|
24
26
|
readonly id?: string;
|
|
@@ -54,13 +56,22 @@ const JSX_TAG = /<\/?[A-Za-z][^<>]*>/g;
|
|
|
54
56
|
* really prose" surface as N9, instead of drifting with a second copy of
|
|
55
57
|
* these regexes.
|
|
56
58
|
*/
|
|
57
|
-
|
|
59
|
+
/** `{/* … *\/}` — an MDX comment never renders, so its text is not prose. */
|
|
60
|
+
const MDX_COMMENT = /\{\/\*[\s\S]*?\*\/\}/g;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `keepMath` leaves `$…$` / `$$…$$` in place (everything else still blanked,
|
|
64
|
+
* offsets unchanged) — for a check that needs to see what a math span sits
|
|
65
|
+
* next to, such as `$x = 3$ meters`.
|
|
66
|
+
*/
|
|
67
|
+
export function proseOnly(body: string, options: { readonly keepMath?: boolean } = {}): string {
|
|
58
68
|
const blank = (match: string) => match.replace(/[^\n]/g, ' ');
|
|
59
|
-
|
|
69
|
+
const code = body
|
|
70
|
+
.replace(MDX_COMMENT, blank)
|
|
60
71
|
.replace(CODE_FENCE, blank)
|
|
61
|
-
.replace(INLINE_CODE, (match, lead: string) => lead + blank(match.slice(lead.length)))
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
.replace(INLINE_CODE, (match, lead: string) => lead + blank(match.slice(lead.length)));
|
|
73
|
+
const math = options.keepMath ? code : code.replace(DISPLAY_MATH, blank).replace(INLINE_MATH, blank);
|
|
74
|
+
return math
|
|
64
75
|
.replace(IMPORT_LINE, blank)
|
|
65
76
|
.replace(KEY_REFERENCE, blank)
|
|
66
77
|
.replace(JSX_TAG, blank);
|
|
@@ -122,10 +133,21 @@ const SLASH_TOKENS = /\b([A-Za-z]{1,6})\/([A-Za-z]{1,6})\b/g;
|
|
|
122
133
|
// hand-typed sub/superscript that was never wrapped in `$…$`.
|
|
123
134
|
const FAKE_SCRIPT = /[A-Za-z0-9][_^][A-Za-z0-9{]/g;
|
|
124
135
|
|
|
136
|
+
/** Every N9 finding that is not a spelled-out unit. */
|
|
137
|
+
const PROSE_NOTATION_CODE = 'prose-notation' as const;
|
|
138
|
+
|
|
125
139
|
interface Finding {
|
|
126
140
|
readonly index: number;
|
|
127
141
|
readonly token: string;
|
|
128
142
|
readonly message: string;
|
|
143
|
+
/** `spelled-unit` for a unit written as a word; `prose-notation` otherwise. */
|
|
144
|
+
readonly code?: 'spelled-unit';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Rule N9's settings the gate reads (`rules.N9` in the course settings). */
|
|
148
|
+
export interface ProseNotationOptions {
|
|
149
|
+
/** Unit words this course lets prose spell out (`rules.N9.allow`), singular or plural. */
|
|
150
|
+
readonly allowSpelledUnits?: readonly string[];
|
|
129
151
|
}
|
|
130
152
|
|
|
131
153
|
function findAll(text: string, pattern: RegExp, toFinding: (m: RegExpExecArray) => Finding): Finding[] {
|
|
@@ -141,9 +163,25 @@ function findAll(text: string, pattern: RegExp, toFinding: (m: RegExpExecArray)
|
|
|
141
163
|
|
|
142
164
|
const FIX = 'Wrap it in $…$ (or $$…$$) instead — see docs/authoring/notation.md#n9.';
|
|
143
165
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Every N9 finding in one text: `text` is `proseOnly(raw)`, `withMath` is
|
|
168
|
+
* `proseOnly(raw, { keepMath: true })` (same offsets).
|
|
169
|
+
*/
|
|
170
|
+
function findingsIn(text: string, withMath: string, options: ProseNotationOptions): Finding[] {
|
|
171
|
+
const spelled = findSpelledUnits(text, withMath, options.allowSpelledUnits).map(
|
|
172
|
+
(unit): Finding => ({
|
|
173
|
+
index: unit.index,
|
|
174
|
+
token: unit.token,
|
|
175
|
+
code: 'spelled-unit',
|
|
176
|
+
message:
|
|
177
|
+
`Spelled-out unit ${JSON.stringify(unit.token)}: write it as ${unit.suggestion}. ` +
|
|
178
|
+
'A unit that quantifies something is an upright symbol inside $…$, even in flowing prose. ' +
|
|
179
|
+
'Where the sentence defines or names the unit itself, waive the block with ' +
|
|
180
|
+
'{/* explico-allow N9: reason */} — see docs/authoring/notation.md#n9.',
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
146
183
|
return [
|
|
184
|
+
...spelled,
|
|
147
185
|
...findAll(text, /\\\|/g, (m) => ({
|
|
148
186
|
index: m.index,
|
|
149
187
|
token: m[0],
|
|
@@ -177,19 +215,25 @@ function findingsIn(text: string): Finding[] {
|
|
|
177
215
|
|
|
178
216
|
/**
|
|
179
217
|
* Scan every lesson body for plain-text math notation outside `$…$`. Returns
|
|
180
|
-
* one `MathGateDiagnostic` (context `'prose'`, always an error
|
|
181
|
-
*
|
|
218
|
+
* one `MathGateDiagnostic` (context `'prose'`, always an error) per finding:
|
|
219
|
+
* code `spelled-unit` for a unit written as a word ("2 meters per second
|
|
220
|
+
* squared", "30 degrees"), `prose-notation` for everything else. A place is
|
|
221
|
+
* excused with a waiver (`{/* explico-allow N9: reason *\/}`), applied by the
|
|
222
|
+
* manifest.
|
|
182
223
|
*/
|
|
183
224
|
export function gateProseNotation(
|
|
184
225
|
lessons: readonly NotationLessonInput[],
|
|
226
|
+
options: ProseNotationOptions = {},
|
|
185
227
|
): MathGateDiagnostic[] {
|
|
186
228
|
const diagnostics: MathGateDiagnostic[] = [];
|
|
187
229
|
|
|
188
230
|
for (const lesson of lessons) {
|
|
189
231
|
const text = proseOnly(lesson.body);
|
|
190
|
-
|
|
232
|
+
const withMath = proseOnly(lesson.body, { keepMath: true });
|
|
233
|
+
for (const finding of findingsIn(text, withMath, options)) {
|
|
191
234
|
diagnostics.push({
|
|
192
235
|
context: 'prose',
|
|
236
|
+
code: finding.code ?? PROSE_NOTATION_CODE,
|
|
193
237
|
severity: 'error',
|
|
194
238
|
file: lesson.source.file,
|
|
195
239
|
line: lessonLine(lesson, finding.index),
|
|
@@ -214,6 +258,7 @@ export function gateProseNotation(
|
|
|
214
258
|
*/
|
|
215
259
|
export function gateProseNotationInAssessments(
|
|
216
260
|
assessments: readonly AssessmentLike[],
|
|
261
|
+
options: ProseNotationOptions = {},
|
|
217
262
|
): MathGateDiagnostic[] {
|
|
218
263
|
const diagnostics: MathGateDiagnostic[] = [];
|
|
219
264
|
|
|
@@ -221,9 +266,11 @@ export function gateProseNotationInAssessments(
|
|
|
221
266
|
for (const item of assessment.items ?? []) {
|
|
222
267
|
for (const field of [item.prompt, item.explanation]) {
|
|
223
268
|
if (typeof field !== 'string') continue;
|
|
224
|
-
|
|
269
|
+
const withMath = proseOnly(field, { keepMath: true });
|
|
270
|
+
for (const finding of findingsIn(proseOnly(field), withMath, options)) {
|
|
225
271
|
diagnostics.push({
|
|
226
272
|
context: 'prose',
|
|
273
|
+
code: finding.code ?? PROSE_NOTATION_CODE,
|
|
227
274
|
severity: 'error',
|
|
228
275
|
file: `${assessment.id} (${item.id ?? '?'})`,
|
|
229
276
|
token: finding.token,
|
|
@@ -32,8 +32,6 @@
|
|
|
32
32
|
* increase. A course therefore either renders with engine-sized delimiters or
|
|
33
33
|
* fails its build saying so — it cannot quietly get the stock behaviour.
|
|
34
34
|
*/
|
|
35
|
-
import { readFileSync } from 'node:fs';
|
|
36
|
-
import { createRequire } from 'node:module';
|
|
37
35
|
import katex from 'katex';
|
|
38
36
|
|
|
39
37
|
/** TeX's `\delimitershortfall`, in points, as KaTeX hard-codes it. */
|
|
@@ -78,12 +76,21 @@ export function rewriteBundle(source) {
|
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
async function loadGrownKatex() {
|
|
79
|
+
// Imported here, not at the top: should a browser graph ever reach this
|
|
80
|
+
// module (a lab island once did, through `render-lab-math.ts`; islands now
|
|
81
|
+
// import `lab-math-slot.ts`), Vite's `node:fs` stub throws on first access —
|
|
82
|
+
// which, for a static named import, is module evaluation itself, so the
|
|
83
|
+
// island would never hydrate.
|
|
84
|
+
const { readFileSync } = await import('node:fs');
|
|
85
|
+
const { createRequire } = await import('node:module');
|
|
81
86
|
const require = createRequire(import.meta.url);
|
|
82
87
|
const source = readFileSync(require.resolve('katex/dist/katex.mjs'), 'utf8');
|
|
83
88
|
// The bundle is self-contained, so it can be re-imported from a data URL
|
|
84
|
-
// with no base to resolve relative specifiers against.
|
|
89
|
+
// with no base to resolve relative specifiers against. The URL is built at
|
|
90
|
+
// run time on purpose, so Vite has nothing to analyze here.
|
|
85
91
|
const rewritten = rewriteBundle(source);
|
|
86
92
|
const module = await import(
|
|
93
|
+
/* @vite-ignore */
|
|
87
94
|
`data:text/javascript;base64,${Buffer.from(rewritten).toString('base64')}`
|
|
88
95
|
);
|
|
89
96
|
return module.default;
|
|
@@ -116,10 +123,16 @@ export function assertDelimiterGrowth(instance = katex) {
|
|
|
116
123
|
}
|
|
117
124
|
}
|
|
118
125
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
// Build and server rendering only. In a browser there is no KaTeX bundle on
|
|
127
|
+
// disk to rewrite. No client graph should include this module (lab math is
|
|
128
|
+
// rendered on the server); if one does, a client-side render falls back to
|
|
129
|
+
// stock sizing rather than breaking the island.
|
|
130
|
+
if (typeof window === 'undefined') {
|
|
131
|
+
const grown = await loadGrownKatex();
|
|
132
|
+
for (const name of RENDER_EXPORTS) {
|
|
133
|
+
if (typeof grown[name] === 'function') katex[name] = grown[name];
|
|
134
|
+
}
|
|
135
|
+
assertDelimiterGrowth();
|
|
122
136
|
}
|
|
123
|
-
assertDelimiterGrowth();
|
|
124
137
|
|
|
125
138
|
export default katex;
|
|
@@ -8,11 +8,18 @@
|
|
|
8
8
|
* - `<prefix>-ref-resolves` (error): every `[[<prefix>-key]]` /
|
|
9
9
|
* `[[slug#<prefix>-key]]` points at the matching keyed float with that `id`.
|
|
10
10
|
* - `<prefix>-key-unused` (info): a keyed float is never referenced.
|
|
11
|
+
* - `<prefix>-removed-prop` (error): the tag sets a prop that no longer
|
|
12
|
+
* exists. Mostly a name prop (`<Figure title>`, `<Diagram label>`) — every
|
|
13
|
+
* float is named by the one caption pair, `caption` (long) and `short`
|
|
14
|
+
* (optional), and a removed prop would otherwise render nothing and escape
|
|
15
|
+
* rule S1 — and a diagram's compaction props (`defaultOpen`,
|
|
16
|
+
* `rememberState`), since a diagram no longer folds by itself.
|
|
11
17
|
*
|
|
12
|
-
* `tables-validate.ts`, `figures-validate.ts`,
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* `tables-validate.ts`, `figures-validate.ts`, `diagrams-validate.ts` and
|
|
19
|
+
* `examples-validate.ts` (`<CompactExamples>`, which adds its required-prop
|
|
20
|
+
* check) are thin wrappers over this so callers import a kind-specific
|
|
21
|
+
* validator with narrow diagnostic-code types, while `equations-validate.ts`
|
|
22
|
+
* stays untouched — equations predate this and keep their own dedicated path.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import {
|
|
@@ -28,11 +35,20 @@ export interface KeyedFloatKind {
|
|
|
28
35
|
readonly prefix: string;
|
|
29
36
|
/** Lowercase noun used in diagnostic messages, e.g. `table` or `figure`. */
|
|
30
37
|
readonly noun: string;
|
|
38
|
+
/**
|
|
39
|
+
* Name props this kind no longer takes, each with the fix its diagnostic
|
|
40
|
+
* gives, e.g. `{ title: 'use \`caption\` …' }`. Omitted: none.
|
|
41
|
+
*/
|
|
42
|
+
readonly removedProps?: Readonly<Record<string, string>>;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
export interface KeyedFloatLabelRecord {
|
|
34
46
|
readonly key: string;
|
|
35
47
|
readonly number: string;
|
|
48
|
+
/** The float's literal `short` caption, when it has one. */
|
|
49
|
+
readonly short?: string;
|
|
50
|
+
/** The float's literal `caption`; absent when it is an expression. */
|
|
51
|
+
readonly caption?: string;
|
|
36
52
|
readonly lessonId: string;
|
|
37
53
|
readonly slug: string;
|
|
38
54
|
readonly section: number;
|
|
@@ -69,13 +85,14 @@ export interface KeyedFloatLessonInput {
|
|
|
69
85
|
readonly file: string;
|
|
70
86
|
readonly body: string;
|
|
71
87
|
/**
|
|
72
|
-
* `{ prefix, offset }` — the
|
|
73
|
-
*
|
|
74
|
-
* When set, floats are numbered
|
|
75
|
-
*
|
|
76
|
-
* passes a different `offset`
|
|
88
|
+
* `{ prefix, offset }` — the lesson's float-number prefix (`‹part›.‹chapter›`,
|
|
89
|
+
* or `‹part›` under `numbering.floats: "part"`) and how many floats of this
|
|
90
|
+
* kind precede the lesson in that scope. When set, floats are numbered
|
|
91
|
+
* `‹prefix›.‹offset + n›`; when omitted, the legacy `##`-section scheme.
|
|
92
|
+
* Each kind counts on its own, so the caller passes a different `offset`
|
|
93
|
+
* for `<Table>` than for `<Figure>`.
|
|
77
94
|
*/
|
|
78
|
-
readonly numbering?: { readonly prefix: number; readonly offset: number };
|
|
95
|
+
readonly numbering?: { readonly prefix: number | string; readonly offset: number };
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
@@ -108,7 +125,7 @@ export function validateKeyedFloats(
|
|
|
108
125
|
lessons: readonly KeyedFloatLessonInput[],
|
|
109
126
|
kind: KeyedFloatKind,
|
|
110
127
|
): KeyedFloatValidation {
|
|
111
|
-
const { tagName, prefix, noun } = kind;
|
|
128
|
+
const { tagName, prefix, noun, removedProps = {} } = kind;
|
|
112
129
|
const labels: KeyedFloatLabelRecord[] = [];
|
|
113
130
|
const diagnostics: KeyedFloatDiagnostic[] = [];
|
|
114
131
|
const numbersBySlug: Record<string, Record<string, string>> = {};
|
|
@@ -124,9 +141,24 @@ export function validateKeyedFloats(
|
|
|
124
141
|
}) as {
|
|
125
142
|
key: string | null;
|
|
126
143
|
number: string;
|
|
144
|
+
short: string | null;
|
|
145
|
+
caption: string | null;
|
|
146
|
+
props: string[];
|
|
127
147
|
section: number;
|
|
128
148
|
indexInSection: number;
|
|
129
149
|
}[]) {
|
|
150
|
+
for (const prop of raw.props) {
|
|
151
|
+
const fix = removedProps[prop];
|
|
152
|
+
if (fix === undefined) continue;
|
|
153
|
+
diagnostics.push({
|
|
154
|
+
code: `${prefix}-removed-prop`,
|
|
155
|
+
severity: 'error',
|
|
156
|
+
message: `${lesson.lessonId} has a <${tagName} ${prop}="…">${raw.key ? ` (id="${raw.key}")` : ''}, but <${tagName}> has no \`${prop}\` prop: ${fix}`,
|
|
157
|
+
file: lesson.file,
|
|
158
|
+
lessonId: lesson.lessonId,
|
|
159
|
+
...(raw.key ? { key: raw.key } : {}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
130
162
|
if (raw.key == null) continue;
|
|
131
163
|
if (!KEYED_FLOAT_KEY_PATTERN.test(raw.key)) {
|
|
132
164
|
diagnostics.push({
|
|
@@ -155,6 +187,8 @@ export function validateKeyedFloats(
|
|
|
155
187
|
labels.push({
|
|
156
188
|
key: raw.key,
|
|
157
189
|
number: raw.number,
|
|
190
|
+
...(raw.short ? { short: raw.short } : {}),
|
|
191
|
+
...(raw.caption ? { caption: raw.caption } : {}),
|
|
158
192
|
lessonId: lesson.lessonId,
|
|
159
193
|
slug: lesson.slug,
|
|
160
194
|
section: raw.section,
|