explico 0.7.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/CHANGELOG.md +1409 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +54 -3
  5. package/dist/compiler/cli.js +80 -6
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +84 -8
  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 +14 -1
  15. package/dist/compiler/logging.js +6 -2
  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 +21 -11
  27. package/dist/reference/delimiter-size.js +15 -39
  28. package/dist/reference/diagrams-validate.d.ts +7 -2
  29. package/dist/reference/diagrams-validate.js +9 -1
  30. package/dist/reference/equations-validate.d.ts +8 -5
  31. package/dist/reference/equations-validate.js +1 -0
  32. package/dist/reference/equations.d.mts +69 -34
  33. package/dist/reference/equations.mjs +90 -44
  34. package/dist/reference/examples-validate.d.ts +31 -0
  35. package/dist/reference/examples-validate.js +48 -0
  36. package/dist/reference/figures-validate.d.ts +1 -1
  37. package/dist/reference/figures-validate.js +4 -1
  38. package/dist/reference/folds-validate.d.ts +33 -0
  39. package/dist/reference/folds-validate.js +32 -0
  40. package/dist/reference/gate-abbreviations.d.ts +3 -1
  41. package/dist/reference/gate-abbreviations.js +3 -2
  42. package/dist/reference/gate-course-rules.d.ts +32 -0
  43. package/dist/reference/gate-course-rules.js +85 -0
  44. package/dist/reference/gate-heading-length.d.ts +5 -2
  45. package/dist/reference/gate-heading-length.js +47 -23
  46. package/dist/reference/gate-heading-structure.d.ts +41 -0
  47. package/dist/reference/gate-heading-structure.js +113 -0
  48. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  49. package/dist/reference/gate-math-punctuation.js +251 -0
  50. package/dist/reference/gate-math.d.ts +44 -6
  51. package/dist/reference/gate-math.js +63 -10
  52. package/dist/reference/gate-part-pages.d.ts +50 -0
  53. package/dist/reference/gate-part-pages.js +118 -0
  54. package/dist/reference/gate-prose-notation.d.ts +32 -11
  55. package/dist/reference/gate-prose-notation.js +30 -8
  56. package/dist/reference/katex-delimiters.d.mts +15 -0
  57. package/dist/reference/katex-delimiters.mjs +138 -0
  58. package/dist/reference/katex-options.mjs +4 -0
  59. package/dist/reference/keyed-float-validate.d.ts +27 -10
  60. package/dist/reference/keyed-float-validate.js +15 -1
  61. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  62. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  63. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  64. package/dist/reference/knowledge-checks.d.mts +3 -1
  65. package/dist/reference/knowledge-checks.mjs +9 -4
  66. package/dist/reference/lab-math-scope.d.ts +1 -0
  67. package/dist/reference/lab-math-slot.d.ts +34 -0
  68. package/dist/reference/lab-math-slot.js +6 -0
  69. package/dist/reference/math-glyphs.d.mts +80 -4
  70. package/dist/reference/math-glyphs.mjs +588 -39
  71. package/dist/reference/page-notation.d.ts +6 -1
  72. package/dist/reference/raw-floats-validate.js +1 -1
  73. package/dist/reference/raw-floats.mjs +1 -1
  74. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  75. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  76. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  77. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  78. package/dist/reference/remark-asides.d.mts +3 -0
  79. package/dist/reference/remark-asides.mjs +180 -0
  80. package/dist/reference/remark-memo.d.mts +3 -1
  81. package/dist/reference/remark-memo.mjs +42 -11
  82. package/dist/reference/remark-notation.d.mts +60 -10
  83. package/dist/reference/remark-notation.mjs +387 -36
  84. package/dist/reference/render-lab-math.d.ts +4 -26
  85. package/dist/reference/render-lab-math.js +5 -5
  86. package/dist/reference/sections-validate.d.ts +27 -0
  87. package/dist/reference/sections-validate.js +41 -0
  88. package/dist/reference/sections.d.mts +97 -0
  89. package/dist/reference/sections.mjs +306 -0
  90. package/dist/reference/tables-validate.d.ts +1 -1
  91. package/dist/reference/types.d.ts +24 -1
  92. package/dist/reference/unit-words.d.ts +37 -0
  93. package/dist/reference/unit-words.js +170 -0
  94. package/dist/rules/catalog.d.ts +176 -0
  95. package/dist/rules/catalog.js +983 -0
  96. package/dist/rules/course-rules.d.ts +115 -0
  97. package/dist/rules/course-rules.js +292 -0
  98. package/dist/rules/course-rules.schema.json +138 -0
  99. package/dist/rules/in-effect.d.ts +53 -0
  100. package/dist/rules/in-effect.js +142 -0
  101. package/dist/rules/rules.schema.json +1072 -0
  102. package/dist/rules/settings.d.ts +3 -0
  103. package/dist/rules/settings.js +290 -0
  104. package/dist/rules/waivers.d.ts +53 -0
  105. package/dist/rules/waivers.js +132 -0
  106. package/dist/session/PreferenceStore.d.ts +5 -0
  107. package/dist/session/PreferenceStore.js +9 -0
  108. package/docs/VERSIONING.md +2 -2
  109. package/docs/authoring/README.md +232 -40
  110. package/docs/authoring/agents.md +55 -9
  111. package/docs/authoring/components.md +178 -22
  112. package/docs/authoring/migrating.md +1324 -2
  113. package/docs/authoring/notation.md +616 -344
  114. package/docs/authoring/prose.md +551 -0
  115. package/docs/authoring/retired.md +35 -0
  116. package/docs/authoring/structure-rules.md +422 -40
  117. package/docs/authoring/structure.md +445 -62
  118. package/docs/authoring/versioning.md +20 -28
  119. package/docs/authoring/visualizations.md +142 -20
  120. package/docs/katex-bracket-scaling.md +191 -0
  121. package/package.json +3 -2
  122. package/src/compiler/cli.ts +139 -6
  123. package/src/compiler/collections.ts +144 -10
  124. package/src/compiler/course-config.ts +273 -34
  125. package/src/compiler/knowledge-version.ts +1 -1
  126. package/src/compiler/lesson-derivation.ts +54 -15
  127. package/src/compiler/logging.ts +21 -4
  128. package/src/compiler/manifest.ts +573 -104
  129. package/src/components/BlockHeading.astro +113 -0
  130. package/src/components/CourseContents.astro +221 -0
  131. package/src/components/CurriculumMap.astro +5 -6
  132. package/src/components/FloatIndex.astro +200 -0
  133. package/src/components/HoverPanel.astro +431 -79
  134. package/src/components/PartBridge.astro +27 -0
  135. package/src/components/PartOverview.astro +401 -0
  136. package/src/components/PartScope.astro +27 -0
  137. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  138. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  139. package/src/components/assessments/AssessmentSet.astro +23 -26
  140. package/src/components/block-heading.ts +146 -0
  141. package/src/components/card-arrow.ts +243 -0
  142. package/src/components/card-drag.ts +631 -0
  143. package/src/components/card-jump.ts +45 -0
  144. package/src/components/card-manager.ts +1589 -0
  145. package/src/components/card-memory.ts +177 -0
  146. package/src/components/card-placement.ts +504 -0
  147. package/src/components/card-side.ts +336 -0
  148. package/src/components/card-travel.ts +197 -0
  149. package/src/components/citation/CitationLayer.astro +77 -76
  150. package/src/components/diagram/Diagram.astro +161 -316
  151. package/src/components/disclosure/course-disclosure.ts +24 -0
  152. package/src/components/disclosure/hover-preview.ts +122 -42
  153. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  154. package/src/components/disclosure/print-as-shown.ts +252 -0
  155. package/src/components/examples/CompactExample.astro +3 -1
  156. package/src/components/examples/CompactExamples.astro +101 -94
  157. package/src/components/figure/Figure.astro +174 -274
  158. package/src/components/figure/FloatCaption.astro +87 -0
  159. package/src/components/figure/figure-state.ts +58 -0
  160. package/src/components/figure/figure-tools.ts +87 -0
  161. package/src/components/float-name.ts +101 -0
  162. package/src/components/fold/Fold.astro +125 -0
  163. package/src/components/fold/fold-id.ts +40 -0
  164. package/src/components/memo/Memo.astro +162 -231
  165. package/src/components/memo/ShortNote.astro +6 -6
  166. package/src/components/notation/NotationFormula.astro +5 -1
  167. package/src/components/notation/NotationGlossary.astro +24 -14
  168. package/src/components/notation/NotationLayer.astro +169 -162
  169. package/src/components/notation/NotationMath.astro +10 -1
  170. package/src/components/notation/render-notation-math.ts +111 -10
  171. package/src/components/reference/FloatRefLayer.astro +485 -423
  172. package/src/components/starlight/AboutPanel.astro +22 -6
  173. package/src/components/starlight/CollapsibleSections.astro +207 -93
  174. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  175. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  176. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  177. package/src/components/starlight/LayoutHeader.astro +298 -19
  178. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  179. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  180. package/src/components/starlight/LayoutSidebar.astro +229 -11
  181. package/src/components/starlight/LessonExportActions.astro +53 -72
  182. package/src/components/starlight/LessonFooter.astro +26 -4
  183. package/src/components/starlight/section-parts.ts +15 -2
  184. package/src/components/starlight/section-summary.ts +181 -0
  185. package/src/components/table/Table.astro +95 -76
  186. package/src/components/table/TableOverflowExpander.astro +65 -28
  187. package/src/content-config.ts +40 -4
  188. package/src/reference/base-accents.mjs +31 -0
  189. package/src/reference/base-roman-labels.mjs +2 -2
  190. package/src/reference/consistency.ts +176 -5
  191. package/src/reference/delimiter-size.ts +40 -61
  192. package/src/reference/diagrams-validate.ts +17 -1
  193. package/src/reference/equations-validate.ts +10 -5
  194. package/src/reference/equations.mjs +90 -44
  195. package/src/reference/examples-validate.ts +97 -0
  196. package/src/reference/figures-validate.ts +6 -1
  197. package/src/reference/folds-validate.ts +65 -0
  198. package/src/reference/gate-abbreviations.ts +3 -2
  199. package/src/reference/gate-course-rules.ts +134 -0
  200. package/src/reference/gate-heading-length.ts +68 -37
  201. package/src/reference/gate-heading-structure.ts +193 -0
  202. package/src/reference/gate-math-punctuation.ts +381 -0
  203. package/src/reference/gate-math.ts +119 -12
  204. package/src/reference/gate-part-pages.ts +217 -0
  205. package/src/reference/gate-prose-notation.ts +74 -13
  206. package/src/reference/katex-delimiters.mjs +138 -0
  207. package/src/reference/katex-options.mjs +4 -0
  208. package/src/reference/keyed-float-validate.ts +45 -11
  209. package/src/reference/keyed-jsx-floats.mjs +73 -20
  210. package/src/reference/knowledge-checks-validate.ts +1 -1
  211. package/src/reference/knowledge-checks.mjs +9 -4
  212. package/src/reference/lab-math-slot.ts +36 -0
  213. package/src/reference/math-glyphs.mjs +588 -39
  214. package/src/reference/raw-floats-validate.ts +1 -1
  215. package/src/reference/raw-floats.mjs +1 -1
  216. package/src/reference/rehype-heading-anchors.mjs +116 -17
  217. package/src/reference/rehype-table-header-labels.mjs +21 -3
  218. package/src/reference/remark-asides.mjs +180 -0
  219. package/src/reference/remark-memo.mjs +42 -11
  220. package/src/reference/remark-notation.mjs +387 -36
  221. package/src/reference/render-lab-math.ts +17 -32
  222. package/src/reference/sections-validate.ts +81 -0
  223. package/src/reference/sections.mjs +306 -0
  224. package/src/reference/tables-validate.ts +2 -1
  225. package/src/reference/types.ts +24 -0
  226. package/src/reference/unit-words.ts +233 -0
  227. package/src/rules/catalog.ts +1298 -0
  228. package/src/rules/course-rules.ts +456 -0
  229. package/src/rules/in-effect.ts +235 -0
  230. package/src/rules/settings.ts +448 -0
  231. package/src/rules/waivers.ts +249 -0
  232. package/src/session/PreferenceStore.ts +15 -0
  233. package/src/styles/global.css +1471 -163
  234. package/src/styles/surfaces.css +182 -0
  235. package/src/components/hover-panel.ts +0 -339
  236. 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);
@@ -71,6 +82,20 @@ export function lineOf(text: string, index: number): number {
71
82
  return text.slice(0, index).split('\n').length;
72
83
  }
73
84
 
85
+ /**
86
+ * {@link lineOf} corrected to the file the lesson came from. A gate scans
87
+ * `lesson.body`, which starts *after* the frontmatter, so a raw body line is
88
+ * short by the frontmatter's height and points at the wrong source line —
89
+ * silently, because the number is still plausible. Every gate that reports a
90
+ * `line` against `lesson.source.file` must go through this.
91
+ */
92
+ export function lessonLine(
93
+ lesson: { readonly body: string; readonly bodyLine?: number },
94
+ index: number,
95
+ ): number {
96
+ return lineOf(lesson.body, index) + (lesson.bodyLine ?? 1) - 1;
97
+ }
98
+
74
99
  // Symbolic/abbreviated unit tokens that must be typeset in `$…$`, longest
75
100
  // first so the alternation can't match a prefix of a longer unit.
76
101
  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`;
@@ -108,10 +133,21 @@ const SLASH_TOKENS = /\b([A-Za-z]{1,6})\/([A-Za-z]{1,6})\b/g;
108
133
  // hand-typed sub/superscript that was never wrapped in `$…$`.
109
134
  const FAKE_SCRIPT = /[A-Za-z0-9][_^][A-Za-z0-9{]/g;
110
135
 
136
+ /** Every N9 finding that is not a spelled-out unit. */
137
+ const PROSE_NOTATION_CODE = 'prose-notation' as const;
138
+
111
139
  interface Finding {
112
140
  readonly index: number;
113
141
  readonly token: string;
114
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[];
115
151
  }
116
152
 
117
153
  function findAll(text: string, pattern: RegExp, toFinding: (m: RegExpExecArray) => Finding): Finding[] {
@@ -127,9 +163,25 @@ function findAll(text: string, pattern: RegExp, toFinding: (m: RegExpExecArray)
127
163
 
128
164
  const FIX = 'Wrap it in $…$ (or $$…$$) instead — see docs/authoring/notation.md#n9.';
129
165
 
130
- /** Every N9 finding in one already-prose-only string, in source order. */
131
- 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
+ );
132
183
  return [
184
+ ...spelled,
133
185
  ...findAll(text, /\\\|/g, (m) => ({
134
186
  index: m.index,
135
187
  token: m[0],
@@ -163,22 +215,28 @@ function findingsIn(text: string): Finding[] {
163
215
 
164
216
  /**
165
217
  * 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.
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.
168
223
  */
169
224
  export function gateProseNotation(
170
225
  lessons: readonly NotationLessonInput[],
226
+ options: ProseNotationOptions = {},
171
227
  ): MathGateDiagnostic[] {
172
228
  const diagnostics: MathGateDiagnostic[] = [];
173
229
 
174
230
  for (const lesson of lessons) {
175
231
  const text = proseOnly(lesson.body);
176
- for (const finding of findingsIn(text)) {
232
+ const withMath = proseOnly(lesson.body, { keepMath: true });
233
+ for (const finding of findingsIn(text, withMath, options)) {
177
234
  diagnostics.push({
178
235
  context: 'prose',
236
+ code: finding.code ?? PROSE_NOTATION_CODE,
179
237
  severity: 'error',
180
238
  file: lesson.source.file,
181
- line: lineOf(lesson.body, finding.index),
239
+ line: lessonLine(lesson, finding.index),
182
240
  token: finding.token,
183
241
  message: finding.message,
184
242
  });
@@ -200,6 +258,7 @@ export function gateProseNotation(
200
258
  */
201
259
  export function gateProseNotationInAssessments(
202
260
  assessments: readonly AssessmentLike[],
261
+ options: ProseNotationOptions = {},
203
262
  ): MathGateDiagnostic[] {
204
263
  const diagnostics: MathGateDiagnostic[] = [];
205
264
 
@@ -207,9 +266,11 @@ export function gateProseNotationInAssessments(
207
266
  for (const item of assessment.items ?? []) {
208
267
  for (const field of [item.prompt, item.explanation]) {
209
268
  if (typeof field !== 'string') continue;
210
- for (const finding of findingsIn(proseOnly(field))) {
269
+ const withMath = proseOnly(field, { keepMath: true });
270
+ for (const finding of findingsIn(proseOnly(field), withMath, options)) {
211
271
  diagnostics.push({
212
272
  context: 'prose',
273
+ code: finding.code ?? PROSE_NOTATION_CODE,
213
274
  severity: 'error',
214
275
  file: `${assessment.id} (${item.id ?? '?'})`,
215
276
  token: finding.token,
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Rule N17's engine half: make `\left`/`\right` grow strictly outward.
3
+ *
4
+ * Importing this module for its side effect installs a KaTeX whose
5
+ * `\delimitershortfall` is `-1pt` instead of the stock `5pt`, so a scalable
6
+ * delimiter pair is always *strictly* taller than what it wraps and nesting
7
+ * depth shows up on its own. See `docs/katex-bracket-scaling.md` for the
8
+ * derivation and `docs/authoring/notation.md#n17` for the authoring rule.
9
+ *
10
+ * ## Why this is not a `patches/` file
11
+ *
12
+ * `delimiterExtend` is a function-local `const` inside `makeLeftRightDelim`.
13
+ * Nothing KaTeX exports reaches it — `__defineMacro`, `__defineFunction`,
14
+ * `__defineSymbol`, `__setFontMetrics` and the settings schema all sit above
15
+ * the layout engine — so the constant can only be changed in the module's own
16
+ * source. A `pnpm patch` does that, but a patch is project-local: it cannot
17
+ * travel inside the published `explico` artifact, so every downstream course
18
+ * would have to reapply it or silently render flat nests.
19
+ *
20
+ * So the engine does it in code instead. It reads KaTeX's own ESM bundle,
21
+ * rewrites that one constant, imports the result as a module, and moves the
22
+ * rendering entry points onto the shared `katex` object every consumer in the
23
+ * process already imported. KaTeX's default export is a plain mutable object
24
+ * and `rehype-katex` reads `katex.renderToString` at call time, so the swap
25
+ * reaches lesson-body math without forking the plugin.
26
+ *
27
+ * ## Why this is safe to rely on
28
+ *
29
+ * The assumptions above (a matching constant, a mutable export, a bundle with
30
+ * no imports of its own) are all checked here, and `assertDelimiterGrowth()`
31
+ * renders a known nest afterwards and throws when the sizes do not actually
32
+ * increase. A course therefore either renders with engine-sized delimiters or
33
+ * fails its build saying so — it cannot quietly get the stock behaviour.
34
+ */
35
+ import katex from 'katex';
36
+
37
+ /** TeX's `\delimitershortfall`, in points, as KaTeX hard-codes it. */
38
+ const STOCK_SHORTFALL = '5.0';
39
+ /** The negative shortfall that forces strict outward growth. */
40
+ const GROWN_SHORTFALL = '-1.0';
41
+
42
+ const needle = (points) =>
43
+ `var delimiterExtend = ${points} / options.fontMetrics().ptPerEm;`;
44
+
45
+ /** The entry points that build a delimiter and must come from the rewrite. */
46
+ const RENDER_EXPORTS = [
47
+ 'render',
48
+ 'renderToString',
49
+ '__renderToDomTree',
50
+ '__renderToHTMLTree',
51
+ ];
52
+
53
+ /**
54
+ * Exported for the test that pins both failure messages — a KaTeX upgrade that
55
+ * moves the constant, and a leftover `patches/` file, are the two ways this
56
+ * stops being able to install itself.
57
+ */
58
+ export function rewriteBundle(source) {
59
+ const stock = needle(STOCK_SHORTFALL);
60
+ if (!source.includes(stock)) {
61
+ // Either the constant moved or someone already rewrote it. Both mean the
62
+ // assumption this module rests on no longer holds for this KaTeX build.
63
+ const alreadyGrown = source.includes(needle(GROWN_SHORTFALL));
64
+ throw new Error(
65
+ alreadyGrown
66
+ ? `explico: katex ${katex.version} already carries the -1pt delimiter ` +
67
+ 'shortfall (a patches/ file?). Remove that patch — the engine ' +
68
+ 'installs it in code (src/reference/katex-delimiters.mjs).'
69
+ : `explico: cannot install the N17 delimiter sizing into katex ` +
70
+ `${katex.version}: the \\delimitershortfall constant ` +
71
+ `(${stock}) is not in its ESM bundle. Re-derive the one-line ` +
72
+ 'change against the new bundle — see docs/katex-bracket-scaling.md.',
73
+ );
74
+ }
75
+ return source.replace(stock, needle(GROWN_SHORTFALL));
76
+ }
77
+
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');
86
+ const require = createRequire(import.meta.url);
87
+ const source = readFileSync(require.resolve('katex/dist/katex.mjs'), 'utf8');
88
+ // The bundle is self-contained, so it can be re-imported from a data URL
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.
91
+ const rewritten = rewriteBundle(source);
92
+ const module = await import(
93
+ /* @vite-ignore */
94
+ `data:text/javascript;base64,${Buffer.from(rewritten).toString('base64')}`
95
+ );
96
+ return module.default;
97
+ }
98
+
99
+ /**
100
+ * Render a known nest and confirm the outer pair really is larger than the
101
+ * inner one. Cheap, and it turns every way this could go wrong — an upstream
102
+ * refactor, a frozen export, an import that beat us to it — into one loud
103
+ * failure at the point of installation.
104
+ */
105
+ export function assertDelimiterGrowth(instance = katex) {
106
+ const html = instance.renderToString(
107
+ String.raw`\left[1+\left(\frac{a}{b}\right)\right]`,
108
+ { displayMode: true, output: 'html' },
109
+ );
110
+ const sizes = [...html.matchAll(/delimsizing size(\d)/g)].map((m) =>
111
+ Number(m[1]),
112
+ );
113
+ // Two pairs, so four glyphs: outer, inner, inner, outer.
114
+ const grew = sizes.length === 4 && sizes[0] > sizes[1];
115
+ if (!grew) {
116
+ throw new Error(
117
+ 'explico: the N17 delimiter sizing did not take effect — a nested ' +
118
+ `\\left…\\right pair rendered as [${sizes.join(', ')}] instead of ` +
119
+ 'growing outward. Something rendered math before ' +
120
+ "'explico/reference/katex-delimiters.mjs' was imported, or katex " +
121
+ 'changed shape. See docs/katex-bracket-scaling.md.',
122
+ );
123
+ }
124
+ }
125
+
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();
136
+ }
137
+
138
+ export default katex;
@@ -1,3 +1,7 @@
1
+ // Side-effect import: installs rule N17's delimiter sizing onto the shared
2
+ // `katex` module before anything renders. A course wires this file into its
3
+ // `astro.config.mjs`, so `rehype-katex` inherits it without a patch.
4
+ import './katex-delimiters.mjs';
1
5
  import { NOTATION_KEY_PATTERN } from './remark-notation.mjs';
2
6
 
3
7
  export const EXPLAIN_MACRO = String.raw`\htmlData{notation-key=#1}{#2}`;
@@ -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,