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
@@ -1,7 +1,21 @@
1
1
  import { NotationRegistryInput, NotationLessonInput } from './types.js';
2
2
 
3
+ /**
4
+ * The equation a finding sits in, when its math carries a `\label{eq:…}`.
5
+ *
6
+ * A location tells you where to edit; this tells you *what you are looking
7
+ * at*. A size-only N17 finding inside a six-line `aligned` block is otherwise
8
+ * anonymous — the message names delimiters, not the result they belong to —
9
+ * so the key and the rendered page's anchor both travel with the diagnostic.
10
+ */
11
+ interface EquationLink {
12
+ /** The label's key, without the `eq:` prefix. */
13
+ readonly key: string;
14
+ /** Where the reader sees it, e.g. `/l1-ar/ar1-basics/#eq-ar1-variance`. */
15
+ readonly href: string;
16
+ }
3
17
  interface MathGateDiagnostic {
4
- readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
18
+ readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure' | 'course';
5
19
  /**
6
20
  * Body / component-slot math blocks the build (the render path needs every
7
21
  * glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
@@ -20,17 +34,24 @@ interface MathGateDiagnostic {
20
34
  * `gate-abbreviations.ts`) is the same "ships straight as an error" story
21
35
  * for a different shape of plain-text token — an unexplained acronym or
22
36
  * dotted abbreviation, rather than a mis-typed quantity/unit. `structure`
23
- * (S1, `gate-heading-length.ts`) is unrelated to notation — it caps how
24
- * 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
25
40
  * diagnostic shape and the one `manifest.diagnostics.math` array rather
26
- * 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.
27
45
  */
28
46
  readonly severity: 'error' | 'warning';
29
- readonly code?: string;
47
+ /** Owned by exactly one rule or invariant in `src/rules/catalog.ts`. */
48
+ readonly code: string;
30
49
  readonly file: string;
31
50
  readonly line?: number;
32
51
  readonly column?: number;
33
52
  readonly token?: string;
53
+ /** Present when the offending math carries a `\label{eq:…}`. */
54
+ readonly equation?: EquationLink;
34
55
  readonly message: string;
35
56
  }
36
57
  interface AssessmentItemLike {
@@ -46,6 +67,15 @@ interface LessonAssessmentLink {
46
67
  readonly lessonId: string;
47
68
  readonly assessmentIds: readonly string[];
48
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[];
49
79
  type ScopeEntry = {
50
80
  key: string;
51
81
  notation: string;
@@ -58,6 +88,14 @@ type ScopeEntry = {
58
88
  * hand and risk drifting from what the completeness gate actually checks.
59
89
  */
60
90
  declare function pageScope(lesson: NotationLessonInput, sharedByKey: ReadonlyMap<string, ScopeEntry>): ScopeEntry[];
91
+ /**
92
+ * The `\label{eq:…}` a math source carries, as a key plus the anchor the
93
+ * rendered page gives it. `EQ_LABEL_PATTERN` is global, so its `lastIndex` is
94
+ * reset before use — a stale one would skip the first label of the next span.
95
+ */
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[];
61
99
  /**
62
100
  * Resolve every rendered-math context against the page glyph table. Returns one
63
101
  * diagnostic per unresolved token; an empty array means the gate passed.
@@ -68,4 +106,4 @@ declare function gateContentMath(input: {
68
106
  readonly lessonAssessments: readonly LessonAssessmentLink[];
69
107
  }): MathGateDiagnostic[];
70
108
 
71
- export { type MathGateDiagnostic, type ScopeEntry, gateContentMath, pageScope };
109
+ export { type EquationLink, type MathGateDiagnostic, type MathSpan, type ScopeEntry, equationLinkFor, gateContentMath, mathSpans, pageScope, suspendedOperators };
@@ -1,5 +1,9 @@
1
1
  import { resolveMathGlyphs, GlyphResolutionError } from "./math-glyphs.mjs";
2
- import { stripEquationLabels } from "./equations.mjs";
2
+ import {
3
+ EQ_LABEL_PATTERN,
4
+ equationAnchorId,
5
+ stripEquationLabels
6
+ } from "./equations.mjs";
3
7
  import { lintDelimiterSizes } from "./delimiter-size.js";
4
8
  import { formulaGlyphScope, unknownFormulaScopeKeys } from "./gloss.js";
5
9
  const SEVERITY = {
@@ -8,7 +12,9 @@ const SEVERITY = {
8
12
  assessment: "error",
9
13
  prose: "error",
10
14
  abbreviation: "error",
11
- structure: "error"
15
+ structure: "error",
16
+ // Never read: `gateCourseRules` sets the severity the course chose.
17
+ course: "error"
12
18
  };
13
19
  const TERM_KEY = /\[\[\s*([a-z0-9]+(?:[.-][a-z0-9]+)*)\s*\]\]/g;
14
20
  const EXPLAIN_KEY = /\\explain\s*\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}/g;
@@ -34,7 +40,8 @@ function mathSpans(body) {
34
40
  spans.push({
35
41
  latex,
36
42
  line: lineOf(body, latexOffset),
37
- column: latexOffset - lastNewline
43
+ column: latexOffset - lastNewline,
44
+ offset: latexOffset
38
45
  });
39
46
  }
40
47
  }
@@ -58,7 +65,20 @@ function pageScope(lesson, sharedByKey) {
58
65
  }
59
66
  return scope;
60
67
  }
61
- function resolveInto(latex, scope, context, file, line, column, fix, out) {
68
+ function equationLinkFor(lessonId, latex) {
69
+ EQ_LABEL_PATTERN.lastIndex = 0;
70
+ const match = EQ_LABEL_PATTERN.exec(latex);
71
+ EQ_LABEL_PATTERN.lastIndex = 0;
72
+ if (!match) return void 0;
73
+ const key = match[1];
74
+ const slug = lessonId.replace(/\./g, "/");
75
+ return { key, href: `/${slug}/#${equationAnchorId({ key })}` };
76
+ }
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 = []) {
81
+ const at = equation === void 0 ? {} : { equation };
62
82
  for (const diagnostic of lintDelimiterSizes(latex)) {
63
83
  const locatedLine = line === void 0 ? void 0 : line + diagnostic.line - 1;
64
84
  const locatedColumn = column === void 0 ? void 0 : diagnostic.line === 1 ? column + diagnostic.column - 1 : diagnostic.column;
@@ -69,15 +89,21 @@ function resolveInto(latex, scope, context, file, line, column, fix, out) {
69
89
  file,
70
90
  ...locatedLine === void 0 ? {} : { line: locatedLine },
71
91
  ...locatedColumn === void 0 ? {} : { column: locatedColumn },
92
+ ...at,
72
93
  message: diagnostic.message
73
94
  });
74
95
  }
75
96
  let result;
76
97
  try {
77
- result = resolveMathGlyphs(stripEquationLabels(latex), [...scope]);
98
+ result = resolveMathGlyphs(stripEquationLabels(latex), [...scope], {
99
+ suspended
100
+ });
78
101
  } catch (error) {
79
102
  out.push({
80
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",
81
107
  severity: SEVERITY[context],
82
108
  file,
83
109
  line,
@@ -85,14 +111,31 @@ function resolveInto(latex, scope, context, file, line, column, fix, out) {
85
111
  });
86
112
  return;
87
113
  }
88
- 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}}.` : "";
89
130
  out.push({
90
131
  context,
132
+ code: "unresolved-glyph",
91
133
  severity: SEVERITY[context],
92
134
  file,
93
135
  line,
94
136
  token,
95
- message: `Unresolved notation ${JSON.stringify(token)} in ${context} math. ${fix}`
137
+ ...at,
138
+ message: `Unresolved notation ${JSON.stringify(token)} in ${context} math.${fontHint} ${fix}`
96
139
  });
97
140
  }
98
141
  }
@@ -116,6 +159,9 @@ function reportUnknownScopeKeys(definition, cardKeys, file, out) {
116
159
  )) {
117
160
  out.push({
118
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",
119
165
  severity: SEVERITY.formula,
120
166
  file,
121
167
  token: key,
@@ -156,16 +202,20 @@ function gateContentMath(input) {
156
202
  for (const lesson of notationInput.lessons) {
157
203
  const scope = pageScope(lesson, sharedByKey);
158
204
  const file = lesson.source.file;
205
+ const suspended = suspendedOperators(lesson);
206
+ const bodyOffset = (lesson.bodyLine ?? 1) - 1;
159
207
  for (const span of mathSpans(lesson.body)) {
160
208
  resolveInto(
161
209
  span.latex,
162
210
  scope,
163
211
  "body",
164
212
  file,
165
- span.line,
213
+ span.line + bodyOffset,
166
214
  span.column,
167
215
  BODY_FIX,
168
- diagnostics
216
+ diagnostics,
217
+ equationLinkFor(lesson.lessonId, span.latex),
218
+ suspended
169
219
  );
170
220
  }
171
221
  for (const definition of lesson.localDefinitions) {
@@ -243,6 +293,9 @@ function gateContentMath(input) {
243
293
  return diagnostics;
244
294
  }
245
295
  export {
296
+ equationLinkFor,
246
297
  gateContentMath,
247
- pageScope
298
+ mathSpans,
299
+ pageScope,
300
+ suspendedOperators
248
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,20 +32,40 @@ 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;
44
+ /**
45
+ * {@link lineOf} corrected to the file the lesson came from. A gate scans
46
+ * `lesson.body`, which starts *after* the frontmatter, so a raw body line is
47
+ * short by the frontmatter's height and points at the wrong source line —
48
+ * silently, because the number is still plausible. Every gate that reports a
49
+ * `line` against `lesson.source.file` must go through this.
50
+ */
51
+ declare function lessonLine(lesson: {
52
+ readonly body: string;
53
+ readonly bodyLine?: number;
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
+ }
42
60
  /**
43
61
  * Scan every lesson body for plain-text math notation outside `$…$`. Returns
44
- * one `MathGateDiagnostic` (context `'prose'`, always an error — N9 has no
45
- * 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.
46
67
  */
47
- declare function gateProseNotation(lessons: readonly NotationLessonInput[]): MathGateDiagnostic[];
68
+ declare function gateProseNotation(lessons: readonly NotationLessonInput[], options?: ProseNotationOptions): MathGateDiagnostic[];
48
69
  /**
49
70
  * Scan every assessment `prompt` / `explanation` for the same plain-text
50
71
  * notation N9 forbids in a lesson body — `AssessmentRunner` renders both
@@ -55,6 +76,6 @@ declare function gateProseNotation(lessons: readonly NotationLessonInput[]): Mat
55
76
  * same `"<assessmentId> (<itemId>)"` shape `gate-math.ts` already uses for
56
77
  * this context.
57
78
  */
58
- declare function gateProseNotationInAssessments(assessments: readonly AssessmentLike[]): MathGateDiagnostic[];
79
+ declare function gateProseNotationInAssessments(assessments: readonly AssessmentLike[], options?: ProseNotationOptions): MathGateDiagnostic[];
59
80
 
60
- export { gateProseNotation, gateProseNotationInAssessments, lineOf, proseOnly };
81
+ export { type ProseNotationOptions, gateProseNotation, gateProseNotationInAssessments, lessonLine, lineOf, proseOnly };
@@ -1,3 +1,4 @@
1
+ import { findSpelledUnits } from "./unit-words.js";
1
2
  const CODE_FENCE = /```[\s\S]*?```/g;
2
3
  const INLINE_CODE = /(^|[^`])`[^`\n]*`/g;
3
4
  const DISPLAY_MATH = /\$\$([\s\S]+?)\$\$/g;
@@ -5,13 +6,19 @@ const INLINE_MATH = /(?<![\\$])\$(?!\s)([^$\n]+?)(?<![\\\s])\$(?!\d)/g;
5
6
  const IMPORT_LINE = /^[ \t]*import\b[^\n]*$/gm;
6
7
  const KEY_REFERENCE = /\[\[[^\]\n]*\]\]/g;
7
8
  const JSX_TAG = /<\/?[A-Za-z][^<>]*>/g;
8
- function proseOnly(body) {
9
+ const MDX_COMMENT = /\{\/\*[\s\S]*?\*\/\}/g;
10
+ function proseOnly(body, options = {}) {
9
11
  const blank = (match) => match.replace(/[^\n]/g, " ");
10
- return body.replace(CODE_FENCE, blank).replace(INLINE_CODE, (match, lead) => lead + blank(match.slice(lead.length))).replace(DISPLAY_MATH, blank).replace(INLINE_MATH, blank).replace(IMPORT_LINE, blank).replace(KEY_REFERENCE, blank).replace(JSX_TAG, blank);
12
+ const code = body.replace(MDX_COMMENT, blank).replace(CODE_FENCE, blank).replace(INLINE_CODE, (match, lead) => lead + blank(match.slice(lead.length)));
13
+ const math = options.keepMath ? code : code.replace(DISPLAY_MATH, blank).replace(INLINE_MATH, blank);
14
+ return math.replace(IMPORT_LINE, blank).replace(KEY_REFERENCE, blank).replace(JSX_TAG, blank);
11
15
  }
12
16
  function lineOf(text, index) {
13
17
  return text.slice(0, index).split("\n").length;
14
18
  }
19
+ function lessonLine(lesson, index) {
20
+ return lineOf(lesson.body, index) + (lesson.bodyLine ?? 1) - 1;
21
+ }
15
22
  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`;
16
23
  const NUMERAL_UNIT = new RegExp(
17
24
  String.raw`(?<![\w.])-?\d+(?:\.\d+)?\s?(?:${UNIT})(?![A-Za-z])`,
@@ -37,6 +44,7 @@ const SLASH_IDIOMS = /* @__PURE__ */ new Set([
37
44
  ]);
38
45
  const SLASH_TOKENS = /\b([A-Za-z]{1,6})\/([A-Za-z]{1,6})\b/g;
39
46
  const FAKE_SCRIPT = /[A-Za-z0-9][_^][A-Za-z0-9{]/g;
47
+ const PROSE_NOTATION_CODE = "prose-notation";
40
48
  function findAll(text, pattern, toFinding) {
41
49
  const out = [];
42
50
  const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
@@ -48,8 +56,17 @@ function findAll(text, pattern, toFinding) {
48
56
  return out;
49
57
  }
50
58
  const FIX = "Wrap it in $\u2026$ (or $$\u2026$$) instead \u2014 see docs/authoring/notation.md#n9.";
51
- function findingsIn(text) {
59
+ function findingsIn(text, withMath, options) {
60
+ const spelled = findSpelledUnits(text, withMath, options.allowSpelledUnits).map(
61
+ (unit) => ({
62
+ index: unit.index,
63
+ token: unit.token,
64
+ code: "spelled-unit",
65
+ message: `Spelled-out unit ${JSON.stringify(unit.token)}: write it as ${unit.suggestion}. A unit that quantifies something is an upright symbol inside $\u2026$, even in flowing prose. Where the sentence defines or names the unit itself, waive the block with {/* explico-allow N9: reason */} \u2014 see docs/authoring/notation.md#n9.`
66
+ })
67
+ );
52
68
  return [
69
+ ...spelled,
53
70
  ...findAll(text, /\\\|/g, (m) => ({
54
71
  index: m.index,
55
72
  token: m[0],
@@ -80,16 +97,18 @@ function findingsIn(text) {
80
97
  }).filter(Boolean)
81
98
  ];
82
99
  }
83
- function gateProseNotation(lessons) {
100
+ function gateProseNotation(lessons, options = {}) {
84
101
  const diagnostics = [];
85
102
  for (const lesson of lessons) {
86
103
  const text = proseOnly(lesson.body);
87
- for (const finding of findingsIn(text)) {
104
+ const withMath = proseOnly(lesson.body, { keepMath: true });
105
+ for (const finding of findingsIn(text, withMath, options)) {
88
106
  diagnostics.push({
89
107
  context: "prose",
108
+ code: finding.code ?? PROSE_NOTATION_CODE,
90
109
  severity: "error",
91
110
  file: lesson.source.file,
92
- line: lineOf(lesson.body, finding.index),
111
+ line: lessonLine(lesson, finding.index),
93
112
  token: finding.token,
94
113
  message: finding.message
95
114
  });
@@ -97,15 +116,17 @@ function gateProseNotation(lessons) {
97
116
  }
98
117
  return diagnostics;
99
118
  }
100
- function gateProseNotationInAssessments(assessments) {
119
+ function gateProseNotationInAssessments(assessments, options = {}) {
101
120
  const diagnostics = [];
102
121
  for (const assessment of assessments) {
103
122
  for (const item of assessment.items ?? []) {
104
123
  for (const field of [item.prompt, item.explanation]) {
105
124
  if (typeof field !== "string") continue;
106
- for (const finding of findingsIn(proseOnly(field))) {
125
+ const withMath = proseOnly(field, { keepMath: true });
126
+ for (const finding of findingsIn(proseOnly(field), withMath, options)) {
107
127
  diagnostics.push({
108
128
  context: "prose",
129
+ code: finding.code ?? PROSE_NOTATION_CODE,
109
130
  severity: "error",
110
131
  file: `${assessment.id} (${item.id ?? "?"})`,
111
132
  token: finding.token,
@@ -120,6 +141,7 @@ function gateProseNotationInAssessments(assessments) {
120
141
  export {
121
142
  gateProseNotation,
122
143
  gateProseNotationInAssessments,
144
+ lessonLine,
123
145
  lineOf,
124
146
  proseOnly
125
147
  };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Exported for the test that pins both failure messages — a KaTeX upgrade that
3
+ * moves the constant, and a leftover `patches/` file, are the two ways this
4
+ * stops being able to install itself.
5
+ */
6
+ export function rewriteBundle(source: any): any;
7
+ /**
8
+ * Render a known nest and confirm the outer pair really is larger than the
9
+ * inner one. Cheap, and it turns every way this could go wrong — an upstream
10
+ * refactor, a frozen export, an import that beat us to it — into one loud
11
+ * failure at the point of installation.
12
+ */
13
+ export function assertDelimiterGrowth(instance?: typeof katex): void;
14
+ export default katex;
15
+ import katex from 'katex';