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
@@ -4,23 +4,24 @@
4
4
  * Every display equation (`$$…$$`) in a lesson is numbered and given an `id`
5
5
  * from appearance order. When the caller supplies `numbering`
6
6
  * (`{ prefix, offset }` — see `resolveNumbering`), the number is
7
- * `‹part›.‹offset + n›`: the prefix is the **part**'s ordinal among the sidebar
8
- * groups, `n` counts every display equation in the lesson, and `offset` carries
9
- * the count from earlier lessons of the same part so the sequence runs
10
- * continuously across a part rather than restarting on every page. `##` and
11
- * `###` headings never affect it — `(2.4)` is the fourth display equation of
12
- * part 2, wherever in that part it sits. See `docs/authoring/structure.md` for
13
- * the level names. With no `numbering` the number falls back to the
14
- * `##`-section scheme `‹section›.‹indexInSection›` (used by hand-built test
15
- * trees and non-lesson pages). An author additionally
7
+ * `‹prefix›.‹offset + n›`: `n` counts every display equation in the lesson,
8
+ * and the course's `numbering.floats` scope decides the rest — `chapter` (the
9
+ * default) makes the prefix `‹part›.‹chapter›` with no offset, so `(2.2.4)` is
10
+ * the fourth display equation of part 2's second chapter; `part` makes it the
11
+ * part's ordinal alone, with `offset` carrying the count from earlier lessons
12
+ * of the part so the sequence runs on across it. `##` and `###` headings never
13
+ * affect it. See `docs/authoring/structure.md` for the level names. With no
14
+ * `numbering` the number falls back to the `##`-section scheme
15
+ * `‹section›.‹indexInSection›` (used by hand-built test trees and non-lesson
16
+ * pages). An author additionally
16
17
  * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
17
18
  * block (inside math, so the `:` is safe and the form matches amsmath) when
18
19
  * they want a *stable* anchor that survives edits and cross-page references:
19
20
  *
20
- * [[eq-discount-factor-def]] same page → "(2.4)" linked to #eq-…
21
+ * [[eq-discount-factor-def]] same page → "(2.2.4)" linked to #eq-…
21
22
  * [[foundations/present-value#eq-pv]] cross page → number from the manifest
22
23
  *
23
- * An unkeyed equation still gets a positional anchor (`#eq-2-4`) so it can be
24
+ * An unkeyed equation still gets a positional anchor (`#eq-2-2-4`) so it can be
24
25
  * deep-linked, but only a keyed equation is referenceable by name.
25
26
  *
26
27
  * `scanEquationLabels` (raw Markdown) and `collectEquationLabels` (mdast tree)
@@ -37,6 +38,28 @@ export const EQ_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
37
38
  export const EQ_LABEL_PATTERN =
38
39
  /\\label\{\s*eq:([a-z0-9]+(?:-[a-z0-9]+)*)\s*\}/g;
39
40
 
41
+ /**
42
+ * A `\caption{<short caption>}` anywhere in a math source — the equation's
43
+ * short caption, plain text with no nested braces. Global; the text in group
44
+ * 1. An equation takes only a short caption: it names the equation in the
45
+ * `[[eq-…]]` preview card and in the "Copy LaTeX — ‹caption›" tooltip, and is
46
+ * stripped before KaTeX exactly like `\label{}`.
47
+ */
48
+ export const EQ_CAPTION_PATTERN = /\\caption\{([^{}]*)\}/g;
49
+
50
+ /**
51
+ * The short caption a display equation carries, trimmed, or `null` when it
52
+ * has none. Only the first `\caption{}` counts; `remark-notation.mjs` fails a
53
+ * page that gives one equation two, or an empty one.
54
+ */
55
+ export function equationCaption(latex) {
56
+ EQ_CAPTION_PATTERN.lastIndex = 0;
57
+ const match = EQ_CAPTION_PATTERN.exec(String(latex ?? ''));
58
+ EQ_CAPTION_PATTERN.lastIndex = 0;
59
+ const text = match?.[1]?.replace(/\s+/g, ' ').trim();
60
+ return text ? text : null;
61
+ }
62
+
40
63
  /**
41
64
  * A `[[ … ]]` payload that targets an equation: `eq-<key>` on the current page,
42
65
  * or `<lesson-slug>#eq-<key>` across pages. `lesson` (group 1) is a `/`-slug.
@@ -52,10 +75,14 @@ export function parseEquationRef(raw) {
52
75
  return match[1] ? { lesson: match[1], key: match[2] } : { key: match[2] };
53
76
  }
54
77
 
55
- /** Remove every `\label{eq:…}` (and the whitespace it leaves) from a math source. */
78
+ /**
79
+ * Remove every `\label{eq:…}` and `\caption{…}` (and the whitespace they
80
+ * leave) from a math source: both are identity markers, not math KaTeX parses.
81
+ */
56
82
  export function stripEquationLabels(latex) {
57
83
  return String(latex)
58
84
  .replace(EQ_LABEL_PATTERN, '')
85
+ .replace(EQ_CAPTION_PATTERN, '')
59
86
  .replace(/[ \t]+\n/g, '\n')
60
87
  .replace(/\n{3,}/g, '\n\n')
61
88
  .replace(/[ \t]{2,}/g, ' ')
@@ -70,13 +97,14 @@ function equationNumber(section, index) {
70
97
  /**
71
98
  * Normalise the numbering input to `{ prefix, offset }`.
72
99
  *
73
- * `prefix` is the number before the dot — the **part**'s ordinal (its position
74
- * among the sidebar groups). `offset` is how many display equations already
75
- * appeared in earlier lessons of that same part, so `n` runs continuously
76
- * across a part instead of restarting on every page. A bare number is the
77
- * legacy per-lesson form (`prefix = n`, `offset = 0`) and is still accepted so
78
- * hand-built trees and unit tests keep working. `undefined`/`null`/`0` ⇒ the
79
- * `##`-section fallback for non-lesson pages.
100
+ * `prefix` is everything before the last dot — `‹part›.‹chapter›` under the
101
+ * default `numbering.floats: "chapter"`, the part's ordinal under `"part"`.
102
+ * `offset` is how many items of the kind precede the lesson in that scope: 0
103
+ * for a chapter, the earlier chapters' count for a part, so `n` runs on
104
+ * across it. A bare number is the legacy per-lesson form (`prefix = n`,
105
+ * `offset = 0`) and is still accepted so hand-built trees and unit tests keep
106
+ * working. `undefined`/`null`/`0`/`''` ⇒ the `##`-section fallback for
107
+ * non-lesson pages.
80
108
  */
81
109
  export function resolveNumbering(input) {
82
110
  if (input == null) return { prefix: undefined, offset: 0 };
@@ -90,9 +118,9 @@ export function resolveNumbering(input) {
90
118
  }
91
119
 
92
120
  /**
93
- * The visible number for one display equation. With a `prefix` (the part's
94
- * ordinal) the scheme is `‹part›.‹offset + indexInLesson›` and `##` headings
95
- * are ignored; without it, the legacy `##`-section scheme.
121
+ * The visible number for one display equation. With a `prefix` the scheme is
122
+ * `‹prefix›.‹offset + indexInLesson›` and `##` headings are ignored; without
123
+ * it, the legacy `##`-section scheme.
96
124
  */
97
125
  export function displayEquationNumber({
98
126
  prefix,
@@ -106,7 +134,13 @@ export function displayEquationNumber({
106
134
  : `${prefix}.${offset + indexInLesson}`;
107
135
  }
108
136
 
109
- /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
137
+ /**
138
+ * A display equation's anchor id: `eq-<key>` when it carries a
139
+ * `\label{eq:…}`, else the positional form, e.g. `2.4` → `eq-2-4`.
140
+ *
141
+ * @param {{ key?: string, number?: string | number }} equation - `number` is
142
+ * read only when there is no `key`, so a keyed caller may omit it.
143
+ */
110
144
  export function equationAnchorId({ key, number }) {
111
145
  return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
112
146
  }
@@ -130,10 +164,8 @@ export function stripFencedCode(markdown) {
130
164
  * component while still finding `<Table>`/`<Figure>` tags at the top level.
131
165
  *
132
166
  * `transparent`, when given, removes only those components' opening/closing
133
- * tags and exposes their body to the later blanking pass. `<Memo>` and
134
- * `<ShortNote>` are intentional uses: unlike a worked-example/presentation
135
- * wrapper, their bodies are details in the lesson's reading order, so numbered
136
- * equations/floats in them stay in the official sequence. Section headings in
167
+ * tags and exposes their body to the later blanking pass. The
168
+ * {@link TRANSPARENT_COMPONENTS} are the intentional uses. Section headings in
137
169
  * a transparent body do not change the surrounding page's section counter.
138
170
  */
139
171
  export function stripJsxComponents(markdown, keep, transparent = []) {
@@ -163,23 +195,35 @@ export function stripJsxComponents(markdown, keep, transparent = []) {
163
195
  );
164
196
  }
165
197
 
198
+ /**
199
+ * The components whose bodies stay in the lesson's reading order: `<Memo>`,
200
+ * `<ShortNote>` and `<Fold>`. Unlike a worked-example/presentation wrapper,
201
+ * their bodies are details or folded stretches of the lesson itself, so a
202
+ * numbered equation or float in one stays in the official sequence. Every
203
+ * scan and tree walk that numbers equations or floats reads this list.
204
+ */
205
+ export const TRANSPARENT_COMPONENTS = Object.freeze(['Memo', 'ShortNote', 'Fold']);
206
+
166
207
  /**
167
208
  * Every display equation in a Markdown body, keyed or not, in appearance order
168
- * with its assigned number. `$$…$$` blocks only. With `numbering`
169
- * (`{ prefix, offset }`, the part's ordinal and the count of display equations
170
- * in earlier lessons of that part) the number is `‹part›.‹offset + n›`, `n`
171
- * sequential over every display block in the lesson; without it, `##` headings
172
- * advance a section counter and the number is `‹section›.‹indexInSection›`.
209
+ * with its assigned number and its `\caption{}` short caption (`short`, or
210
+ * `null`). `$$…$$` blocks only. With `numbering`
211
+ * (`{ prefix, offset }`, the lesson's float-number prefix and the count of
212
+ * display equations before it in that scope) the number is
213
+ * `‹prefix›.‹offset + n›`, `n` sequential over every display block in the
214
+ * lesson; without it, `##` headings advance a section counter and the number
215
+ * is `‹section›.‹indexInSection›`.
173
216
  * Either way every display block advances the index, so a keyed equation's
174
217
  * number reflects its position among *all* display equations, not only the
175
218
  * keyed ones.
176
219
  */
177
220
  export function scanDisplayEquations(markdown, numbering) {
178
221
  const { prefix, offset } = resolveNumbering(numbering);
179
- const text = stripJsxComponents(stripFencedCode(markdown), undefined, [
180
- 'Memo',
181
- 'ShortNote',
182
- ]);
222
+ const text = stripJsxComponents(
223
+ stripFencedCode(markdown),
224
+ undefined,
225
+ TRANSPARENT_COMPONENTS,
226
+ );
183
227
  const tokens = [];
184
228
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
185
229
  const display = /\$\$([\s\S]+?)\$\$/g;
@@ -193,6 +237,7 @@ export function scanDisplayEquations(markdown, numbering) {
193
237
  kind: 'equation',
194
238
  index: match.index,
195
239
  key: label ? label[1] : null,
240
+ short: equationCaption(match[1]),
196
241
  });
197
242
  }
198
243
  tokens.sort((a, b) => a.index - b.index);
@@ -218,6 +263,7 @@ export function scanDisplayEquations(markdown, numbering) {
218
263
  indexInSection,
219
264
  indexInLesson,
220
265
  }),
266
+ short: token.short,
221
267
  section,
222
268
  indexInSection,
223
269
  });
@@ -255,11 +301,11 @@ export function collectDisplayEquations(tree, numbering) {
255
301
  let indexInSection = 0;
256
302
  let indexInLesson = 0;
257
303
 
258
- // Top-level blocks plus the bodies of `<Memo>` and `<ShortNote>`: other MDX
259
- // components (a `<CompactExample>` worked example, an `<Aside>`) remain
260
- // illustrative and outside the lesson's official equation sequence. These
261
- // two detail components are deliberately transparent because they occupy an
262
- // exact point in the reading order, even while visually collapsed.
304
+ // Top-level blocks plus the bodies of the `TRANSPARENT_COMPONENTS`: other
305
+ // MDX components (a `<CompactExample>` worked example, an `<Aside>`) remain
306
+ // illustrative and outside the lesson's official equation sequence. The
307
+ // transparent ones occupy an exact point in the reading order, even while
308
+ // visually collapsed.
263
309
  const collectFromDetail = (node) => {
264
310
  if (!node || typeof node !== 'object') return;
265
311
  if (node.type === 'math') {
@@ -269,6 +315,7 @@ export function collectDisplayEquations(tree, numbering) {
269
315
  const label = EQ_LABEL_PATTERN.exec(String(node.value ?? ''));
270
316
  equations.push({
271
317
  key: label ? label[1] : null,
318
+ short: equationCaption(node.value),
272
319
  number: displayEquationNumber({
273
320
  prefix,
274
321
  offset,
@@ -284,8 +331,7 @@ export function collectDisplayEquations(tree, numbering) {
284
331
  }
285
332
  if (
286
333
  node.type === 'mdxJsxFlowElement' &&
287
- node.name !== 'Memo' &&
288
- node.name !== 'ShortNote'
334
+ !TRANSPARENT_COMPONENTS.includes(node.name)
289
335
  ) {
290
336
  return;
291
337
  }
@@ -304,7 +350,7 @@ export function collectDisplayEquations(tree, numbering) {
304
350
  if (node.type === 'math') collectFromDetail(node);
305
351
  else if (
306
352
  node.type === 'mdxJsxFlowElement' &&
307
- (node.name === 'Memo' || node.name === 'ShortNote')
353
+ TRANSPARENT_COMPONENTS.includes(node.name)
308
354
  ) {
309
355
  collectFromDetail(node);
310
356
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `validateKeyedFloats` for `<CompactExamples>` — see
3
+ * `keyed-float-validate.ts`. A worked-example set is a numbered kind of its
4
+ * own, like a knowledge check (docs/plans/titles-and-containers.md Part 6,
5
+ * Q11): `Example ‹prefix›.‹n›`, anchored `#ex-‹id›`, cited with `[[ex-‹id›]]`.
6
+ *
7
+ * On top of the shared checks, both of the set's names are required
8
+ * (`ex-missing-prop`): the `id` its anchor and references need, and the
9
+ * `short` title its heading shows after the engine's "Example" badge. There is
10
+ * no default title to fall back on.
11
+ */
12
+
13
+ import {
14
+ validateKeyedFloats,
15
+ type KeyedFloatLabelRecord,
16
+ type KeyedFloatLessonInput,
17
+ type KeyedFloatValidation,
18
+ } from './keyed-float-validate';
19
+ import { scanKeyedJsxFloats } from './keyed-jsx-floats.mjs';
20
+
21
+ export type ExampleDiagnosticCode =
22
+ | 'ex-invalid-key'
23
+ | 'ex-duplicate-key'
24
+ | 'ex-ref-resolves'
25
+ | 'ex-key-unused'
26
+ | 'ex-removed-prop'
27
+ | 'ex-missing-prop';
28
+
29
+ export interface ExampleDiagnostic {
30
+ readonly code: ExampleDiagnosticCode;
31
+ readonly severity: 'error' | 'warning' | 'info';
32
+ readonly message: string;
33
+ readonly file?: string;
34
+ readonly lessonId?: string;
35
+ readonly key?: string;
36
+ }
37
+
38
+ export type ExampleLabelRecord = KeyedFloatLabelRecord;
39
+ export type ExampleLessonInput = KeyedFloatLessonInput;
40
+
41
+ export interface ExampleValidation extends KeyedFloatValidation {
42
+ readonly diagnostics: readonly ExampleDiagnostic[];
43
+ }
44
+
45
+ /** The props a set cannot do without, each with the fix its diagnostic gives. */
46
+ const REQUIRED_PROPS: Readonly<Record<string, string>> = {
47
+ id: 'give it an `id` (lowercase letters, digits and hyphens): the set is anchored `#ex-‹id›` and cited with `[[ex-‹id›]]`.',
48
+ short:
49
+ 'give it a `short` title naming the topic, e.g. "Converting between coordinate systems"; the engine draws "Example" and the number before it.',
50
+ };
51
+
52
+ export function validateExamples(
53
+ lessons: readonly ExampleLessonInput[],
54
+ ): ExampleValidation {
55
+ const shared = validateKeyedFloats(lessons, {
56
+ tagName: 'CompactExamples',
57
+ prefix: 'ex',
58
+ noun: 'worked-example set',
59
+ removedProps: {
60
+ summary:
61
+ 'rename it `short`: the set’s topical title, shown after the engine’s "Example" badge.',
62
+ },
63
+ });
64
+
65
+ const missing: ExampleDiagnostic[] = [];
66
+ for (const lesson of lessons) {
67
+ const sets = scanKeyedJsxFloats(lesson.body, {
68
+ tagName: 'CompactExamples',
69
+ numbering: lesson.numbering,
70
+ }) as { key: string | null; props: string[] }[];
71
+ for (const set of sets) {
72
+ for (const [prop, fix] of Object.entries(REQUIRED_PROPS)) {
73
+ if (set.props.includes(prop)) continue;
74
+ missing.push({
75
+ code: 'ex-missing-prop',
76
+ severity: 'error',
77
+ message: `${lesson.lessonId} has a <CompactExamples>${set.key ? ` (id="${set.key}")` : ''} with no \`${prop}\`: ${fix}`,
78
+ file: lesson.file,
79
+ lessonId: lesson.lessonId,
80
+ ...(set.key ? { key: set.key } : {}),
81
+ });
82
+ }
83
+ }
84
+ }
85
+
86
+ const diagnostics = [
87
+ ...(shared.diagnostics as readonly ExampleDiagnostic[]),
88
+ ...missing,
89
+ ].sort(
90
+ (a, b) =>
91
+ a.code.localeCompare(b.code) ||
92
+ (a.lessonId ?? '').localeCompare(b.lessonId ?? '') ||
93
+ (a.key ?? '').localeCompare(b.key ?? '') ||
94
+ a.message.localeCompare(b.message),
95
+ );
96
+ return { ...shared, diagnostics };
97
+ }
@@ -11,7 +11,8 @@ export type FigureDiagnosticCode =
11
11
  | 'fig-invalid-key'
12
12
  | 'fig-duplicate-key'
13
13
  | 'fig-ref-resolves'
14
- | 'fig-key-unused';
14
+ | 'fig-key-unused'
15
+ | 'fig-removed-prop';
15
16
 
16
17
  export interface FigureDiagnostic {
17
18
  readonly code: FigureDiagnosticCode;
@@ -36,5 +37,9 @@ export function validateFigures(
36
37
  tagName: 'Figure',
37
38
  prefix: 'fig',
38
39
  noun: 'figure',
40
+ removedProps: {
41
+ title:
42
+ 'a figure is named by its `caption` (the long caption, shown below it with its number) and an optional `short` caption; move a short title to `short`.',
43
+ },
39
44
  }) as FigureValidation;
40
45
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `<Fold>` validation, run inside `compileManifest()`
3
+ * (docs/plans/figures-and-short-captions.md, decision 11). A fold's bar is
4
+ * its `label` or, without one, the numbers and short captions of the floats
5
+ * it holds (`../components/float-name.ts`), so a fold needs one or the other:
6
+ *
7
+ * - `fold-unnamed` (error): a `<Fold>` with no `label` and no `<Figure>`,
8
+ * `<Table>` or `<Diagram>` that takes a number inside it — its bar would
9
+ * have nothing to show.
10
+ *
11
+ * `Fold.astro` throws on the same fold when it renders; this reports it
12
+ * earlier, at `validate:content`, with the lesson it is in. A float that a
13
+ * wrapper component renders counts when the lesson writes it under a float's
14
+ * tag name (`<Diagram id="…" />` for a wrapper imported as `Diagram`), the
15
+ * way the numbering scan sees it.
16
+ */
17
+
18
+ import { stripFencedCode } from './equations.mjs';
19
+ import { scanKeyedJsxFloats } from './keyed-jsx-floats.mjs';
20
+ import type { KeyedFloatLessonInput } from './keyed-float-validate';
21
+
22
+ export type FoldDiagnosticCode = 'fold-unnamed';
23
+
24
+ export interface FoldDiagnostic {
25
+ readonly code: FoldDiagnosticCode;
26
+ readonly severity: 'error';
27
+ readonly message: string;
28
+ readonly file?: string;
29
+ readonly lessonId?: string;
30
+ }
31
+
32
+ export interface FoldValidation {
33
+ readonly diagnostics: readonly FoldDiagnostic[];
34
+ }
35
+
36
+ const ATTRIBUTES = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'])*`;
37
+ const FOLD = new RegExp(`<Fold\\b(${ATTRIBUTES})>([\\s\\S]*?)<\\/Fold\\s*>`, 'g');
38
+ const NUMBERED_FLOATS = ['Figure', 'Table', 'Diagram'] as const;
39
+
40
+ export function validateFolds(
41
+ lessons: readonly KeyedFloatLessonInput[],
42
+ ): FoldValidation {
43
+ const diagnostics: FoldDiagnostic[] = [];
44
+ for (const lesson of [...lessons].sort((a, b) =>
45
+ a.lessonId.localeCompare(b.lessonId),
46
+ )) {
47
+ for (const [, attributes = '', body = ''] of stripFencedCode(lesson.body).matchAll(
48
+ FOLD,
49
+ )) {
50
+ if (/(?:^|\s)label\s*=/.test(attributes)) continue;
51
+ const named = NUMBERED_FLOATS.some(
52
+ (tagName) => scanKeyedJsxFloats(body, { tagName, numbering: undefined }).length > 0,
53
+ );
54
+ if (named) continue;
55
+ diagnostics.push({
56
+ code: 'fold-unnamed',
57
+ severity: 'error',
58
+ message: `${lesson.lessonId} has a <Fold> with no \`label\` and no numbered <Figure>, <Table> or <Diagram> inside it, so its bar has nothing to name it by: give it a \`label\`, or fold a numbered float with it.`,
59
+ file: lesson.file,
60
+ lessonId: lesson.lessonId,
61
+ });
62
+ }
63
+ }
64
+ return { diagnostics };
65
+ }
@@ -25,7 +25,7 @@ import { collectOperators, collectRomanLabels } from './math-glyphs.mjs';
25
25
  import { isBaseAbbreviation } from './base-abbreviations.mjs';
26
26
  import { pageScope } from './gate-math';
27
27
  import type { MathGateDiagnostic, ScopeEntry } from './gate-math';
28
- import { lineOf, proseOnly } from './gate-prose-notation';
28
+ import { lessonLine, proseOnly } from './gate-prose-notation';
29
29
  import type {
30
30
  AbbreviationDefinitionInput,
31
31
  NotationRegistryInput,
@@ -115,9 +115,10 @@ export function gateAbbreviations(input: {
115
115
  reported.add(token);
116
116
  diagnostics.push({
117
117
  context: 'abbreviation',
118
+ code: 'abbreviation-unexplained',
118
119
  severity: 'error',
119
120
  file: lesson.source.file,
120
- line: lineOf(lesson.body, index),
121
+ line: lessonLine(lesson, index),
121
122
  token,
122
123
  message: `Unexplained abbreviation ${JSON.stringify(token)} outside math. ${FIX}`,
123
124
  });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The gate for a course's own rules (`courseRules` in `course.settings.json`,
3
+ * resolved by `src/rules/course-rules.ts`).
4
+ *
5
+ * One loop over every enabled course rule's `forbid` patterns, matched against
6
+ * the same "is this really prose" surface rule N9 uses — `proseOnly` blanks
7
+ * code fences, inline code, `$…$` / `$$…$$`, `[[key]]` references, `import`
8
+ * lines, JSX tags and MDX comments to spaces, so offsets still point at the
9
+ * source. A match becomes a `MathGateDiagnostic` with the rule's own ID as its
10
+ * code and a lesson `file` + `line`, which is what lets `{/* explico-allow X1:
11
+ * reason *\/}` waive one place.
12
+ *
13
+ * Nothing here knows what a rule *means*: the patterns come from the course,
14
+ * already validated (they compile, and none matches the empty string). The
15
+ * three scopes are the three units an author can reason about — a paragraph, a
16
+ * whole lesson's prose, a heading.
17
+ */
18
+ import type { MathGateDiagnostic } from './gate-math';
19
+ import { lessonLine, proseOnly } from './gate-prose-notation';
20
+ import type { NotationLessonInput } from './types';
21
+ import type { CourseRule, CourseRulePattern } from '../rules/course-rules';
22
+
23
+ /** A stretch of text to match, with its offset in the (blanked) body. */
24
+ interface Unit {
25
+ readonly text: string;
26
+ readonly index: number;
27
+ }
28
+
29
+ const HEADING = /^[ \t]{0,3}(#{1,6})[ \t]+(.*)$/;
30
+
31
+ /**
32
+ * Consecutive non-blank lines, as one unit each, right-trimmed so `$` means
33
+ * "end of the paragraph" — a paragraph whose last visible thing is math or a
34
+ * JSX tag ends in the spaces `proseOnly` left behind, and the author means the
35
+ * prose before it.
36
+ */
37
+ function paragraphs(text: string): Unit[] {
38
+ const units: Unit[] = [];
39
+ let index = 0;
40
+ let start = -1;
41
+ const lines = text.split('\n');
42
+ lines.forEach((line, i) => {
43
+ const blank = line.trim() === '';
44
+ if (!blank && start === -1) start = index;
45
+ if ((blank || i === lines.length - 1) && start !== -1) {
46
+ const end = blank ? index : index + line.length;
47
+ units.push({ text: text.slice(start, end).replace(/\s+$/, ''), index: start });
48
+ start = -1;
49
+ }
50
+ index += line.length + 1;
51
+ });
52
+ return units.filter((unit) => unit.text !== '');
53
+ }
54
+
55
+ /** The text of each Markdown heading, without its `#`s. */
56
+ function headings(text: string): Unit[] {
57
+ const units: Unit[] = [];
58
+ let index = 0;
59
+ for (const line of text.split('\n')) {
60
+ const match = HEADING.exec(line);
61
+ if (match !== null) {
62
+ const body = match[2]!.replace(/\s+#*\s*$/, '');
63
+ if (body !== '') {
64
+ units.push({ text: body, index: index + line.indexOf(body) });
65
+ }
66
+ }
67
+ index += line.length + 1;
68
+ }
69
+ return units;
70
+ }
71
+
72
+ function unitsFor(scope: CourseRulePattern['scope'], prose: string): Unit[] {
73
+ switch (scope) {
74
+ case 'prose':
75
+ return [{ text: prose, index: 0 }];
76
+ case 'heading':
77
+ return headings(prose);
78
+ case 'paragraph':
79
+ return paragraphs(prose);
80
+ }
81
+ }
82
+
83
+ /** Every match of `pattern` in `unit`, as `[offset in the body, matched text]`. */
84
+ function* matches(pattern: CourseRulePattern, unit: Unit): Generator<[number, string]> {
85
+ const regex = new RegExp(pattern.pattern, `${pattern.flags}g`);
86
+ let match: RegExpExecArray | null;
87
+ while ((match = regex.exec(unit.text)) !== null) {
88
+ yield [unit.index + match.index, match[0]];
89
+ // A zero-length match cannot happen (the pattern is rejected at resolve
90
+ // time if it matches the empty string), but a group could still make one.
91
+ if (match.index === regex.lastIndex) regex.lastIndex += 1;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Scan every lesson body for what the course's own rules forbid. Only enabled
97
+ * rules with at least one pattern do anything; a guide rule is not this gate's
98
+ * business. Findings carry the rule ID as `code`, so `ownerOf` / `ruleIdOf`
99
+ * name the rule and a waiver can target it.
100
+ */
101
+ export function gateCourseRules(
102
+ lessons: readonly NotationLessonInput[],
103
+ rules: readonly CourseRule[],
104
+ ): MathGateDiagnostic[] {
105
+ const active = rules.filter((rule) => rule.enabled && rule.forbid.length > 0);
106
+ if (active.length === 0) return [];
107
+
108
+ const diagnostics: MathGateDiagnostic[] = [];
109
+ for (const lesson of lessons) {
110
+ const prose = proseOnly(lesson.body);
111
+ const cache = new Map<CourseRulePattern['scope'], Unit[]>();
112
+ for (const rule of active) {
113
+ for (const pattern of rule.forbid) {
114
+ if (!cache.has(pattern.scope)) cache.set(pattern.scope, unitsFor(pattern.scope, prose));
115
+ for (const unit of cache.get(pattern.scope)!) {
116
+ for (const [index, token] of matches(pattern, unit)) {
117
+ diagnostics.push({
118
+ context: 'course',
119
+ code: rule.id,
120
+ severity: rule.severity,
121
+ file: lesson.source.file,
122
+ line: lessonLine(lesson, index),
123
+ // The matched text, trimmed — unless the pattern deliberately
124
+ // matched whitespace, in which case there is nothing else to show.
125
+ token: token.trim() || token,
126
+ message: `${pattern.message} (course rule ${rule.id})`,
127
+ });
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ return diagnostics;
134
+ }