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,251 @@
1
+ import { equationLinkFor, mathSpans } from "./gate-math.js";
2
+ import { lessonLine } from "./gate-prose-notation.js";
3
+ const MATH_END_ABBREVIATIONS = [
4
+ "a.e.",
5
+ "a.s.",
6
+ "c.c.",
7
+ "const.",
8
+ "h.c.",
9
+ "i.i.d."
10
+ ];
11
+ const LETTERS = /[A-Za-z]+/y;
12
+ function tokenize(latex) {
13
+ const tokens = [];
14
+ const stack = [];
15
+ let i = 0;
16
+ while (i < latex.length) {
17
+ const ch = latex[i];
18
+ const parent = stack.length === 0 ? -1 : stack[stack.length - 1];
19
+ if (ch === "%") {
20
+ const newline = latex.indexOf("\n", i);
21
+ i = newline === -1 ? latex.length : newline;
22
+ continue;
23
+ }
24
+ if (ch === "\\") {
25
+ LETTERS.lastIndex = i + 1;
26
+ const letters = LETTERS.exec(latex);
27
+ const name = letters ? letters[0] : latex.charAt(i + 1);
28
+ tokens.push({ kind: "command", text: name, start: i, match: -1, parent });
29
+ i += 1 + Math.max(name.length, 1);
30
+ continue;
31
+ }
32
+ if (ch === "{") {
33
+ tokens.push({ kind: "open", text: ch, start: i, match: -1, parent });
34
+ stack.push(tokens.length - 1);
35
+ } else if (ch === "}") {
36
+ const open = stack.pop();
37
+ const index = tokens.length;
38
+ tokens.push({
39
+ kind: "close",
40
+ text: ch,
41
+ start: i,
42
+ match: open ?? -1,
43
+ parent: open === void 0 ? -1 : tokens[open].parent
44
+ });
45
+ if (open !== void 0) tokens[open].match = index;
46
+ } else {
47
+ tokens.push({ kind: /\s/.test(ch) ? "space" : "char", text: ch, start: i, match: -1, parent });
48
+ }
49
+ i += 1;
50
+ }
51
+ return tokens;
52
+ }
53
+ const MARKS = /* @__PURE__ */ new Set([".", ",", ";", ":"]);
54
+ const INVISIBLE = /* @__PURE__ */ new Set([
55
+ ",",
56
+ ";",
57
+ ":",
58
+ "!",
59
+ " ",
60
+ "\\",
61
+ "quad",
62
+ "qquad",
63
+ "enspace",
64
+ "thinspace",
65
+ "medspace",
66
+ "thickspace",
67
+ "negthinspace",
68
+ "negmedspace",
69
+ "negthickspace",
70
+ "nonumber",
71
+ "notag",
72
+ "displaystyle",
73
+ "textstyle"
74
+ ]);
75
+ const SKIPPED_GROUP = /* @__PURE__ */ new Set(["label", "caption", "tag", "begin", "end", "hspace", "vspace", "color"]);
76
+ const UPRIGHT = /* @__PURE__ */ new Set(["text", "textrm", "textup", "textnormal", "mathrm", "mbox", "operatorname"]);
77
+ const SIZER = /^(?:left|right|middle|[bB]igg?[lrm]?)$/;
78
+ function groupOwner(tokens, open, from) {
79
+ let k = open - 1;
80
+ while (k >= from && tokens[k].kind === "space") k -= 1;
81
+ if (k >= from && tokens[k].kind === "char" && tokens[k].text === "*") k -= 1;
82
+ return k >= from && tokens[k].kind === "command" ? k : void 0;
83
+ }
84
+ function tail(tokens, from, to) {
85
+ let j = to - 1;
86
+ while (j >= from) {
87
+ const token = tokens[j];
88
+ if (token.kind === "space") {
89
+ j -= 1;
90
+ continue;
91
+ }
92
+ if (token.kind === "command" && INVISIBLE.has(token.text)) {
93
+ j -= 1;
94
+ continue;
95
+ }
96
+ if (token.kind === "char" && token.text === ".") {
97
+ const before = tokens[j - 1];
98
+ if (j - 1 >= from && before?.kind === "command" && SIZER.test(before.text)) {
99
+ j -= 2;
100
+ continue;
101
+ }
102
+ }
103
+ if (token.kind === "char" && token.text === "]") {
104
+ let open = j - 1;
105
+ while (open >= from && !(tokens[open].kind === "char" && tokens[open].text === "[")) open -= 1;
106
+ const breakToken = tokens[open - 1];
107
+ if (open - 1 >= from && breakToken?.kind === "command" && breakToken.text === "\\") {
108
+ j = open - 2;
109
+ continue;
110
+ }
111
+ return "clean";
112
+ }
113
+ if (token.kind === "close") {
114
+ if (token.match < from) return "clean";
115
+ const owner = groupOwner(tokens, token.match, from);
116
+ if (owner !== void 0 && SKIPPED_GROUP.has(tokens[owner].text)) {
117
+ j = owner - 1;
118
+ continue;
119
+ }
120
+ const inner = tail(tokens, token.match + 1, j);
121
+ if (inner !== "empty") return inner;
122
+ j = token.match - 1;
123
+ continue;
124
+ }
125
+ if (token.kind === "char" && MARKS.has(token.text)) return { mark: j };
126
+ return "clean";
127
+ }
128
+ return "empty";
129
+ }
130
+ function classify(tokens, j, rowEnd, allowed) {
131
+ const token = tokens[j];
132
+ const base = { index: token.start, mark: token.text, rowEnd };
133
+ if (token.text !== ".") return { ...base, kind: "sentence" };
134
+ const isChar = (k, pattern) => tokens[k]?.kind === "char" && pattern.test(tokens[k].text);
135
+ if (isChar(j - 1, /^\.$/)) return { ...base, kind: "ellipsis" };
136
+ if (isChar(j - 1, /^\d$/)) {
137
+ let k = j - 1;
138
+ while (isChar(k - 1, /^\d$/)) k -= 1;
139
+ if (!isChar(k - 1, /^[_^]$/)) return { ...base, kind: "decimal" };
140
+ }
141
+ const owner = token.parent >= 0 ? groupOwner(tokens, token.parent, 0) : void 0;
142
+ if (owner !== void 0 && UPRIGHT.has(tokens[owner].text)) {
143
+ let k = j;
144
+ while (isChar(k - 1, /^[A-Za-z.]$/)) k -= 1;
145
+ const word = tokens.slice(k, j + 1).map((t) => t.text).join("");
146
+ if (/[A-Za-z]/.test(word)) {
147
+ if (allowed.has(word)) return void 0;
148
+ return { ...base, kind: "abbreviation", word };
149
+ }
150
+ }
151
+ return { ...base, kind: "sentence" };
152
+ }
153
+ function allowedAbbreviations(options) {
154
+ const deny = new Set(options.deny ?? []);
155
+ return /* @__PURE__ */ new Set([
156
+ ...MATH_END_ABBREVIATIONS.filter((word) => !deny.has(word)),
157
+ ...options.allow ?? []
158
+ ]);
159
+ }
160
+ function findings(latex, allowed) {
161
+ const tokens = tokenize(latex);
162
+ const byMark = /* @__PURE__ */ new Map();
163
+ const record = (result, rowEnd) => {
164
+ if (typeof result !== "object" || byMark.has(result.mark)) return;
165
+ const finding = classify(tokens, result.mark, rowEnd, allowed);
166
+ if (finding) byMark.set(result.mark, finding);
167
+ };
168
+ tokens.forEach((token, k) => {
169
+ if (token.kind === "command" && token.text === "\\") record(tail(tokens, token.parent + 1, k), true);
170
+ });
171
+ record(tail(tokens, 0, tokens.length), false);
172
+ return [...byMark.values()].sort((left, right) => left.index - right.index);
173
+ }
174
+ function trailingPunctuation(latex, options = {}) {
175
+ return findings(latex, allowedAbbreviations(options));
176
+ }
177
+ const SEE = "See docs/authoring/notation.md#n12.";
178
+ function messageFor(finding, snippet) {
179
+ const where = finding.rowEnd ? "ends a row of the math (before `\\\\`)" : "ends the math";
180
+ switch (finding.kind) {
181
+ case "decimal":
182
+ return `"${snippet}" ${where} with a trailing decimal point, which cannot be told apart from the sentence's full stop. Drop the point, or write the digits that state the precision (\`5.0\`); keep a full stop outside the math. ${SEE}`;
183
+ case "ellipsis":
184
+ return `"${snippet}" ${where} with a typed "...". Write \`\\ldots\`, and keep a full stop outside the math. ${SEE}`;
185
+ case "abbreviation":
186
+ return `"${snippet}" ${where} with "${finding.word}". If that abbreviation is notation (like \`\\text{h.c.}\`), add it to \`rules.N12.allow\`; if the "." is the sentence's full stop, move it after the closing \`$\`. ${SEE}`;
187
+ case "sentence":
188
+ return finding.rowEnd ? `"${snippet}" ${where} with "${finding.mark}" \u2014 the sentence's own punctuation. Drop it; the row break already separates the lines. ${SEE}` : `"${snippet}" ${where} with "${finding.mark}" \u2014 the sentence's own punctuation. Move it after the closing \`$\` (inline math) or drop it (display math). ${SEE}`;
189
+ }
190
+ }
191
+ function snippetOf(latex, index) {
192
+ const from = Math.max(0, index - 24);
193
+ const text = latex.slice(from, index + 1).replace(/\s+/g, " ").trimStart();
194
+ return from > 0 ? `\u2026${text}` : text;
195
+ }
196
+ function gateMathPunctuation(lessons, options = {}) {
197
+ const allowed = allowedAbbreviations(options);
198
+ const diagnostics = [];
199
+ for (const lesson of lessons) {
200
+ for (const span of mathSpans(lesson.body)) {
201
+ const equation = equationLinkFor(lesson.lessonId, span.latex);
202
+ for (const finding of findings(span.latex, allowed)) {
203
+ const at = span.offset + finding.index;
204
+ const snippet = snippetOf(span.latex, finding.index);
205
+ diagnostics.push({
206
+ context: "body",
207
+ code: "math-trailing-punctuation",
208
+ severity: "error",
209
+ file: lesson.source.file,
210
+ line: lessonLine(lesson, at),
211
+ column: at - lesson.body.lastIndexOf("\n", at - 1),
212
+ token: snippet,
213
+ ...equation === void 0 ? {} : { equation },
214
+ message: messageFor(finding, snippet)
215
+ });
216
+ }
217
+ }
218
+ }
219
+ return diagnostics;
220
+ }
221
+ function gateMathPunctuationInAssessments(assessments, options = {}) {
222
+ const allowed = allowedAbbreviations(options);
223
+ const diagnostics = [];
224
+ for (const assessment of assessments) {
225
+ for (const item of assessment.items ?? []) {
226
+ for (const field of [item.prompt, item.explanation]) {
227
+ if (typeof field !== "string") continue;
228
+ for (const span of mathSpans(field)) {
229
+ for (const finding of findings(span.latex, allowed)) {
230
+ const snippet = snippetOf(span.latex, finding.index);
231
+ diagnostics.push({
232
+ context: "assessment",
233
+ code: "math-trailing-punctuation",
234
+ severity: "error",
235
+ file: `${assessment.id} (${item.id ?? "?"})`,
236
+ token: snippet,
237
+ message: messageFor(finding, snippet)
238
+ });
239
+ }
240
+ }
241
+ }
242
+ }
243
+ }
244
+ return diagnostics;
245
+ }
246
+ export {
247
+ MATH_END_ABBREVIATIONS,
248
+ gateMathPunctuation,
249
+ gateMathPunctuationInAssessments,
250
+ trailingPunctuation
251
+ };
@@ -15,7 +15,7 @@ interface EquationLink {
15
15
  readonly href: string;
16
16
  }
17
17
  interface MathGateDiagnostic {
18
- readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
18
+ readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure' | 'course';
19
19
  /**
20
20
  * Body / component-slot math blocks the build (the render path needs every
21
21
  * glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
@@ -34,13 +34,18 @@ interface MathGateDiagnostic {
34
34
  * `gate-abbreviations.ts`) is the same "ships straight as an error" story
35
35
  * for a different shape of plain-text token — an unexplained acronym or
36
36
  * dotted abbreviation, rather than a mis-typed quantity/unit. `structure`
37
- * (S1, `gate-heading-length.ts`) is unrelated to notation — it caps how
38
- * long a heading or a keyed float's title may run — but reuses this same
37
+ * (S1, `gate-heading-length.ts`; S2, `gate-part-pages.ts`) is unrelated to
38
+ * notation — it caps how long a heading or a keyed float's title may run,
39
+ * and requires a part page's bridge and scope paragraphs — but reuses this same
39
40
  * diagnostic shape and the one `manifest.diagnostics.math` array rather
40
- * than adding a second array the CLI has to know about.
41
+ * than adding a second array the CLI has to know about. `course`
42
+ * (`gate-course-rules.ts`) is a rule the *course* wrote in `courseRules`, not
43
+ * one the engine ships; its severity is the course's choice, so it is the one
44
+ * context whose severity is not fixed here.
41
45
  */
42
46
  readonly severity: 'error' | 'warning';
43
- readonly code?: string;
47
+ /** Owned by exactly one rule or invariant in `src/rules/catalog.ts`. */
48
+ readonly code: string;
44
49
  readonly file: string;
45
50
  readonly line?: number;
46
51
  readonly column?: number;
@@ -62,6 +67,15 @@ interface LessonAssessmentLink {
62
67
  readonly lessonId: string;
63
68
  readonly assessmentIds: readonly string[];
64
69
  }
70
+ interface MathSpan {
71
+ readonly latex: string;
72
+ readonly line: number;
73
+ readonly column: number;
74
+ /** Index of `latex` in the text it was cut from. */
75
+ readonly offset: number;
76
+ }
77
+ /** Every `$$…$$` then `$…$` span in `body`, outside code, with its position. */
78
+ declare function mathSpans(body: string): MathSpan[];
65
79
  type ScopeEntry = {
66
80
  key: string;
67
81
  notation: string;
@@ -80,6 +94,8 @@ declare function pageScope(lesson: NotationLessonInput, sharedByKey: ReadonlyMap
80
94
  * reset before use — a stale one would skip the first label of the next span.
81
95
  */
82
96
  declare function equationLinkFor(lessonId: string, latex: string): EquationLink | undefined;
97
+ /** Decorations a lesson suspends (`notation.suspend`, rule N22), by name. */
98
+ declare function suspendedOperators(lesson: NotationLessonInput): string[];
83
99
  /**
84
100
  * Resolve every rendered-math context against the page glyph table. Returns one
85
101
  * diagnostic per unresolved token; an empty array means the gate passed.
@@ -90,4 +106,4 @@ declare function gateContentMath(input: {
90
106
  readonly lessonAssessments: readonly LessonAssessmentLink[];
91
107
  }): MathGateDiagnostic[];
92
108
 
93
- export { type EquationLink, type MathGateDiagnostic, type ScopeEntry, equationLinkFor, gateContentMath, pageScope };
109
+ export { type EquationLink, type MathGateDiagnostic, type MathSpan, type ScopeEntry, equationLinkFor, gateContentMath, mathSpans, pageScope, suspendedOperators };
@@ -12,7 +12,9 @@ const SEVERITY = {
12
12
  assessment: "error",
13
13
  prose: "error",
14
14
  abbreviation: "error",
15
- structure: "error"
15
+ structure: "error",
16
+ // Never read: `gateCourseRules` sets the severity the course chose.
17
+ course: "error"
16
18
  };
17
19
  const TERM_KEY = /\[\[\s*([a-z0-9]+(?:[.-][a-z0-9]+)*)\s*\]\]/g;
18
20
  const EXPLAIN_KEY = /\\explain\s*\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}/g;
@@ -38,7 +40,8 @@ function mathSpans(body) {
38
40
  spans.push({
39
41
  latex,
40
42
  line: lineOf(body, latexOffset),
41
- column: latexOffset - lastNewline
43
+ column: latexOffset - lastNewline,
44
+ offset: latexOffset
42
45
  });
43
46
  }
44
47
  }
@@ -71,7 +74,10 @@ function equationLinkFor(lessonId, latex) {
71
74
  const slug = lessonId.replace(/\./g, "/");
72
75
  return { key, href: `/${slug}/#${equationAnchorId({ key })}` };
73
76
  }
74
- function resolveInto(latex, scope, context, file, line, column, fix, out, equation) {
77
+ function suspendedOperators(lesson) {
78
+ return (lesson.suspended ?? []).map((item) => item.operator);
79
+ }
80
+ function resolveInto(latex, scope, context, file, line, column, fix, out, equation, suspended = []) {
75
81
  const at = equation === void 0 ? {} : { equation };
76
82
  for (const diagnostic of lintDelimiterSizes(latex)) {
77
83
  const locatedLine = line === void 0 ? void 0 : line + diagnostic.line - 1;
@@ -89,10 +95,15 @@ function resolveInto(latex, scope, context, file, line, column, fix, out, equati
89
95
  }
90
96
  let result;
91
97
  try {
92
- result = resolveMathGlyphs(stripEquationLabels(latex), [...scope]);
98
+ result = resolveMathGlyphs(stripEquationLabels(latex), [...scope], {
99
+ suspended
100
+ });
93
101
  } catch (error) {
94
102
  out.push({
95
103
  context,
104
+ // The resolver's own code (`unparenthesized-function`, `invalid-latex`,
105
+ // …) is what the rule catalog keys on; anything else is a KaTeX crash.
106
+ code: error instanceof GlyphResolutionError && typeof error.code === "string" ? error.code : "math-compile-error",
96
107
  severity: SEVERITY[context],
97
108
  file,
98
109
  line,
@@ -100,15 +111,31 @@ function resolveInto(latex, scope, context, file, line, column, fix, out, equati
100
111
  });
101
112
  return;
102
113
  }
103
- for (const { token } of result.unresolved) {
114
+ for (const item of result.unresolved) {
115
+ const { token } = item;
116
+ if ("suspended" in item && typeof item.suspended === "string") {
117
+ out.push({
118
+ context,
119
+ code: "suspended-operator-unresolved",
120
+ severity: SEVERITY[context],
121
+ file,
122
+ line,
123
+ token,
124
+ ...at,
125
+ message: `On this page ${JSON.stringify(item.suspended)} is suspended (notation.suspend), so ${JSON.stringify(token)} is not ${JSON.stringify(item.suspended)} applied to a symbol: it needs its own notation entry (latex: ${token}) or an \\explain{key}{${token}} wrap.`
126
+ });
127
+ continue;
128
+ }
129
+ const fontHint = "font" in item && typeof item.font === "string" ? ` ${token} is a different symbol from the plain letter: give it its own entry (latex: ${token}) or wrap it in \\explain{key}{${token}}.` : "";
104
130
  out.push({
105
131
  context,
132
+ code: "unresolved-glyph",
106
133
  severity: SEVERITY[context],
107
134
  file,
108
135
  line,
109
136
  token,
110
137
  ...at,
111
- message: `Unresolved notation ${JSON.stringify(token)} in ${context} math. ${fix}`
138
+ message: `Unresolved notation ${JSON.stringify(token)} in ${context} math.${fontHint} ${fix}`
112
139
  });
113
140
  }
114
141
  }
@@ -132,6 +159,9 @@ function reportUnknownScopeKeys(definition, cardKeys, file, out) {
132
159
  )) {
133
160
  out.push({
134
161
  context: "formula",
162
+ // Same failure the resolver reports for a body `\explain{key}` that
163
+ // names nothing — one code, one owner (`notation-references`).
164
+ code: "unknown-explicit-key",
135
165
  severity: SEVERITY.formula,
136
166
  file,
137
167
  token: key,
@@ -172,6 +202,7 @@ function gateContentMath(input) {
172
202
  for (const lesson of notationInput.lessons) {
173
203
  const scope = pageScope(lesson, sharedByKey);
174
204
  const file = lesson.source.file;
205
+ const suspended = suspendedOperators(lesson);
175
206
  const bodyOffset = (lesson.bodyLine ?? 1) - 1;
176
207
  for (const span of mathSpans(lesson.body)) {
177
208
  resolveInto(
@@ -183,7 +214,8 @@ function gateContentMath(input) {
183
214
  span.column,
184
215
  BODY_FIX,
185
216
  diagnostics,
186
- equationLinkFor(lesson.lessonId, span.latex)
217
+ equationLinkFor(lesson.lessonId, span.latex),
218
+ suspended
187
219
  );
188
220
  }
189
221
  for (const definition of lesson.localDefinitions) {
@@ -263,5 +295,7 @@ function gateContentMath(input) {
263
295
  export {
264
296
  equationLinkFor,
265
297
  gateContentMath,
266
- pageScope
298
+ mathSpans,
299
+ pageScope,
300
+ suspendedOperators
267
301
  };
@@ -0,0 +1,50 @@
1
+ import { N as NormalizedPartPages } from '../course-config-Dsnri9Tk.js';
2
+ import { MathGateDiagnostic } from './gate-math.js';
3
+ import '../rules/catalog.js';
4
+ import '../rules/course-rules.js';
5
+ import './types.js';
6
+
7
+ /**
8
+ * The part-page gate (rule S2): a part's overview page
9
+ * (`content/docs/<dir>/index.mdx`) states, in two authored paragraphs, how
10
+ * the course got here and where this part goes —
11
+ *
12
+ * - `<PartBridge>` — how the earlier parts lead into this one. Required on
13
+ * every part after the first; optional on the first, which has no earlier
14
+ * part to link from.
15
+ * - `<PartScope>` — what this part itself covers. Required on every part page.
16
+ *
17
+ * Each must appear once, hold exactly one paragraph, and fall inside the
18
+ * course's rule-S2 word range (`rules.S2.*`, see `partPageLimits`); the bridge comes
19
+ * first. What a paragraph *says* is Review-only — the gate checks that the
20
+ * slot is there and sized, not that its prose is about the right thing. See
21
+ * docs/authoring/structure-rules.md#s2.
22
+ *
23
+ * A part without an overview page is not in scope: overview pages stay
24
+ * opt-in, and this gate only shapes the ones a course authors. Ships straight
25
+ * as an error, same as S1.
26
+ */
27
+
28
+ /** One part overview page, as the manifest compiler loads it. */
29
+ interface PartPageInput {
30
+ readonly dir: string;
31
+ /** 1-based position in `courseConfig.parts`. */
32
+ readonly ordinal: number;
33
+ readonly file: string;
34
+ readonly body: string;
35
+ readonly bodyLine: number;
36
+ }
37
+ /**
38
+ * Words a reader reads in a paragraph of MDX prose. A math span, a `[[key]]`
39
+ * reference, or a link each count by what they render as: one token for the
40
+ * first two, the link text for the third. Citations and markup count nothing.
41
+ */
42
+ declare function proseWordCount(markdown: string): number;
43
+ /**
44
+ * Check every part overview page for its S2 paragraphs. Returns one
45
+ * `MathGateDiagnostic` (context `'structure'`, code `part-page-paragraph`,
46
+ * always an error) per finding.
47
+ */
48
+ declare function gatePartPages(pages: readonly PartPageInput[], limits: NormalizedPartPages): MathGateDiagnostic[];
49
+
50
+ export { type PartPageInput, gatePartPages, proseWordCount };
@@ -0,0 +1,118 @@
1
+ import { stripFencedCode } from "./equations.mjs";
2
+ import { lessonLine } from "./gate-prose-notation.js";
3
+ const TAG_BY_SLOT = {
4
+ bridge: "PartBridge",
5
+ scope: "PartScope"
6
+ };
7
+ const SUBJECT_BY_SLOT = {
8
+ bridge: "how the earlier parts lead into this one",
9
+ scope: "what this part covers"
10
+ };
11
+ function scannable(body) {
12
+ return stripFencedCode(body).replace(
13
+ /\{\/\*[\s\S]*?\*\/\}/g,
14
+ (comment) => comment.replace(/[^\n]/g, " ")
15
+ );
16
+ }
17
+ function occurrencesOf(text, tag) {
18
+ const pattern = new RegExp(
19
+ `<${tag}\\b[^>]*?(?:\\/>|>([\\s\\S]*?)<\\/${tag}\\s*>)`,
20
+ "g"
21
+ );
22
+ return [...text.matchAll(pattern)].map((match) => ({
23
+ index: match.index ?? 0,
24
+ content: match[1]
25
+ }));
26
+ }
27
+ function paragraphsOf(content) {
28
+ return content.trim().split(/\n[ \t]*\n/).filter((block) => block.trim().length > 0);
29
+ }
30
+ function proseWordCount(markdown) {
31
+ return markdown.replace(/\$\$[\s\S]*?\$\$/g, " math ").replace(/\$[^$\n]+\$/g, " math ").replace(/\[@[^\]]*\]/g, " ").replace(/\[\[[^\][]+\]\]/g, " term ").replace(/!?\[([^\]]*)\]\([^)]*\)/g, " $1 ").replace(/<\/?[A-Za-z][^>]*>/g, " ").split(/\s+/).filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
32
+ }
33
+ function slotFindings(page, text, slot, limits) {
34
+ const tag = TAG_BY_SLOT[slot];
35
+ const found = occurrencesOf(text, tag);
36
+ const required = slot === "scope" || page.ordinal > 1;
37
+ const out = [];
38
+ if (found.length === 0) {
39
+ if (required) {
40
+ out.push({
41
+ index: 0,
42
+ token: `<${tag}>`,
43
+ message: `Part ${page.ordinal} ("${page.dir}") has an overview page with no <${tag}> paragraph (rule S2). Add one paragraph on ${SUBJECT_BY_SLOT[slot]}, wrapped in <${tag}>\u2026</${tag}>.`
44
+ });
45
+ }
46
+ return out;
47
+ }
48
+ for (const extra of found.slice(1)) {
49
+ out.push({
50
+ index: extra.index,
51
+ token: `<${tag}>`,
52
+ message: `A part overview page carries one <${tag}> (rule S2); this is another. Merge the two into one paragraph.`
53
+ });
54
+ }
55
+ const [first] = found;
56
+ const paragraphs = paragraphsOf(first.content ?? "");
57
+ if (paragraphs.length === 0) {
58
+ out.push({
59
+ index: first.index,
60
+ token: `<${tag}>`,
61
+ message: `<${tag}> is empty (rule S2). Write one paragraph on ${SUBJECT_BY_SLOT[slot]} inside it.`
62
+ });
63
+ return out;
64
+ }
65
+ if (paragraphs.length > 1) {
66
+ out.push({
67
+ index: first.index,
68
+ token: `<${tag}>`,
69
+ message: `<${tag}> holds ${paragraphs.length} paragraphs; rule S2 allows one. Join them, or move the rest of the prose outside the slot.`
70
+ });
71
+ return out;
72
+ }
73
+ const { minWords, maxWords } = limits[slot];
74
+ const words = proseWordCount(paragraphs[0]);
75
+ if (words < minWords || words > maxWords) {
76
+ out.push({
77
+ index: first.index,
78
+ token: `<${tag}>`,
79
+ message: `<${tag}> is ${words} words; rule S2 wants ${minWords}\u2013${maxWords} (rules.S2.${slot}MinWords / ${slot}MaxWords in the course settings). ` + (words < minWords ? `Say more about ${SUBJECT_BY_SLOT[slot]}.` : "Cut it to the connections a reader needs before the chapter list.")
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+ function gatePartPages(pages, limits) {
85
+ const diagnostics = [];
86
+ for (const page of pages) {
87
+ const text = scannable(page.body);
88
+ const findings = [
89
+ ...slotFindings(page, text, "bridge", limits),
90
+ ...slotFindings(page, text, "scope", limits)
91
+ ];
92
+ const [bridge] = occurrencesOf(text, TAG_BY_SLOT.bridge);
93
+ const [scope] = occurrencesOf(text, TAG_BY_SLOT.scope);
94
+ if (bridge && scope && bridge.index > scope.index) {
95
+ findings.push({
96
+ index: bridge.index,
97
+ token: "<PartBridge>",
98
+ message: "<PartBridge> comes after <PartScope> (rule S2). Move it first: the reader needs the link from the earlier parts before this part's own scope."
99
+ });
100
+ }
101
+ for (const finding of findings.sort((a, b) => a.index - b.index)) {
102
+ diagnostics.push({
103
+ context: "structure",
104
+ severity: "error",
105
+ code: "part-page-paragraph",
106
+ file: page.file,
107
+ line: lessonLine(page, finding.index),
108
+ token: finding.token,
109
+ message: finding.message
110
+ });
111
+ }
112
+ }
113
+ return diagnostics;
114
+ }
115
+ export {
116
+ gatePartPages,
117
+ proseWordCount
118
+ };
@@ -6,7 +6,8 @@ import { NotationLessonInput } from './types.js';
6
6
  * completeness gate (`gate-math.ts`). Where that gate asks "does every glyph
7
7
  * *inside* `$…$` resolve", this one asks the complementary question: "is
8
8
  * anything that reads as math sitting *outside* `$…$` in the first place" —
9
- * a unit abbreviation, a division of two variable-like symbols, an escaped
9
+ * a unit abbreviation, a unit spelled out as a word next to a number or
10
+ * `per` (`unit-words.ts`), a division of two variable-like symbols, an escaped
10
11
  * absolute-value bar, a hand-typed sub/superscript. Both gates share one
11
12
  * `MathGateDiagnostic` shape and one output array (`manifest.diagnostics.math`)
12
13
  * so the CLI needs no separate wiring.
@@ -31,12 +32,13 @@ interface AssessmentLike {
31
32
  readonly items?: readonly AssessmentItemLike[];
32
33
  }
33
34
  /**
34
- * Blank code, math, reference, and JSX-tag spans so offsets don't shift.
35
- * Exported so `gate-abbreviations.ts` (N14) scans exactly the same "is this
36
- * really prose" surface as N9, instead of drifting with a second copy of
37
- * these regexes.
35
+ * `keepMath` leaves `$…$` / `$$…$$` in place (everything else still blanked,
36
+ * offsets unchanged) — for a check that needs to see what a math span sits
37
+ * next to, such as `$x = 3$ meters`.
38
38
  */
39
- declare function proseOnly(body: string): string;
39
+ declare function proseOnly(body: string, options?: {
40
+ readonly keepMath?: boolean;
41
+ }): string;
40
42
  /** Exported for the same reason as {@link proseOnly}. */
41
43
  declare function lineOf(text: string, index: number): number;
42
44
  /**
@@ -50,12 +52,20 @@ declare function lessonLine(lesson: {
50
52
  readonly body: string;
51
53
  readonly bodyLine?: number;
52
54
  }, index: number): number;
55
+ /** Rule N9's settings the gate reads (`rules.N9` in the course settings). */
56
+ interface ProseNotationOptions {
57
+ /** Unit words this course lets prose spell out (`rules.N9.allow`), singular or plural. */
58
+ readonly allowSpelledUnits?: readonly string[];
59
+ }
53
60
  /**
54
61
  * Scan every lesson body for plain-text math notation outside `$…$`. Returns
55
- * one `MathGateDiagnostic` (context `'prose'`, always an error — N9 has no
56
- * escape hatch) per finding.
62
+ * one `MathGateDiagnostic` (context `'prose'`, always an error) per finding:
63
+ * code `spelled-unit` for a unit written as a word ("2 meters per second
64
+ * squared", "30 degrees"), `prose-notation` for everything else. A place is
65
+ * excused with a waiver (`{/* explico-allow N9: reason *\/}`), applied by the
66
+ * manifest.
57
67
  */
58
- declare function gateProseNotation(lessons: readonly NotationLessonInput[]): MathGateDiagnostic[];
68
+ declare function gateProseNotation(lessons: readonly NotationLessonInput[], options?: ProseNotationOptions): MathGateDiagnostic[];
59
69
  /**
60
70
  * Scan every assessment `prompt` / `explanation` for the same plain-text
61
71
  * notation N9 forbids in a lesson body — `AssessmentRunner` renders both
@@ -66,6 +76,6 @@ declare function gateProseNotation(lessons: readonly NotationLessonInput[]): Mat
66
76
  * same `"<assessmentId> (<itemId>)"` shape `gate-math.ts` already uses for
67
77
  * this context.
68
78
  */
69
- declare function gateProseNotationInAssessments(assessments: readonly AssessmentLike[]): MathGateDiagnostic[];
79
+ declare function gateProseNotationInAssessments(assessments: readonly AssessmentLike[], options?: ProseNotationOptions): MathGateDiagnostic[];
70
80
 
71
- export { gateProseNotation, gateProseNotationInAssessments, lessonLine, lineOf, proseOnly };
81
+ export { type ProseNotationOptions, gateProseNotation, gateProseNotationInAssessments, lessonLine, lineOf, proseOnly };