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.
Files changed (230) hide show
  1. package/CHANGELOG.md +1257 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +46 -2
  5. package/dist/compiler/cli.js +70 -4
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +68 -5
  8. package/dist/compiler/course-config.d.ts +3 -194
  9. package/dist/compiler/course-config.js +78 -5
  10. package/dist/compiler/exports/standalone-html.d.ts +6 -1
  11. package/dist/compiler/knowledge-version.js +1 -1
  12. package/dist/compiler/lesson-derivation.d.ts +27 -10
  13. package/dist/compiler/lesson-derivation.js +23 -8
  14. package/dist/compiler/logging.d.ts +4 -1
  15. package/dist/compiler/logging.js +4 -1
  16. package/dist/compiler/manifest.d.ts +204 -31
  17. package/dist/compiler/manifest.js +270 -55
  18. package/dist/compiler/resolution-report.d.ts +6 -1
  19. package/dist/course-config-Dsnri9Tk.d.ts +464 -0
  20. package/dist/reference/base-accents.d.mts +26 -0
  21. package/dist/reference/base-accents.mjs +31 -0
  22. package/dist/reference/base-roman-labels.d.mts +2 -2
  23. package/dist/reference/base-roman-labels.mjs +2 -2
  24. package/dist/reference/consistency.d.ts +14 -2
  25. package/dist/reference/consistency.js +108 -4
  26. package/dist/reference/delimiter-size.d.ts +3 -2
  27. package/dist/reference/diagrams-validate.d.ts +7 -2
  28. package/dist/reference/diagrams-validate.js +9 -1
  29. package/dist/reference/equations-validate.d.ts +8 -5
  30. package/dist/reference/equations-validate.js +1 -0
  31. package/dist/reference/equations.d.mts +60 -31
  32. package/dist/reference/equations.mjs +83 -43
  33. package/dist/reference/examples-validate.d.ts +31 -0
  34. package/dist/reference/examples-validate.js +48 -0
  35. package/dist/reference/figures-validate.d.ts +1 -1
  36. package/dist/reference/figures-validate.js +4 -1
  37. package/dist/reference/folds-validate.d.ts +33 -0
  38. package/dist/reference/folds-validate.js +32 -0
  39. package/dist/reference/gate-abbreviations.d.ts +3 -1
  40. package/dist/reference/gate-abbreviations.js +1 -0
  41. package/dist/reference/gate-course-rules.d.ts +32 -0
  42. package/dist/reference/gate-course-rules.js +85 -0
  43. package/dist/reference/gate-heading-length.d.ts +5 -2
  44. package/dist/reference/gate-heading-length.js +45 -21
  45. package/dist/reference/gate-heading-structure.d.ts +41 -0
  46. package/dist/reference/gate-heading-structure.js +113 -0
  47. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  48. package/dist/reference/gate-math-punctuation.js +251 -0
  49. package/dist/reference/gate-math.d.ts +22 -6
  50. package/dist/reference/gate-math.js +42 -8
  51. package/dist/reference/gate-part-pages.d.ts +50 -0
  52. package/dist/reference/gate-part-pages.js +118 -0
  53. package/dist/reference/gate-prose-notation.d.ts +21 -11
  54. package/dist/reference/gate-prose-notation.js +25 -7
  55. package/dist/reference/katex-delimiters.mjs +20 -7
  56. package/dist/reference/keyed-float-validate.d.ts +27 -10
  57. package/dist/reference/keyed-float-validate.js +15 -1
  58. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  59. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  60. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  61. package/dist/reference/knowledge-checks.d.mts +3 -1
  62. package/dist/reference/knowledge-checks.mjs +9 -4
  63. package/dist/reference/lab-math-scope.d.ts +1 -0
  64. package/dist/reference/lab-math-slot.d.ts +34 -0
  65. package/dist/reference/lab-math-slot.js +6 -0
  66. package/dist/reference/math-glyphs.d.mts +80 -4
  67. package/dist/reference/math-glyphs.mjs +572 -37
  68. package/dist/reference/page-notation.d.ts +6 -1
  69. package/dist/reference/raw-floats-validate.js +1 -1
  70. package/dist/reference/raw-floats.mjs +1 -1
  71. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  72. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  73. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  74. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  75. package/dist/reference/remark-asides.d.mts +3 -0
  76. package/dist/reference/remark-asides.mjs +180 -0
  77. package/dist/reference/remark-memo.d.mts +3 -1
  78. package/dist/reference/remark-memo.mjs +42 -11
  79. package/dist/reference/remark-notation.d.mts +60 -10
  80. package/dist/reference/remark-notation.mjs +387 -36
  81. package/dist/reference/render-lab-math.d.ts +4 -26
  82. package/dist/reference/render-lab-math.js +4 -4
  83. package/dist/reference/sections-validate.d.ts +27 -0
  84. package/dist/reference/sections-validate.js +41 -0
  85. package/dist/reference/sections.d.mts +97 -0
  86. package/dist/reference/sections.mjs +306 -0
  87. package/dist/reference/tables-validate.d.ts +1 -1
  88. package/dist/reference/types.d.ts +17 -1
  89. package/dist/reference/unit-words.d.ts +37 -0
  90. package/dist/reference/unit-words.js +170 -0
  91. package/dist/rules/catalog.d.ts +176 -0
  92. package/dist/rules/catalog.js +983 -0
  93. package/dist/rules/course-rules.d.ts +115 -0
  94. package/dist/rules/course-rules.js +292 -0
  95. package/dist/rules/course-rules.schema.json +138 -0
  96. package/dist/rules/in-effect.d.ts +53 -0
  97. package/dist/rules/in-effect.js +142 -0
  98. package/dist/rules/rules.schema.json +1072 -0
  99. package/dist/rules/settings.d.ts +3 -0
  100. package/dist/rules/settings.js +290 -0
  101. package/dist/rules/waivers.d.ts +53 -0
  102. package/dist/rules/waivers.js +132 -0
  103. package/dist/session/PreferenceStore.d.ts +5 -0
  104. package/dist/session/PreferenceStore.js +9 -0
  105. package/docs/VERSIONING.md +5 -3
  106. package/docs/authoring/README.md +232 -40
  107. package/docs/authoring/agents.md +55 -9
  108. package/docs/authoring/components.md +178 -22
  109. package/docs/authoring/migrating.md +1152 -2
  110. package/docs/authoring/notation.md +576 -322
  111. package/docs/authoring/prose.md +551 -0
  112. package/docs/authoring/retired.md +35 -0
  113. package/docs/authoring/structure-rules.md +422 -40
  114. package/docs/authoring/structure.md +445 -62
  115. package/docs/authoring/versioning.md +20 -28
  116. package/docs/authoring/visualizations.md +142 -20
  117. package/package.json +2 -2
  118. package/src/compiler/cli.ts +116 -6
  119. package/src/compiler/collections.ts +111 -7
  120. package/src/compiler/course-config.ts +273 -34
  121. package/src/compiler/knowledge-version.ts +1 -1
  122. package/src/compiler/lesson-derivation.ts +54 -15
  123. package/src/compiler/logging.ts +8 -2
  124. package/src/compiler/manifest.ts +573 -104
  125. package/src/components/BlockHeading.astro +113 -0
  126. package/src/components/CourseContents.astro +221 -0
  127. package/src/components/CurriculumMap.astro +5 -6
  128. package/src/components/FloatIndex.astro +200 -0
  129. package/src/components/HoverPanel.astro +431 -79
  130. package/src/components/PartBridge.astro +27 -0
  131. package/src/components/PartOverview.astro +401 -0
  132. package/src/components/PartScope.astro +27 -0
  133. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  134. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  135. package/src/components/assessments/AssessmentSet.astro +23 -26
  136. package/src/components/block-heading.ts +146 -0
  137. package/src/components/card-arrow.ts +243 -0
  138. package/src/components/card-drag.ts +631 -0
  139. package/src/components/card-jump.ts +45 -0
  140. package/src/components/card-manager.ts +1589 -0
  141. package/src/components/card-memory.ts +177 -0
  142. package/src/components/card-placement.ts +504 -0
  143. package/src/components/card-side.ts +336 -0
  144. package/src/components/card-travel.ts +197 -0
  145. package/src/components/citation/CitationLayer.astro +77 -76
  146. package/src/components/diagram/Diagram.astro +161 -316
  147. package/src/components/disclosure/course-disclosure.ts +24 -0
  148. package/src/components/disclosure/hover-preview.ts +122 -42
  149. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  150. package/src/components/disclosure/print-as-shown.ts +252 -0
  151. package/src/components/examples/CompactExample.astro +3 -1
  152. package/src/components/examples/CompactExamples.astro +101 -94
  153. package/src/components/figure/Figure.astro +174 -274
  154. package/src/components/figure/FloatCaption.astro +87 -0
  155. package/src/components/figure/figure-state.ts +58 -0
  156. package/src/components/figure/figure-tools.ts +87 -0
  157. package/src/components/float-name.ts +101 -0
  158. package/src/components/fold/Fold.astro +125 -0
  159. package/src/components/fold/fold-id.ts +40 -0
  160. package/src/components/memo/Memo.astro +162 -231
  161. package/src/components/memo/ShortNote.astro +6 -6
  162. package/src/components/notation/NotationFormula.astro +5 -1
  163. package/src/components/notation/NotationGlossary.astro +3 -4
  164. package/src/components/notation/NotationLayer.astro +169 -162
  165. package/src/components/notation/NotationMath.astro +10 -1
  166. package/src/components/reference/FloatRefLayer.astro +485 -423
  167. package/src/components/starlight/AboutPanel.astro +5 -4
  168. package/src/components/starlight/CollapsibleSections.astro +207 -93
  169. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  170. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  171. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  172. package/src/components/starlight/LayoutHeader.astro +298 -19
  173. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  174. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  175. package/src/components/starlight/LayoutSidebar.astro +229 -11
  176. package/src/components/starlight/LessonExportActions.astro +53 -72
  177. package/src/components/starlight/LessonFooter.astro +26 -4
  178. package/src/components/starlight/section-parts.ts +15 -2
  179. package/src/components/starlight/section-summary.ts +181 -0
  180. package/src/components/table/Table.astro +95 -76
  181. package/src/components/table/TableOverflowExpander.astro +65 -28
  182. package/src/content-config.ts +40 -4
  183. package/src/reference/base-accents.mjs +31 -0
  184. package/src/reference/base-roman-labels.mjs +2 -2
  185. package/src/reference/consistency.ts +176 -5
  186. package/src/reference/delimiter-size.ts +3 -2
  187. package/src/reference/diagrams-validate.ts +17 -1
  188. package/src/reference/equations-validate.ts +10 -5
  189. package/src/reference/equations.mjs +83 -43
  190. package/src/reference/examples-validate.ts +97 -0
  191. package/src/reference/figures-validate.ts +6 -1
  192. package/src/reference/folds-validate.ts +65 -0
  193. package/src/reference/gate-abbreviations.ts +1 -0
  194. package/src/reference/gate-course-rules.ts +134 -0
  195. package/src/reference/gate-heading-length.ts +66 -35
  196. package/src/reference/gate-heading-structure.ts +193 -0
  197. package/src/reference/gate-math-punctuation.ts +381 -0
  198. package/src/reference/gate-math.ts +69 -10
  199. package/src/reference/gate-part-pages.ts +217 -0
  200. package/src/reference/gate-prose-notation.ts +59 -12
  201. package/src/reference/katex-delimiters.mjs +20 -7
  202. package/src/reference/keyed-float-validate.ts +45 -11
  203. package/src/reference/keyed-jsx-floats.mjs +73 -20
  204. package/src/reference/knowledge-checks-validate.ts +1 -1
  205. package/src/reference/knowledge-checks.mjs +9 -4
  206. package/src/reference/lab-math-slot.ts +36 -0
  207. package/src/reference/math-glyphs.mjs +572 -37
  208. package/src/reference/raw-floats-validate.ts +1 -1
  209. package/src/reference/raw-floats.mjs +1 -1
  210. package/src/reference/rehype-heading-anchors.mjs +116 -17
  211. package/src/reference/rehype-table-header-labels.mjs +21 -3
  212. package/src/reference/remark-asides.mjs +180 -0
  213. package/src/reference/remark-memo.mjs +42 -11
  214. package/src/reference/remark-notation.mjs +387 -36
  215. package/src/reference/render-lab-math.ts +16 -31
  216. package/src/reference/sections-validate.ts +81 -0
  217. package/src/reference/sections.mjs +306 -0
  218. package/src/reference/tables-validate.ts +2 -1
  219. package/src/reference/types.ts +17 -0
  220. package/src/reference/unit-words.ts +233 -0
  221. package/src/rules/catalog.ts +1298 -0
  222. package/src/rules/course-rules.ts +456 -0
  223. package/src/rules/in-effect.ts +235 -0
  224. package/src/rules/settings.ts +448 -0
  225. package/src/rules/waivers.ts +249 -0
  226. package/src/session/PreferenceStore.ts +15 -0
  227. package/src/styles/global.css +1467 -163
  228. package/src/styles/surfaces.css +182 -0
  229. package/src/components/hover-panel.ts +0 -339
  230. 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 division of two variable-like symbols, an escaped
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
- export function proseOnly(body: string): string {
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
- return body
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
- .replace(DISPLAY_MATH, blank)
63
- .replace(INLINE_MATH, blank)
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
- /** Every N9 finding in one already-prose-only string, in source order. */
145
- function findingsIn(text: string): Finding[] {
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 — N9 has no
181
- * escape hatch) per finding.
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
- for (const finding of findingsIn(text)) {
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
- for (const finding of findingsIn(proseOnly(field))) {
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
- const grown = await loadGrownKatex();
120
- for (const name of RENDER_EXPORTS) {
121
- if (typeof grown[name] === 'function') katex[name] = grown[name];
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`, 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.
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 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>`.
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,