explico 0.11.1 → 0.13.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 (298) hide show
  1. package/CHANGELOG.md +1292 -1
  2. package/README.md +91 -13
  3. package/dist/compiler/astro.d.ts +11 -4
  4. package/dist/compiler/astro.js +48 -15
  5. package/dist/compiler/cli.d.ts +27 -12
  6. package/dist/compiler/cli.js +96 -25
  7. package/dist/compiler/collections.d.ts +33 -2
  8. package/dist/compiler/collections.js +91 -19
  9. package/dist/compiler/content-schema.d.ts +31 -0
  10. package/dist/compiler/content-schema.js +82 -0
  11. package/dist/compiler/content-schemas.d.ts +422 -0
  12. package/dist/compiler/content-schemas.js +229 -0
  13. package/dist/compiler/course-config.d.ts +1 -1
  14. package/dist/compiler/course-config.js +30 -2
  15. package/dist/compiler/exports/combined-html.d.ts +97 -0
  16. package/dist/compiler/exports/combined-html.js +492 -0
  17. package/dist/compiler/exports/download-index.d.ts +9 -0
  18. package/dist/compiler/exports/download-index.js +33 -0
  19. package/dist/compiler/exports/export-chapters.d.ts +98 -0
  20. package/dist/compiler/exports/export-chapters.js +164 -0
  21. package/dist/compiler/exports/export-pages.d.ts +48 -0
  22. package/dist/compiler/exports/export-pages.js +22 -0
  23. package/dist/compiler/exports/html-tree.d.ts +22 -0
  24. package/dist/compiler/exports/html-tree.js +47 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +106 -0
  26. package/dist/compiler/exports/latex/capture.js +435 -0
  27. package/dist/compiler/exports/latex/compile.d.ts +73 -0
  28. package/dist/compiler/exports/latex/compile.js +115 -0
  29. package/dist/compiler/exports/latex/document.d.ts +102 -0
  30. package/dist/compiler/exports/latex/document.js +349 -0
  31. package/dist/compiler/exports/latex/explico.cls +415 -0
  32. package/dist/compiler/exports/latex/numbering.d.ts +18 -0
  33. package/dist/compiler/exports/latex/numbering.js +57 -0
  34. package/dist/compiler/exports/latex/serialize.d.ts +112 -0
  35. package/dist/compiler/exports/latex/serialize.js +606 -0
  36. package/dist/compiler/exports/latex-export.d.ts +44 -0
  37. package/dist/compiler/exports/latex-export.js +169 -0
  38. package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
  39. package/dist/compiler/exports/markdown/serialize.js +527 -0
  40. package/dist/compiler/exports/markdown-export.d.ts +39 -0
  41. package/dist/compiler/exports/markdown-export.js +240 -0
  42. package/dist/compiler/exports/standalone-html.d.ts +15 -5
  43. package/dist/compiler/exports/standalone-html.js +23 -51
  44. package/dist/compiler/exports/static-tree.d.ts +253 -0
  45. package/dist/compiler/exports/static-tree.js +625 -0
  46. package/dist/compiler/exports/zip.d.ts +16 -0
  47. package/dist/compiler/exports/zip.js +72 -0
  48. package/dist/compiler/lesson-derivation.d.ts +8 -2
  49. package/dist/compiler/lesson-derivation.js +6 -7
  50. package/dist/compiler/manifest.d.ts +115 -8
  51. package/dist/compiler/manifest.js +221 -39
  52. package/dist/compiler/render-diagnostics.d.ts +65 -0
  53. package/dist/compiler/render-diagnostics.js +56 -0
  54. package/dist/compiler/resolution-report.d.ts +3 -1
  55. package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
  56. package/dist/curriculum/validation.d.ts +9 -3
  57. package/dist/curriculum/validation.js +35 -17
  58. package/dist/reference/base-abbreviations.d.mts +2 -2
  59. package/dist/reference/base-abbreviations.mjs +2 -2
  60. package/dist/reference/base-operators.d.mts +8 -2
  61. package/dist/reference/base-operators.mjs +8 -10
  62. package/dist/reference/base-roman-labels.d.mts +4 -3
  63. package/dist/reference/base-roman-labels.mjs +4 -3
  64. package/dist/reference/citation-format.d.mts +35 -0
  65. package/dist/reference/citation-format.mjs +74 -0
  66. package/dist/reference/consistency.d.ts +5 -9
  67. package/dist/reference/consistency.js +34 -38
  68. package/dist/reference/curriculum-alignment.js +3 -24
  69. package/dist/reference/derived-entries.d.ts +45 -0
  70. package/dist/reference/derived-entries.js +50 -0
  71. package/dist/reference/derived-notation.d.mts +125 -0
  72. package/dist/reference/derived-notation.mjs +121 -0
  73. package/dist/reference/diagram-fit.d.ts +45 -0
  74. package/dist/reference/diagram-fit.js +54 -0
  75. package/dist/reference/diagram-layout.d.ts +59 -10
  76. package/dist/reference/diagram-layout.js +85 -24
  77. package/dist/reference/diagrams-validate.d.ts +1 -1
  78. package/dist/reference/equations-validate.d.ts +1 -1
  79. package/dist/reference/equations-validate.js +26 -1
  80. package/dist/reference/equations.d.mts +27 -0
  81. package/dist/reference/equations.mjs +120 -22
  82. package/dist/reference/examples-validate.d.ts +1 -1
  83. package/dist/reference/figure-fit.d.ts +70 -0
  84. package/dist/reference/figure-fit.js +60 -0
  85. package/dist/reference/figure-pictures.d.ts +27 -0
  86. package/dist/reference/figure-pictures.js +49 -0
  87. package/dist/reference/figure-symbols.d.ts +32 -0
  88. package/dist/reference/figure-symbols.js +55 -0
  89. package/dist/reference/figures-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-abbreviations.js +1 -1
  92. package/dist/reference/gate-course-rules.d.ts +4 -3
  93. package/dist/reference/gate-course-rules.js +9 -4
  94. package/dist/reference/gate-heading-length.d.ts +2 -0
  95. package/dist/reference/gate-heading-length.js +7 -5
  96. package/dist/reference/gate-heading-structure.js +26 -0
  97. package/dist/reference/gate-indices.d.ts +35 -0
  98. package/dist/reference/gate-indices.js +299 -0
  99. package/dist/reference/gate-math.d.ts +32 -2
  100. package/dist/reference/gate-math.js +68 -12
  101. package/dist/reference/gate-mdx-expression.d.ts +17 -0
  102. package/dist/reference/gate-mdx-expression.js +29 -0
  103. package/dist/reference/gate-non-chapter-pages.d.ts +39 -0
  104. package/dist/reference/gate-non-chapter-pages.js +156 -0
  105. package/dist/reference/gate-part-pages.d.ts +4 -2
  106. package/dist/reference/gate-part-pages.js +2 -1
  107. package/dist/reference/gate-prop-text.d.ts +28 -0
  108. package/dist/reference/gate-prop-text.js +106 -0
  109. package/dist/reference/gate-prose-notation.d.ts +3 -1
  110. package/dist/reference/gate-prose-notation.js +4 -2
  111. package/dist/reference/gate-retired-keys.d.ts +35 -0
  112. package/dist/reference/gate-retired-keys.js +58 -0
  113. package/dist/reference/gloss.d.ts +2 -0
  114. package/dist/reference/katex-measure.d.ts +17 -0
  115. package/dist/reference/katex-measure.js +223 -0
  116. package/dist/reference/keyed-float-validate.js +12 -0
  117. package/dist/reference/keyed-jsx-floats.d.mts +2 -2
  118. package/dist/reference/keyed-jsx-floats.mjs +11 -14
  119. package/dist/reference/lab-katex-validate.d.ts +43 -0
  120. package/dist/reference/lab-katex-validate.js +96 -0
  121. package/dist/reference/lab-math-scope.d.ts +1 -0
  122. package/dist/reference/lab-math-scope.js +13 -2
  123. package/dist/reference/lint-ignore.js +1 -1
  124. package/dist/reference/math-glyphs.d.mts +94 -2
  125. package/dist/reference/math-glyphs.mjs +851 -46
  126. package/dist/reference/mixed-content.d.ts +8 -3
  127. package/dist/reference/mixed-content.js +4 -2
  128. package/dist/reference/page-notation.d.ts +39 -7
  129. package/dist/reference/page-notation.js +62 -10
  130. package/dist/reference/raw-floats.mjs +37 -13
  131. package/dist/reference/references.d.ts +11 -1
  132. package/dist/reference/references.js +6 -0
  133. package/dist/reference/registry.js +115 -11
  134. package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
  135. package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
  136. package/dist/reference/rehype-heading-anchors.mjs +11 -1
  137. package/dist/reference/remark-citation.mjs +0 -0
  138. package/dist/reference/remark-notation.mjs +275 -23
  139. package/dist/reference/render-lab-math.d.ts +13 -2
  140. package/dist/reference/render-lab-math.js +18 -6
  141. package/dist/reference/sections-validate.d.ts +28 -3
  142. package/dist/reference/sections-validate.js +31 -4
  143. package/dist/reference/sections.d.mts +24 -6
  144. package/dist/reference/sections.mjs +92 -17
  145. package/dist/reference/tables-validate.d.ts +1 -1
  146. package/dist/reference/tables-validate.js +24 -1
  147. package/dist/reference/text-measure.d.ts +10 -0
  148. package/dist/reference/text-measure.js +41 -0
  149. package/dist/reference/text-widths.d.ts +10 -0
  150. package/dist/reference/text-widths.js +1238 -0
  151. package/dist/reference/types.d.ts +64 -8
  152. package/dist/rules/catalog.js +223 -32
  153. package/dist/rules/in-effect.d.ts +1 -1
  154. package/dist/rules/rules.schema.json +100 -27
  155. package/dist/rules/settings.d.ts +1 -1
  156. package/dist/rules/settings.js +7 -0
  157. package/dist/session/PreferenceStore.d.ts +18 -1
  158. package/dist/session/PreferenceStore.js +29 -1
  159. package/docs/authoring/README.md +33 -21
  160. package/docs/authoring/agents.md +38 -7
  161. package/docs/authoring/components.md +115 -10
  162. package/docs/authoring/migrating.md +878 -0
  163. package/docs/authoring/notation.md +285 -30
  164. package/docs/authoring/retired.md +1 -0
  165. package/docs/authoring/structure-rules.md +84 -45
  166. package/docs/authoring/structure.md +67 -2
  167. package/docs/authoring/visualizations.md +167 -25
  168. package/package.json +12 -7
  169. package/src/compiler/astro.ts +72 -21
  170. package/src/compiler/cli.ts +143 -35
  171. package/src/compiler/collections.ts +158 -27
  172. package/src/compiler/content-schema.ts +108 -0
  173. package/src/compiler/content-schemas.ts +391 -0
  174. package/src/compiler/course-config.ts +86 -15
  175. package/src/compiler/exports/combined-html.ts +669 -0
  176. package/src/compiler/exports/download-index.ts +36 -0
  177. package/src/compiler/exports/export-chapters.ts +233 -0
  178. package/src/compiler/exports/export-pages.ts +44 -0
  179. package/src/compiler/exports/html-tree.ts +62 -0
  180. package/src/compiler/exports/latex/capture.ts +651 -0
  181. package/src/compiler/exports/latex/compile.ts +168 -0
  182. package/src/compiler/exports/latex/document.ts +479 -0
  183. package/src/compiler/exports/latex/explico.cls +415 -0
  184. package/src/compiler/exports/latex/numbering.ts +74 -0
  185. package/src/compiler/exports/latex/serialize.ts +787 -0
  186. package/src/compiler/exports/latex/texlive-packages.txt +76 -0
  187. package/src/compiler/exports/latex-export.ts +255 -0
  188. package/src/compiler/exports/markdown/serialize.ts +632 -0
  189. package/src/compiler/exports/markdown-export.ts +329 -0
  190. package/src/compiler/exports/standalone-html.ts +39 -70
  191. package/src/compiler/exports/static-tree.ts +944 -0
  192. package/src/compiler/exports/zip.ts +88 -0
  193. package/src/compiler/lesson-derivation.ts +13 -8
  194. package/src/compiler/manifest.ts +387 -40
  195. package/src/compiler/render-diagnostics.ts +111 -0
  196. package/src/components/CourseContents.astro +6 -0
  197. package/src/components/HoverPanel.astro +204 -6
  198. package/src/components/PartOverview.astro +18 -9
  199. package/src/components/abbreviation/AbbreviationGlossary.astro +6 -2
  200. package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
  201. package/src/components/assessments/AssessmentRunner.tsx +7 -1
  202. package/src/components/assessments/AssessmentSet.astro +14 -1
  203. package/src/components/block-heading.ts +38 -0
  204. package/src/components/card-arrow.ts +18 -4
  205. package/src/components/card-carry.ts +406 -0
  206. package/src/components/card-drag.ts +1 -30
  207. package/src/components/card-fetch.ts +87 -0
  208. package/src/components/card-manager.ts +1222 -38
  209. package/src/components/card-memory.ts +5 -1
  210. package/src/components/card-placement.ts +211 -13
  211. package/src/components/card-side.ts +281 -31
  212. package/src/components/citation/CitationLayer.astro +45 -40
  213. package/src/components/diagram/Diagram.astro +123 -44
  214. package/src/components/disclosure/print-as-shown.ts +42 -6
  215. package/src/components/figure/Figure.astro +17 -1
  216. package/src/components/figure/FloatCaption.astro +4 -17
  217. package/src/components/figure/figure-state.ts +26 -17
  218. package/src/components/float-toolbar.ts +274 -0
  219. package/src/components/memo/Memo.astro +21 -10
  220. package/src/components/memo/ShortNote.astro +4 -0
  221. package/src/components/notation/NotationGlossary.astro +22 -17
  222. package/src/components/notation/NotationLayer.astro +300 -79
  223. package/src/components/notation/NotationMath.astro +8 -1
  224. package/src/components/notation/render-notation-math.ts +51 -5
  225. package/src/components/readable-text.ts +25 -0
  226. package/src/components/reference/FloatRefLayer.astro +162 -89
  227. package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
  228. package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
  229. package/src/components/starlight/LayoutPageSidebar.astro +17 -3
  230. package/src/components/starlight/LayoutSocialIcons.astro +79 -29
  231. package/src/components/starlight/LessonExportActions.astro +384 -310
  232. package/src/components/starlight/LessonFooter.astro +66 -8
  233. package/src/components/starlight/PrintController.astro +28 -0
  234. package/src/components/starlight/TextSizeControl.astro +137 -0
  235. package/src/components/starlight/print-controller.ts +380 -0
  236. package/src/components/starlight/section-summary.ts +13 -5
  237. package/src/components/table/Table.astro +14 -22
  238. package/src/components/table/table-truncation.ts +15 -0
  239. package/src/content-config.ts +20 -312
  240. package/src/curriculum/validation.ts +72 -24
  241. package/src/reference/base-abbreviations.mjs +2 -2
  242. package/src/reference/base-operators.mjs +8 -10
  243. package/src/reference/base-roman-labels.mjs +4 -3
  244. package/src/reference/citation-format.mjs +74 -0
  245. package/src/reference/consistency.ts +47 -61
  246. package/src/reference/curriculum-alignment.ts +4 -41
  247. package/src/reference/derived-entries.ts +68 -0
  248. package/src/reference/derived-notation.mjs +121 -0
  249. package/src/reference/diagram-fit.ts +104 -0
  250. package/src/reference/diagram-layout.ts +182 -51
  251. package/src/reference/diagrams-validate.ts +2 -1
  252. package/src/reference/equations-validate.ts +41 -2
  253. package/src/reference/equations.mjs +120 -22
  254. package/src/reference/examples-validate.ts +1 -0
  255. package/src/reference/figure-fit.ts +139 -0
  256. package/src/reference/figure-pictures.ts +96 -0
  257. package/src/reference/figure-symbols.ts +117 -0
  258. package/src/reference/figures-validate.ts +2 -1
  259. package/src/reference/gate-abbreviations.ts +1 -1
  260. package/src/reference/gate-course-rules.ts +22 -7
  261. package/src/reference/gate-heading-length.ts +25 -7
  262. package/src/reference/gate-heading-structure.ts +40 -1
  263. package/src/reference/gate-indices.ts +470 -0
  264. package/src/reference/gate-math.ts +102 -13
  265. package/src/reference/gate-mdx-expression.ts +45 -0
  266. package/src/reference/gate-non-chapter-pages.ts +241 -0
  267. package/src/reference/gate-part-pages.ts +1 -1
  268. package/src/reference/gate-prop-text.ts +145 -0
  269. package/src/reference/gate-prose-notation.ts +19 -5
  270. package/src/reference/gate-retired-keys.ts +101 -0
  271. package/src/reference/gloss.ts +2 -0
  272. package/src/reference/katex-measure.ts +384 -0
  273. package/src/reference/keyed-float-validate.ts +22 -0
  274. package/src/reference/keyed-jsx-floats.mjs +11 -14
  275. package/src/reference/lab-katex-validate.ts +156 -0
  276. package/src/reference/lab-math-scope.ts +15 -1
  277. package/src/reference/lint-ignore.ts +4 -1
  278. package/src/reference/math-glyphs.mjs +851 -46
  279. package/src/reference/mixed-content.ts +11 -2
  280. package/src/reference/page-notation.ts +114 -14
  281. package/src/reference/raw-floats.mjs +37 -13
  282. package/src/reference/references.ts +14 -0
  283. package/src/reference/registry.ts +170 -9
  284. package/src/reference/rehype-fail-katex-errors.mjs +19 -0
  285. package/src/reference/rehype-heading-anchors.mjs +11 -1
  286. package/src/reference/remark-citation.mjs +0 -0
  287. package/src/reference/remark-notation.mjs +275 -23
  288. package/src/reference/render-lab-math.ts +40 -5
  289. package/src/reference/sections-validate.ts +67 -3
  290. package/src/reference/sections.mjs +92 -17
  291. package/src/reference/tables-validate.ts +40 -2
  292. package/src/reference/text-measure.ts +63 -0
  293. package/src/reference/text-widths.ts +133 -0
  294. package/src/reference/types.ts +75 -8
  295. package/src/rules/catalog.ts +241 -31
  296. package/src/rules/settings.ts +10 -0
  297. package/src/session/PreferenceStore.ts +53 -0
  298. package/src/styles/global.css +322 -83
@@ -0,0 +1,384 @@
1
+ /**
2
+ * The size of a formula as KaTeX lays it out, measured at build time.
3
+ *
4
+ * There is no browser at build time, but KaTeX already knows most of the
5
+ * answer: `katex.__renderToHTMLTree` returns the tree its HTML is printed
6
+ * from, and every `SymbolNode` in it carries the advance `width` and the
7
+ * `italic` correction of its glyph, in em, from KaTeX's own font metrics. A
8
+ * span carries no width, so {@link measureFormula} walks the tree the way the
9
+ * browser lays the HTML out: it sums a row, takes the widest row of a `vlist`
10
+ * (scripts, fractions, `\underset`), scales by the `sizing reset-sizeN sizeM`
11
+ * classes, and adds every margin and padding the tree writes inline (glue
12
+ * between atoms, `\,`, `\!`, the margin a subscript is shifted by).
13
+ *
14
+ * The rest of the widths live in `katex.css`, not in the tree, and are
15
+ * listed here class by class: the `0.12em` of a `.nulldelimiter` either side
16
+ * of every fraction and of `\left.`, the padding of `\boxed`, the zero width
17
+ * of an accent's glyph row and of `\llap`. KaTeX also joins a run of letters
18
+ * into one node that carries only the first letter's width; the rest of the
19
+ * run comes from KaTeX's metric tables, in the font `katex.css` gives it.
20
+ *
21
+ * Chromium on Linux rounds each glyph of KaTeX's web fonts up to a whole
22
+ * pixel, which widens a short formula by several percent at a diagram's
23
+ * 13.6px, so every glyph is counted that way: the result is never narrower
24
+ * than the browser's, and at most a pixel or so per glyph wider than where a
25
+ * browser positions glyphs at subpixel precision.
26
+ * `tests/reference/katex-measure.test.ts` pins it to widths measured in
27
+ * Chromium (`playground/scripts/measure-katex-widths.ts`), so a KaTeX upgrade
28
+ * that moves a width from the tree to the stylesheet fails there.
29
+ *
30
+ * Importing `katex-options.mjs`, as this module does, installs rule N17's
31
+ * delimiter sizing, so `\left…\right` is measured at the size it renders.
32
+ */
33
+ import katex, { type KatexOptions } from 'katex';
34
+ import fontMetricsData from 'katex/src/fontMetricsData.js';
35
+
36
+ import { createNotationKatexOptions } from './katex-options.mjs';
37
+
38
+ /** A formula's box at one font size, in px. */
39
+ export interface FormulaMetrics {
40
+ /** Advance width. */
41
+ readonly width: number;
42
+ /** Height above the baseline. */
43
+ readonly height: number;
44
+ /** Depth below the baseline. */
45
+ readonly depth: number;
46
+ }
47
+
48
+ /** The shape of a node in KaTeX's HTML tree, read structurally. */
49
+ interface KatexNode {
50
+ readonly classes?: readonly string[];
51
+ readonly children?: readonly KatexNode[];
52
+ readonly style?: Readonly<Record<string, string | undefined>>;
53
+ /** A `SymbolNode`'s text; absent on every other node. */
54
+ readonly text?: string;
55
+ readonly width?: number | null;
56
+ readonly italic?: number;
57
+ readonly height?: number;
58
+ readonly depth?: number;
59
+ }
60
+
61
+ /** `katex.css`: `.sizing.reset-sizeN.sizeM { font-size: (M / N)em }`. */
62
+ const SIZE_MULTIPLIERS = [0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.2, 1.44, 1.728, 2.074, 2.488];
63
+
64
+ /** `katex.css` paddings, in em, as `[left, right]`. */
65
+ const CLASS_PADDING: Readonly<Record<string, readonly [number, number]>> = {
66
+ 'x-arrow-pad': [0.5, 0.5],
67
+ 'cd-arrow-pad': [0.27778, 0.55556],
68
+ boxpad: [0.3, 0.3],
69
+ 'cancel-pad': [0.2, 0.2],
70
+ anglpad: [0.03889, 0.03889],
71
+ };
72
+
73
+ /** `katex.css` margins, in em, as `[left, right]`. */
74
+ const CLASS_MARGIN: Readonly<Record<string, readonly [number, number]>> = {
75
+ 'cancel-lap': [-0.2, -0.2],
76
+ angl: [0, 0.03889],
77
+ };
78
+
79
+ /** `katex.css` borders, in em, as `[left, right]`. */
80
+ const CLASS_BORDER: Readonly<Record<string, readonly [number, number]>> = {
81
+ fbox: [0.04, 0.04],
82
+ fcolorbox: [0.04, 0.04],
83
+ angl: [0, 0.049],
84
+ };
85
+
86
+ /** `katex.css` boxes whose width is zero whatever they hold. */
87
+ const ZERO_WIDTH = new Set(['llap', 'rlap', 'clap', 'pstrut', 'vlist-s']);
88
+
89
+ function has(node: KatexNode, name: string): boolean {
90
+ return node.classes?.includes(name) ?? false;
91
+ }
92
+
93
+ /** An inline length in em; a percentage or `calc()` is `auto`, so `0`. */
94
+ function em(value: string | undefined): number {
95
+ if (!value) return 0;
96
+ const match = /^(-?\d*\.?\d+)em$/.exec(value.trim());
97
+ return match ? Number(match[1]) : 0;
98
+ }
99
+
100
+ /** The font-size factor a node's `sizing` classes apply, relative to its parent. */
101
+ function sizing(node: KatexNode): number {
102
+ if (!has(node, 'sizing') && !has(node, 'fontsize-ensurer')) return 1;
103
+ let from = 0;
104
+ let to = 0;
105
+ for (const name of node.classes ?? []) {
106
+ const reset = /^reset-size(\d+)$/.exec(name);
107
+ if (reset) from = Number(reset[1]);
108
+ const size = /^size(\d+)$/.exec(name);
109
+ if (size) to = Number(size[1]);
110
+ }
111
+ const fromMultiplier = SIZE_MULTIPLIERS[from - 1];
112
+ const toMultiplier = SIZE_MULTIPLIERS[to - 1];
113
+ return fromMultiplier && toMultiplier ? toMultiplier / fromMultiplier : 1;
114
+ }
115
+
116
+ function pairOf(
117
+ table: Readonly<Record<string, readonly [number, number]>>,
118
+ node: KatexNode,
119
+ ): number {
120
+ let total = 0;
121
+ for (const name of node.classes ?? []) {
122
+ const pair = table[name];
123
+ if (pair) total += pair[0] + pair[1];
124
+ }
125
+ return total;
126
+ }
127
+
128
+ /** The font a node's text is drawn in, as `katex.css` cascades it. */
129
+ interface Font {
130
+ readonly family: string;
131
+ readonly bold: boolean;
132
+ readonly italic: boolean;
133
+ }
134
+
135
+ /** `.katex { font: normal 1.21em KaTeX_Main }`. */
136
+ const ROOT_FONT: Font = { family: 'Main', bold: false, italic: false };
137
+
138
+ /** `katex.css`'s font rules, one class at a time. */
139
+ const FONT_CLASSES: Readonly<Record<string, Partial<Font>>> = {
140
+ textbf: { bold: true },
141
+ textit: { italic: true },
142
+ textrm: { family: 'Main' },
143
+ texttt: { family: 'Typewriter' },
144
+ mathnormal: { family: 'Math', italic: true },
145
+ mathit: { family: 'Main', italic: true },
146
+ mathrm: { italic: false },
147
+ mathbf: { family: 'Main', bold: true },
148
+ boldsymbol: { family: 'Math', bold: true, italic: true },
149
+ amsrm: { family: 'AMS' },
150
+ mathbb: { family: 'AMS' },
151
+ textbb: { family: 'AMS' },
152
+ mathcal: { family: 'Caligraphic' },
153
+ mathfrak: { family: 'Fraktur' },
154
+ textfrak: { family: 'Fraktur' },
155
+ mathboldfrak: { family: 'Fraktur', bold: true },
156
+ textboldfrak: { family: 'Fraktur', bold: true },
157
+ mathtt: { family: 'Typewriter' },
158
+ mathscr: { family: 'Script' },
159
+ textscr: { family: 'Script' },
160
+ mathsf: { family: 'SansSerif' },
161
+ textsf: { family: 'SansSerif' },
162
+ mathboldsf: { family: 'SansSerif', bold: true },
163
+ textboldsf: { family: 'SansSerif', bold: true },
164
+ mathsfit: { family: 'SansSerif', italic: true },
165
+ mathitsf: { family: 'SansSerif', italic: true },
166
+ textitsf: { family: 'SansSerif', italic: true },
167
+ mainrm: { family: 'Main', italic: false },
168
+ };
169
+
170
+ function fontOf(node: KatexNode, inherited: Font): Font {
171
+ let font = inherited;
172
+ for (const name of node.classes ?? []) {
173
+ const rule = FONT_CLASSES[name];
174
+ if (rule) font = { ...font, ...rule };
175
+ }
176
+ if (has(node, 'delimsizing')) {
177
+ for (const size of [1, 2, 3, 4]) {
178
+ if (has(node, `size${size}`)) font = { ...font, family: `Size${size}` };
179
+ }
180
+ }
181
+ if (has(node, 'op-symbol')) {
182
+ if (has(node, 'small-op')) font = { ...font, family: 'Size1' };
183
+ if (has(node, 'large-op')) font = { ...font, family: 'Size2' };
184
+ }
185
+ return font;
186
+ }
187
+
188
+ type MetricTable = Readonly<Record<string, readonly number[] | undefined>>;
189
+ const METRICS = fontMetricsData as unknown as Readonly<Record<string, MetricTable | undefined>>;
190
+
191
+ /** KaTeX's metric table for `font`, falling back to the nearest it has. */
192
+ function metricsFor(font: Font): MetricTable {
193
+ const variants = [
194
+ font.bold && font.italic ? 'BoldItalic' : font.bold ? 'Bold' : font.italic ? 'Italic' : 'Regular',
195
+ font.bold ? 'Bold' : undefined,
196
+ font.italic ? 'Italic' : undefined,
197
+ 'Regular',
198
+ ];
199
+ for (const variant of variants) {
200
+ const table = variant && METRICS[`${font.family}-${variant}`];
201
+ if (table) return table;
202
+ }
203
+ return METRICS['Main-Regular'] ?? {};
204
+ }
205
+
206
+ /**
207
+ * Glyphs whose KaTeX metrics are far too narrow: the multiple integrals. KaTeX
208
+ * gives `∬` the single integral's width, while a browser draws it (from a
209
+ * fallback font) as wide as two: in Chromium on Linux, `\iint` is 13.6px
210
+ * against `\int`'s 9.6px at 13.6px, and `\iiint` 18.6px. An upper bound, in em.
211
+ */
212
+ const WIDE_GLYPHS: ReadonlyMap<number, number> = new Map([
213
+ [0x222c, 0.85], // ∬ \iint
214
+ [0x222d, 1.22], // ∭ \iiint
215
+ [0x222f, 0.85], // ∯ \oiint
216
+ [0x2230, 1.22], // ∰ \oiiint
217
+ [0x2a0c, 1.6], // ⨌ \iiiint
218
+ ]);
219
+
220
+ /** A glyph KaTeX has no metrics for is drawn from a fallback font: count it as an `M`. */
221
+ function glyphWidth(table: MetricTable, char: string): number {
222
+ if (char === '\u200b') return 0;
223
+ const metrics = table[String(char.codePointAt(0))] ?? table['77'];
224
+ return metrics?.[4] ?? 1;
225
+ }
226
+
227
+ /**
228
+ * The advance of each glyph in a `SymbolNode`, in em. KaTeX joins a run of
229
+ * characters with the same classes into one node ("Var" in
230
+ * `\operatorname{Var}`, "if " in `\text{if }`) and keeps the first
231
+ * character's width for the whole run, so the rest of the run is looked up
232
+ * here, in the font the run is drawn in.
233
+ */
234
+ function glyphAdvances(node: KatexNode, font: Font): number[] {
235
+ const [first, ...rest] = [...(node.text ?? '')];
236
+ if (first === undefined) return [];
237
+ const table = metricsFor(font);
238
+ const wide = (char: string) => WIDE_GLYPHS.get(char.codePointAt(0) ?? 0);
239
+ const firstWidth =
240
+ wide(first) ?? (node.width && node.width > 0 ? node.width : glyphWidth(table, first));
241
+ return [firstWidth, ...rest.map((char) => wide(char) ?? glyphWidth(table, char))];
242
+ }
243
+
244
+ /**
245
+ * A glyph's advance in px, the wider of the two ways a browser draws it.
246
+ * Chromium on Linux hints KaTeX's web fonts at a whole-pixel size and rounds
247
+ * each glyph to whole pixels: a 16.456px formula is laid out at 16px, a 13.6px
248
+ * one at 14px. A browser that positions glyphs at subpixel precision (macOS,
249
+ * iOS) takes the exact advance at the real size.
250
+ */
251
+ function glyphPx(advanceEm: number, fontPx: number): number {
252
+ return Math.max(Math.round(advanceEm * Math.round(fontPx)), advanceEm * fontPx);
253
+ }
254
+
255
+ interface Context {
256
+ /** The formula's root font size in px. */
257
+ readonly fontPx: number;
258
+ }
259
+
260
+ /** What a node inherits from its parent: font size (in root em) and font. */
261
+ interface Frame {
262
+ readonly scale: number;
263
+ readonly font: Font;
264
+ }
265
+
266
+ /**
267
+ * The horizontal advance of `node`, margins included, in px.
268
+ */
269
+ function advance(node: KatexNode, inherited: Frame, context: Context, parent?: KatexNode): number {
270
+ const frame: Frame = {
271
+ scale: inherited.scale * sizing(node),
272
+ font: fontOf(node, inherited.font),
273
+ };
274
+ // 1em of this node's own font size, in px.
275
+ const scale = frame.scale * context.fontPx;
276
+ const style = node.style ?? {};
277
+
278
+ if (typeof node.text === 'string') {
279
+ // `SymbolNode.toMarkup` writes the italic correction as `margin-right`,
280
+ // then the node's own style, so an explicit margin-right replaces it.
281
+ const marginRight =
282
+ style.marginRight !== undefined ? em(style.marginRight) : Math.max(0, node.italic ?? 0);
283
+ const glyphs = glyphAdvances(node, frame.font).reduce(
284
+ (sum, width) => sum + glyphPx(width, scale),
285
+ 0,
286
+ );
287
+ return glyphs + (marginRight + em(style.marginLeft)) * scale;
288
+ }
289
+ // An `SvgNode` or its paths: `.katex svg` is absolutely positioned, so the
290
+ // span around it is as wide as its own style says.
291
+ if (!node.classes) return 0;
292
+
293
+ const margins =
294
+ (em(style.marginLeft) +
295
+ em(style.marginRight) +
296
+ pairOf(CLASS_MARGIN, node) +
297
+ rootMargin(node, parent)) *
298
+ scale;
299
+ if (node.classes.some((name) => ZERO_WIDTH.has(name))) return margins;
300
+ if (has(node, 'accent-body') && !has(node, 'accent-full')) return margins;
301
+
302
+ let content: number;
303
+ if (has(node, 'nulldelimiter')) {
304
+ content = 0.12 * scale;
305
+ } else if (em(style.width) > 0) {
306
+ content = em(style.width) * scale;
307
+ } else if (has(node, 'vlist-t')) {
308
+ content = tableWidth(node, frame, context);
309
+ } else if (has(node, 'vlist')) {
310
+ // A cell of block rows: as wide as its widest row, margins included.
311
+ content = Math.max(
312
+ 0,
313
+ ...(node.children ?? []).map((row) => advance(row, frame, context, node)),
314
+ );
315
+ } else {
316
+ content = (node.children ?? []).reduce(
317
+ (sum, child) => sum + advance(child, frame, context, node),
318
+ 0,
319
+ );
320
+ }
321
+ content = Math.max(content, em(style.minWidth) * scale);
322
+ if (has(node, 'vertical-separator')) content = Math.max(content, 1);
323
+
324
+ const padding = (em(style.paddingLeft) + pairOf(CLASS_PADDING, node)) * scale;
325
+ const border = (em(style.borderRightWidth) + pairOf(CLASS_BORDER, node)) * scale;
326
+ return content + padding + border + margins;
327
+ }
328
+
329
+ /** `.sqrt > .root`: the index of `\sqrt[n]{x}` is kerned in from both sides. */
330
+ function rootMargin(node: KatexNode, parent?: KatexNode): number {
331
+ return has(node, 'root') && parent !== undefined && has(parent, 'sqrt')
332
+ ? 0.2777777778 - 0.5555555556
333
+ : 0;
334
+ }
335
+
336
+ /**
337
+ * A `vlist-t` is an inline table of `vlist-r` rows. Its columns are as wide as
338
+ * their widest cell; the 2px `vlist-s` column a `vlist-t2` adds is taken back
339
+ * by that table's `margin-right: -2px`, so only the `vlist` cells count.
340
+ */
341
+ function tableWidth(node: KatexNode, frame: Frame, context: Context): number {
342
+ const columns: number[] = [];
343
+ for (const row of node.children ?? []) {
344
+ let column = 0;
345
+ for (const cell of row.children ?? []) {
346
+ if (has(cell, 'vlist-s')) continue;
347
+ columns[column] = Math.max(columns[column] ?? 0, advance(cell, frame, context, row));
348
+ column += 1;
349
+ }
350
+ }
351
+ return columns.reduce((sum, width) => sum + width, 0);
352
+ }
353
+
354
+ function collectBases(node: KatexNode, out: KatexNode[]): KatexNode[] {
355
+ if (has(node, 'base')) {
356
+ out.push(node);
357
+ return out;
358
+ }
359
+ for (const child of node.children ?? []) collectBases(child, out);
360
+ return out;
361
+ }
362
+
363
+ /**
364
+ * Measure `latex` as the notation KaTeX renders it — the same macros
365
+ * (`\explain`, `\slot`), trust boundary and delimiter sizing as lesson math —
366
+ * at a font size of `fontPx`. Throws on LaTeX KaTeX cannot render.
367
+ */
368
+ export function measureFormula(latex: string, fontPx: number): FormulaMetrics {
369
+ // Looked up at call time: `katex-delimiters.mjs` replaces it on the shared
370
+ // object with the build that has N17's sizing.
371
+ const { __renderToHTMLTree } = katex as unknown as {
372
+ __renderToHTMLTree(expression: string, options: KatexOptions): unknown;
373
+ };
374
+ const tree = __renderToHTMLTree(latex, {
375
+ ...createNotationKatexOptions(),
376
+ displayMode: false,
377
+ }) as KatexNode;
378
+ const bases = collectBases(tree, []);
379
+ return {
380
+ width: advance(tree, { scale: 1, font: ROOT_FONT }, { fontPx }),
381
+ height: Math.max(0, ...bases.map((base) => base.height ?? 0)) * fontPx,
382
+ depth: Math.max(0, ...bases.map((base) => base.depth ?? 0)) * fontPx,
383
+ };
384
+ }
@@ -28,6 +28,7 @@ import {
28
28
  scanKeyedJsxFloats,
29
29
  } from './keyed-jsx-floats.mjs';
30
30
 
31
+
31
32
  export interface KeyedFloatKind {
32
33
  /** The MDX component tag, e.g. `Table` or `Figure`. */
33
34
  readonly tagName: string;
@@ -159,6 +160,27 @@ export function validateKeyedFloats(
159
160
  ...(raw.key ? { key: raw.key } : {}),
160
161
  });
161
162
  }
163
+ // A caption the manifest cannot read — `short={…}` renders on the page,
164
+ // but the "Figures and tables" page, a folded section's line and a
165
+ // copied link name the float without it.
166
+ for (const prop of ['short', 'caption'] as const) {
167
+ if (!raw.props.includes(prop) || raw[prop] != null) continue;
168
+ const places =
169
+ prefix === 'ex'
170
+ ? `its [[${prefix}-…]] card and a copied link`
171
+ : `its [[${prefix}-…]] card, a copied link, a folded section's line and the "Figures and tables" page`;
172
+ diagnostics.push({
173
+ code: `${prefix}-unread-prop`,
174
+ severity: 'warning',
175
+ message:
176
+ `${lesson.lessonId} has a <${tagName} ${prop}={…}>${raw.key ? ` (id="${raw.key}")` : ''}, ` +
177
+ `but the manifest reads only a quoted \`${prop}\`, so ${places} name this ${noun} ` +
178
+ `without it. Write ${prop}="…", with typographic quotes (‘’ “”) inside.`,
179
+ file: lesson.file,
180
+ lessonId: lesson.lessonId,
181
+ ...(raw.key ? { key: raw.key } : {}),
182
+ });
183
+ }
162
184
  if (raw.key == null) continue;
163
185
  if (!KEYED_FLOAT_KEY_PATTERN.test(raw.key)) {
164
186
  diagnostics.push({
@@ -25,6 +25,7 @@
25
25
 
26
26
  import {
27
27
  resolveNumbering,
28
+ stripContainerDirectives,
28
29
  stripFencedCode,
29
30
  stripJsxComponents,
30
31
  TRANSPARENT_COMPONENTS,
@@ -131,10 +132,9 @@ function mdxStringAttribute(node, name) {
131
132
  */
132
133
  export function scanKeyedJsxFloats(markdown, { tagName, numbering }) {
133
134
  const { prefix, offset } = resolveNumbering(numbering);
134
- const text = stripJsxComponents(
135
- stripFencedCode(markdown),
136
- tagName,
137
- TRANSPARENT_COMPONENTS,
135
+ // A `:::note` aside is a callout, like `<Aside>`: nothing in it numbers.
136
+ const text = stripContainerDirectives(
137
+ stripJsxComponents(stripFencedCode(markdown), tagName, TRANSPARENT_COMPONENTS),
138
138
  );
139
139
  const tokens = [];
140
140
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
@@ -196,8 +196,8 @@ export function countKeyedJsxFloats(markdown, { tagName }) {
196
196
  }
197
197
 
198
198
  /**
199
- * The mdast equivalent of `scanKeyedJsxFloats`: walk the tree's top-level
200
- * children (and through the `TRANSPARENT_COMPONENTS`' bodies) in document order, advancing the
199
+ * The mdast equivalent of `scanKeyedJsxFloats`: walk the lesson's flow (and
200
+ * through the `TRANSPARENT_COMPONENTS`' bodies) in document order, advancing the
201
201
  * section counter on every top-level depth-2 heading and emitting a record for
202
202
  * every `mdxJsxFlowElement` named `tagName` — keyed or not, exactly like
203
203
  * `collectDisplayEquations`. `numbering` is threaded the same way as in
@@ -210,8 +210,12 @@ export function collectKeyedJsxFloats(tree, { tagName, numbering }) {
210
210
  let indexInSection = 0;
211
211
  let indexInLesson = 0;
212
212
 
213
+ // The lesson's flow — top-level blocks, list items, blockquotes — and the
214
+ // `TRANSPARENT_COMPONENTS`' bodies; never another component's body or a
215
+ // `:::note` aside. `scanKeyedJsxFloats` reads the same set from the text.
213
216
  const collectFromDetail = (node) => {
214
217
  if (!node || typeof node !== 'object') return;
218
+ if (node.type === 'containerDirective') return;
215
219
  if (node.type === 'mdxJsxFlowElement' && node.name === tagName) {
216
220
  indexInSection += 1;
217
221
  indexInLesson += 1;
@@ -256,14 +260,7 @@ export function collectKeyedJsxFloats(tree, { tagName, numbering }) {
256
260
  indexInSection = 0;
257
261
  continue;
258
262
  }
259
- if (node.type === 'mdxJsxFlowElement' && node.name === tagName) {
260
- collectFromDetail(node);
261
- } else if (
262
- node.type === 'mdxJsxFlowElement' &&
263
- TRANSPARENT_COMPONENTS.includes(node.name)
264
- ) {
265
- collectFromDetail(node);
266
- }
263
+ collectFromDetail(node);
267
264
  }
268
265
  return floats;
269
266
  }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Rule V3's seam, checked in the source: a component a lesson renders draws
3
+ * its math through `renderLabMath`, which binds every symbol to the page's
4
+ * notation, never by calling KaTeX itself — a legend helper around
5
+ * `katex.renderToString` binds none, so its symbols open no card.
6
+ *
7
+ * Starting at each lesson's own imports, this follows every relative import
8
+ * — the course's visuals, wherever they live — and reports each file that
9
+ * imports `katex` as `lab-katex-direct`, with the chain of imports that
10
+ * reaches it. A package import (`explico/…`, `react`) is not followed:
11
+ * explico's own KaTeX use is the seam. A type-only import and the KaTeX
12
+ * stylesheet draw nothing and are not reported.
13
+ *
14
+ * `figure-symbols.ts` checks the same promise on the rendered page, which
15
+ * also catches math a lab draws some other way.
16
+ */
17
+
18
+ import { readFileSync, statSync } from 'node:fs';
19
+ import { dirname, join, relative, sep } from 'node:path';
20
+
21
+ import { stripFencedCode } from './equations.mjs';
22
+
23
+ export interface LabKatexDiagnostic {
24
+ readonly code: 'lab-katex-direct';
25
+ readonly severity: 'error';
26
+ readonly message: string;
27
+ /** The file that imports `katex`, repository-relative. */
28
+ readonly file: string;
29
+ /** The first lesson (by id) whose imports reach it. */
30
+ readonly lessonId: string;
31
+ }
32
+
33
+ export interface LabKatexLessonInput {
34
+ readonly lessonId: string;
35
+ /** Repository-relative path of the lesson file. */
36
+ readonly file: string;
37
+ readonly body: string;
38
+ }
39
+
40
+ /** Extensions tried, in order, for an import written without one. */
41
+ const EXTENSIONS = ['.astro', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.mts', '.cjs'];
42
+ /** Files worth reading for imports: scripts, components and MDX. */
43
+ const SOURCE_FILE = /\.(?:astro|tsx|ts|jsx|js|mjs|mts|cjs|mdx)$/u;
44
+
45
+ const IMPORT_PATTERNS = [
46
+ // import x from 'y' · import { x } from 'y' · import 'y' · import type … from 'y'
47
+ /\bimport\s+(type\s+)?(?:[\w*{}\s,$]+?\s+from\s+)?(['"])([^'"\n]+)\2/gu,
48
+ // export { x } from 'y' · export * from 'y' · export type { x } from 'y'
49
+ /\bexport\s+(type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s+from\s+(['"])([^'"\n]+)\2/gu,
50
+ // import('y') · require('y')
51
+ /\b(?:import|require)\s*\(\s*()(['"])([^'"\n]+)\2\s*\)/gu,
52
+ ];
53
+
54
+ /**
55
+ * Every module a source file imports at run time, in order: static, re-exported,
56
+ * dynamic and `require`d. A type-only import is left out.
57
+ */
58
+ export function importSpecifiers(source: string): string[] {
59
+ const found: { index: number; specifier: string }[] = [];
60
+ for (const pattern of IMPORT_PATTERNS) {
61
+ for (const match of source.matchAll(pattern)) {
62
+ if (match[1]) continue;
63
+ found.push({ index: match.index ?? 0, specifier: match[3]! });
64
+ }
65
+ }
66
+ return found.sort((a, b) => a.index - b.index).map(({ specifier }) => specifier);
67
+ }
68
+
69
+ /** `katex`, or a script inside it — not its stylesheet or its fonts. */
70
+ function isKatexModule(specifier: string): boolean {
71
+ if (specifier !== 'katex' && !specifier.startsWith('katex/')) return false;
72
+ return !/\.(?:css|woff2?|ttf)$/u.test(specifier);
73
+ }
74
+
75
+ function isFile(path: string): boolean {
76
+ try {
77
+ return statSync(path).isFile();
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /** The file a relative import names, trying the extensions a bundler would. */
84
+ function resolveRelative(fromFile: string, specifier: string): string | undefined {
85
+ const base = join(dirname(fromFile), specifier);
86
+ const candidates = [
87
+ base,
88
+ // `./chart.js` written for `./chart.ts`, the TypeScript ESM convention.
89
+ ...(/\.[cm]?js$/u.test(base) ? ['.ts', '.tsx', '.mts', '.cts'].map((ext) => base.replace(/\.[cm]?js$/u, ext)) : []),
90
+ ...EXTENSIONS.map((ext) => `${base}${ext}`),
91
+ ...EXTENSIONS.map((ext) => join(base, `index${ext}`)),
92
+ ];
93
+ return candidates.find(isFile);
94
+ }
95
+
96
+ /**
97
+ * Each course file that a lesson's imports reach and that imports `katex`.
98
+ * `root` is the repository root the lesson paths are relative to.
99
+ */
100
+ export function validateLabKatexImports(
101
+ lessons: readonly LabKatexLessonInput[],
102
+ root: string = process.cwd(),
103
+ ): LabKatexDiagnostic[] {
104
+ const read = (path: string): string | undefined => {
105
+ try {
106
+ return readFileSync(path, 'utf8');
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ };
111
+ const repoPath = (path: string) => relative(root, path).split(sep).join('/');
112
+
113
+ const diagnostics = new Map<string, LabKatexDiagnostic>();
114
+ const seen = new Set<string>();
115
+ for (const lesson of [...lessons].sort((a, b) => a.lessonId.localeCompare(b.lessonId))) {
116
+ const lessonPath = join(root, lesson.file);
117
+ // Breadth first, so a reported chain is the shortest one.
118
+ const queue: { path: string; source: string; chain: readonly string[] }[] = [
119
+ { path: lessonPath, source: stripFencedCode(lesson.body), chain: [lesson.file] },
120
+ ];
121
+ while (queue.length > 0) {
122
+ const { path, source, chain } = queue.shift()!;
123
+ for (const specifier of importSpecifiers(source)) {
124
+ if (isKatexModule(specifier)) {
125
+ const file = repoPath(path);
126
+ if (!diagnostics.has(file)) {
127
+ diagnostics.set(file, {
128
+ code: 'lab-katex-direct',
129
+ severity: 'error',
130
+ file,
131
+ lessonId: lesson.lessonId,
132
+ message:
133
+ `${file} imports '${specifier}' and draws math whose symbols open no ` +
134
+ `notation card (reached by ${chain.join(' → ')}). Render a lab's math ` +
135
+ `with renderLabMath(template, scope) — { display: false } for a legend ` +
136
+ `entry, an axis title or a label — so every symbol is bound to the ` +
137
+ `page's notation (rule V3).`,
138
+ });
139
+ }
140
+ continue;
141
+ }
142
+ if (!specifier.startsWith('.')) continue;
143
+ const target = resolveRelative(path, specifier);
144
+ if (!target || !SOURCE_FILE.test(target) || target.includes(`${sep}node_modules${sep}`)) {
145
+ continue;
146
+ }
147
+ if (seen.has(target)) continue;
148
+ seen.add(target);
149
+ const text = read(target);
150
+ if (text === undefined) continue;
151
+ queue.push({ path: target, source: text, chain: [...chain, repoPath(target)] });
152
+ }
153
+ }
154
+ }
155
+ return [...diagnostics.values()].sort((a, b) => a.file.localeCompare(b.file));
156
+ }
@@ -1,6 +1,7 @@
1
1
  import { loadManifest } from '../compiler/manifest';
2
2
  import type { LabMathScopeDefinition } from './render-lab-math';
3
3
  import {
4
+ bindScopeToPage,
4
5
  registerPageNotationDependencies,
5
6
  routeIdFromLocals,
6
7
  } from './page-notation';
@@ -9,6 +10,7 @@ interface LessonNotationFrontmatter {
9
10
  readonly local?: ReadonlyArray<{
10
11
  readonly key: string;
11
12
  readonly latex: string;
13
+ readonly fixedIndex?: boolean;
12
14
  }>;
13
15
  }
14
16
 
@@ -39,6 +41,7 @@ export async function labMathScope(
39
41
  const scope: LabMathScopeDefinition[] = local.map((definition) => ({
40
42
  key: definition.key,
41
43
  notation: definition.latex,
44
+ ...(definition.fixedIndex === true ? { fixedIndex: true as const } : {}),
42
45
  }));
43
46
 
44
47
  const referenced = new Set<string>();
@@ -66,7 +69,11 @@ export async function labMathScope(
66
69
  }
67
70
  const shared = sharedByKey.get(key);
68
71
  if (shared) {
69
- scope.push({ key: shared.key, notation: shared.notation });
72
+ scope.push({
73
+ key: shared.key,
74
+ notation: shared.notation,
75
+ ...(shared.fixedIndex ? { fixedIndex: true as const } : {}),
76
+ });
70
77
  resolvedKeys.add(key);
71
78
  continue;
72
79
  }
@@ -90,6 +97,13 @@ export async function labMathScope(
90
97
  resolvedKeys,
91
98
  component.context,
92
99
  );
100
+ // Rule N23: the lab's indexed symbols take derived cards on this page,
101
+ // and are held to the rule unless the course turned it off.
102
+ bindScopeToPage(scope, {
103
+ locals: component.locals,
104
+ context: component.context,
105
+ checkIndices: !manifest.rules.disabled.includes('N23'),
106
+ });
93
107
  }
94
108
 
95
109
  return scope;