explico 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +1137 -1
  2. package/README.md +91 -13
  3. package/dist/compiler/astro.d.ts +4 -3
  4. package/dist/compiler/astro.js +45 -15
  5. package/dist/compiler/cli.d.ts +26 -12
  6. package/dist/compiler/cli.js +96 -25
  7. package/dist/compiler/collections.d.ts +9 -1
  8. package/dist/compiler/collections.js +55 -16
  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 +14 -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 +87 -6
  51. package/dist/compiler/manifest.js +174 -31
  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 +2 -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 +2 -2
  61. package/dist/reference/base-operators.mjs +2 -2
  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/figures-validate.d.ts +1 -1
  88. package/dist/reference/gate-abbreviations.d.ts +1 -1
  89. package/dist/reference/gate-abbreviations.js +1 -1
  90. package/dist/reference/gate-course-rules.d.ts +4 -3
  91. package/dist/reference/gate-course-rules.js +9 -4
  92. package/dist/reference/gate-heading-length.d.ts +2 -0
  93. package/dist/reference/gate-heading-length.js +7 -5
  94. package/dist/reference/gate-heading-structure.js +26 -0
  95. package/dist/reference/gate-indices.d.ts +35 -0
  96. package/dist/reference/gate-indices.js +299 -0
  97. package/dist/reference/gate-math.d.ts +32 -2
  98. package/dist/reference/gate-math.js +68 -12
  99. package/dist/reference/gate-mdx-expression.d.ts +17 -0
  100. package/dist/reference/gate-mdx-expression.js +29 -0
  101. package/dist/reference/gate-part-pages.d.ts +1 -1
  102. package/dist/reference/gate-prop-text.d.ts +28 -0
  103. package/dist/reference/gate-prop-text.js +106 -0
  104. package/dist/reference/gate-prose-notation.d.ts +3 -1
  105. package/dist/reference/gate-prose-notation.js +4 -2
  106. package/dist/reference/gate-retired-keys.d.ts +35 -0
  107. package/dist/reference/gate-retired-keys.js +58 -0
  108. package/dist/reference/gloss.d.ts +2 -0
  109. package/dist/reference/katex-measure.d.ts +17 -0
  110. package/dist/reference/katex-measure.js +223 -0
  111. package/dist/reference/keyed-float-validate.js +12 -0
  112. package/dist/reference/keyed-jsx-floats.d.mts +2 -2
  113. package/dist/reference/keyed-jsx-floats.mjs +11 -14
  114. package/dist/reference/lab-math-scope.d.ts +1 -0
  115. package/dist/reference/lab-math-scope.js +13 -2
  116. package/dist/reference/math-glyphs.d.mts +94 -2
  117. package/dist/reference/math-glyphs.mjs +851 -46
  118. package/dist/reference/mixed-content.d.ts +8 -3
  119. package/dist/reference/mixed-content.js +4 -2
  120. package/dist/reference/page-notation.d.ts +34 -4
  121. package/dist/reference/page-notation.js +54 -2
  122. package/dist/reference/references.d.ts +11 -1
  123. package/dist/reference/references.js +6 -0
  124. package/dist/reference/registry.js +104 -10
  125. package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
  126. package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
  127. package/dist/reference/rehype-heading-anchors.mjs +11 -1
  128. package/dist/reference/remark-citation.mjs +0 -0
  129. package/dist/reference/remark-notation.mjs +84 -20
  130. package/dist/reference/render-lab-math.d.ts +2 -0
  131. package/dist/reference/render-lab-math.js +17 -5
  132. package/dist/reference/sections-validate.d.ts +28 -3
  133. package/dist/reference/sections-validate.js +31 -4
  134. package/dist/reference/sections.d.mts +24 -6
  135. package/dist/reference/sections.mjs +92 -17
  136. package/dist/reference/tables-validate.d.ts +1 -1
  137. package/dist/reference/tables-validate.js +24 -1
  138. package/dist/reference/text-measure.d.ts +10 -0
  139. package/dist/reference/text-measure.js +41 -0
  140. package/dist/reference/text-widths.d.ts +10 -0
  141. package/dist/reference/text-widths.js +1238 -0
  142. package/dist/reference/types.d.ts +64 -8
  143. package/dist/rules/catalog.js +191 -30
  144. package/dist/rules/in-effect.d.ts +1 -1
  145. package/dist/rules/rules.schema.json +79 -24
  146. package/dist/rules/settings.d.ts +1 -1
  147. package/dist/rules/settings.js +7 -0
  148. package/dist/session/PreferenceStore.d.ts +18 -1
  149. package/dist/session/PreferenceStore.js +29 -1
  150. package/docs/authoring/README.md +32 -21
  151. package/docs/authoring/agents.md +38 -7
  152. package/docs/authoring/components.md +111 -6
  153. package/docs/authoring/migrating.md +707 -0
  154. package/docs/authoring/notation.md +281 -29
  155. package/docs/authoring/retired.md +1 -0
  156. package/docs/authoring/structure-rules.md +10 -41
  157. package/docs/authoring/structure.md +53 -2
  158. package/docs/authoring/visualizations.md +144 -18
  159. package/package.json +12 -7
  160. package/src/compiler/astro.ts +57 -17
  161. package/src/compiler/cli.ts +143 -35
  162. package/src/compiler/collections.ts +92 -19
  163. package/src/compiler/content-schema.ts +108 -0
  164. package/src/compiler/content-schemas.ts +391 -0
  165. package/src/compiler/course-config.ts +86 -15
  166. package/src/compiler/exports/combined-html.ts +669 -0
  167. package/src/compiler/exports/download-index.ts +36 -0
  168. package/src/compiler/exports/export-chapters.ts +233 -0
  169. package/src/compiler/exports/export-pages.ts +44 -0
  170. package/src/compiler/exports/html-tree.ts +62 -0
  171. package/src/compiler/exports/latex/capture.ts +651 -0
  172. package/src/compiler/exports/latex/compile.ts +168 -0
  173. package/src/compiler/exports/latex/document.ts +479 -0
  174. package/src/compiler/exports/latex/explico.cls +415 -0
  175. package/src/compiler/exports/latex/numbering.ts +74 -0
  176. package/src/compiler/exports/latex/serialize.ts +787 -0
  177. package/src/compiler/exports/latex/texlive-packages.txt +76 -0
  178. package/src/compiler/exports/latex-export.ts +255 -0
  179. package/src/compiler/exports/markdown/serialize.ts +632 -0
  180. package/src/compiler/exports/markdown-export.ts +329 -0
  181. package/src/compiler/exports/standalone-html.ts +39 -70
  182. package/src/compiler/exports/static-tree.ts +944 -0
  183. package/src/compiler/exports/zip.ts +88 -0
  184. package/src/compiler/lesson-derivation.ts +13 -8
  185. package/src/compiler/manifest.ts +299 -28
  186. package/src/compiler/render-diagnostics.ts +111 -0
  187. package/src/components/HoverPanel.astro +204 -6
  188. package/src/components/PartOverview.astro +12 -9
  189. package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
  190. package/src/components/assessments/AssessmentRunner.tsx +7 -1
  191. package/src/components/assessments/AssessmentSet.astro +14 -1
  192. package/src/components/block-heading.ts +38 -0
  193. package/src/components/card-arrow.ts +18 -4
  194. package/src/components/card-carry.ts +406 -0
  195. package/src/components/card-drag.ts +1 -30
  196. package/src/components/card-fetch.ts +87 -0
  197. package/src/components/card-manager.ts +1222 -38
  198. package/src/components/card-memory.ts +5 -1
  199. package/src/components/card-placement.ts +211 -13
  200. package/src/components/card-side.ts +281 -31
  201. package/src/components/citation/CitationLayer.astro +45 -40
  202. package/src/components/diagram/Diagram.astro +123 -44
  203. package/src/components/figure/Figure.astro +11 -0
  204. package/src/components/figure/FloatCaption.astro +4 -17
  205. package/src/components/float-toolbar.ts +274 -0
  206. package/src/components/memo/Memo.astro +21 -10
  207. package/src/components/memo/ShortNote.astro +4 -0
  208. package/src/components/notation/NotationGlossary.astro +22 -17
  209. package/src/components/notation/NotationLayer.astro +321 -79
  210. package/src/components/notation/NotationMath.astro +8 -1
  211. package/src/components/notation/render-notation-math.ts +51 -5
  212. package/src/components/readable-text.ts +25 -0
  213. package/src/components/reference/FloatRefLayer.astro +162 -89
  214. package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
  215. package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
  216. package/src/components/starlight/LayoutPageSidebar.astro +17 -3
  217. package/src/components/starlight/LayoutSocialIcons.astro +79 -29
  218. package/src/components/starlight/LessonExportActions.astro +384 -310
  219. package/src/components/starlight/LessonFooter.astro +47 -9
  220. package/src/components/starlight/PrintController.astro +28 -0
  221. package/src/components/starlight/TextSizeControl.astro +137 -0
  222. package/src/components/starlight/print-controller.ts +380 -0
  223. package/src/components/starlight/section-summary.ts +13 -5
  224. package/src/components/table/Table.astro +13 -21
  225. package/src/components/table/table-truncation.ts +15 -0
  226. package/src/content-config.ts +20 -312
  227. package/src/curriculum/validation.ts +72 -24
  228. package/src/reference/base-abbreviations.mjs +2 -2
  229. package/src/reference/base-operators.mjs +2 -2
  230. package/src/reference/base-roman-labels.mjs +4 -3
  231. package/src/reference/citation-format.mjs +74 -0
  232. package/src/reference/consistency.ts +47 -61
  233. package/src/reference/curriculum-alignment.ts +4 -41
  234. package/src/reference/derived-entries.ts +68 -0
  235. package/src/reference/derived-notation.mjs +121 -0
  236. package/src/reference/diagram-fit.ts +104 -0
  237. package/src/reference/diagram-layout.ts +182 -51
  238. package/src/reference/diagrams-validate.ts +2 -1
  239. package/src/reference/equations-validate.ts +41 -2
  240. package/src/reference/equations.mjs +120 -22
  241. package/src/reference/examples-validate.ts +1 -0
  242. package/src/reference/figure-fit.ts +139 -0
  243. package/src/reference/figure-pictures.ts +96 -0
  244. package/src/reference/figures-validate.ts +2 -1
  245. package/src/reference/gate-abbreviations.ts +1 -1
  246. package/src/reference/gate-course-rules.ts +22 -7
  247. package/src/reference/gate-heading-length.ts +25 -7
  248. package/src/reference/gate-heading-structure.ts +40 -1
  249. package/src/reference/gate-indices.ts +470 -0
  250. package/src/reference/gate-math.ts +102 -13
  251. package/src/reference/gate-mdx-expression.ts +45 -0
  252. package/src/reference/gate-prop-text.ts +145 -0
  253. package/src/reference/gate-prose-notation.ts +19 -5
  254. package/src/reference/gate-retired-keys.ts +101 -0
  255. package/src/reference/gloss.ts +2 -0
  256. package/src/reference/katex-measure.ts +384 -0
  257. package/src/reference/keyed-float-validate.ts +22 -0
  258. package/src/reference/keyed-jsx-floats.mjs +11 -14
  259. package/src/reference/lab-math-scope.ts +15 -1
  260. package/src/reference/math-glyphs.mjs +851 -46
  261. package/src/reference/mixed-content.ts +11 -2
  262. package/src/reference/page-notation.ts +100 -3
  263. package/src/reference/references.ts +14 -0
  264. package/src/reference/registry.ts +154 -8
  265. package/src/reference/rehype-fail-katex-errors.mjs +19 -0
  266. package/src/reference/rehype-heading-anchors.mjs +11 -1
  267. package/src/reference/remark-citation.mjs +0 -0
  268. package/src/reference/remark-notation.mjs +84 -20
  269. package/src/reference/render-lab-math.ts +29 -5
  270. package/src/reference/sections-validate.ts +67 -3
  271. package/src/reference/sections.mjs +92 -17
  272. package/src/reference/tables-validate.ts +40 -2
  273. package/src/reference/text-measure.ts +63 -0
  274. package/src/reference/text-widths.ts +133 -0
  275. package/src/reference/types.ts +75 -8
  276. package/src/rules/catalog.ts +205 -29
  277. package/src/rules/settings.ts +10 -0
  278. package/src/session/PreferenceStore.ts +53 -0
  279. package/src/styles/global.css +291 -75
@@ -35,9 +35,12 @@ export const NOTATION_KEY_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
35
35
 
36
36
  import {
37
37
  GlyphResolutionError,
38
+ baseSymbolLatex,
39
+ derivedNotationLatex,
38
40
  resolveMathGlyphs,
39
41
  stripNotationMarkers,
40
42
  } from './math-glyphs.mjs';
43
+ import { composeDerivedEntry } from './derived-notation.mjs';
41
44
  import {
42
45
  EQ_CAPTION_PATTERN,
43
46
  EQ_LABEL_PATTERN,
@@ -234,6 +237,26 @@ function collectPageSharedKeys(tree) {
234
237
  return keys;
235
238
  }
236
239
 
240
+ /**
241
+ * A derived card (rule N23) as the remark pass records it: the frontmatter of
242
+ * its base and index entries composed (`derived-notation.mjs`). Only an
243
+ * authored `label` is known here, so the title is composed when both entries
244
+ * author one, which is what a table header's label needs.
245
+ */
246
+ function derivedResolution(key, base, index) {
247
+ const entry = composeDerivedEntry({
248
+ key,
249
+ notation: derivedNotationLatex(base.notation, index.notation),
250
+ baseLatex: baseSymbolLatex(base.notation),
251
+ base: { ...base, label: base.label ?? '' },
252
+ index: { ...index, label: index.label ?? '', summary: index.meaning },
253
+ });
254
+ const labelled =
255
+ typeof base.label === 'string' && typeof index.label === 'string';
256
+ const { label, ...rest } = entry;
257
+ return { ...rest, ...(labelled ? { label } : {}), scope: 'derived' };
258
+ }
259
+
237
260
  function pageGlyphScope(localDefinitions, sharedDefinitions, pageSharedKeys) {
238
261
  return [
239
262
  ...localDefinitions.values(),
@@ -246,6 +269,8 @@ function pageGlyphScope(localDefinitions, sharedDefinitions, pageSharedKeys) {
246
269
  ].map((definition) => ({
247
270
  key: definition.key,
248
271
  notation: definition.notation,
272
+ // Rule N23: an entry whose index is a fixed label is no family.
273
+ ...(definition.fixedIndex === true ? { fixedIndex: true } : {}),
249
274
  }));
250
275
  }
251
276
 
@@ -405,8 +430,9 @@ function inlineEquationNode(mathNode) {
405
430
  * in a lesson is numbered; a keyed one anchors at `#eq-<key>`, an unkeyed one
406
431
  * at its positional `#eq-<section>-<index>`. The visible number is a focusable
407
432
  * `<a>` sibling — never authored, never inside KaTeX — so it survives print and
408
- * screen readers name it "Equation 2.4". A "Copy LaTeX" button sits beside it
409
- * (`.keyed-equation__controls`, styled in `global.css`; titled "Copy LaTeX —
433
+ * screen readers name it "Equation 2.4". A "Copy LaTeX" button sits on the
434
+ * equation's bottom border, under the number (`.keyed-equation__copy`, styled
435
+ * in `global.css` and placed by `float-toolbar.ts`; titled "Copy LaTeX —
410
436
  * ‹short›" when the equation has a `\caption{}`) — the equation's own
411
437
  * copy affordance, distinct from the notation popup card's (`NotationLayer.astro`),
412
438
  * which only exists for a symbol with a registry entry, not a bare display
@@ -451,19 +477,21 @@ function equationNode(mathNode, { key, number, short }) {
451
477
  },
452
478
  },
453
479
  },
454
- el(
455
- 'button',
456
- {
457
- type: 'button',
458
- className: ['keyed-equation__copy'],
459
- 'data-eq-copy': '',
460
- 'data-latex': latex,
461
- 'aria-label': `Copy LaTeX for Equation ${number}`,
462
- title: short ? `Copy LaTeX — ${short}` : 'Copy LaTeX',
463
- },
464
- [copyIconNode()],
465
- ),
466
480
  ]),
481
+ // On the block's border, not beside the number: the number's toolbar
482
+ // (`float-toolbar.ts`) takes the room around the number.
483
+ el(
484
+ 'button',
485
+ {
486
+ type: 'button',
487
+ className: ['keyed-equation__copy'],
488
+ 'data-eq-copy': '',
489
+ 'data-latex': latex,
490
+ 'aria-label': `Copy LaTeX for Equation ${number}`,
491
+ title: short ? `Copy LaTeX — ${short}` : 'Copy LaTeX',
492
+ },
493
+ [copyIconNode()],
494
+ ),
467
495
  ],
468
496
  };
469
497
  }
@@ -581,7 +609,9 @@ function blockingComponent(tree, target) {
581
609
  node.name &&
582
610
  !TRANSPARENT_COMPONENTS.includes(node.name)
583
611
  ? node.name
584
- : blocking;
612
+ : node.type === 'containerDirective'
613
+ ? `:::${node.name ?? ''}`
614
+ : blocking;
585
615
  for (const child of node.children) {
586
616
  const found = search(child, here);
587
617
  if (found !== undefined) return found;
@@ -593,11 +623,16 @@ function blockingComponent(tree, target) {
593
623
 
594
624
  /**
595
625
  * Why a `<Table>`/`<Figure>` on a lesson page got no number: it sits in a
596
- * component other than the `TRANSPARENT_COMPONENTS`, where a float is
597
- * illustrative, as an equation there is (`keyed-jsx-floats.mjs`).
626
+ * component other than the `TRANSPARENT_COMPONENTS`, or in a `:::note`
627
+ * aside, where a float is illustrative, as an equation there is
628
+ * (`keyed-jsx-floats.mjs`).
598
629
  */
599
630
  function unnumberedFloatMessage(tagName, component) {
600
- const where = component ? `<${component}>` : 'another component';
631
+ const where = !component
632
+ ? 'another component'
633
+ : component.startsWith(':::')
634
+ ? `a ${component} aside`
635
+ : `<${component}>`;
601
636
  const noun = tagName === 'Table' ? 'table' : 'figure';
602
637
  const instead =
603
638
  tagName === 'Table'
@@ -1305,6 +1340,16 @@ function assertManifestNumberParity({
1305
1340
 
1306
1341
  for (const [key, number] of pageLabels) {
1307
1342
  const expected = published[key];
1343
+ if (expected === undefined && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(key)) {
1344
+ // The manifest left it out on purpose: `validate:content` reports the
1345
+ // id (`<prefix>-invalid-key`), and recompiling changes nothing.
1346
+ fail(
1347
+ file,
1348
+ `${slug}: the id in ${syntax(key)} is not a valid key — lowercase ` +
1349
+ `letters, digits and hyphens only — so the manifest leaves it unnumbered. ` +
1350
+ `Fix the id.`,
1351
+ );
1352
+ }
1308
1353
  if (expected === undefined) {
1309
1354
  fail(
1310
1355
  file,
@@ -1744,6 +1789,9 @@ export default function remarkNotation(options = {}) {
1744
1789
  // Suspension (rule N22) is lesson-body scope only.
1745
1790
  result = resolveMathGlyphs(value, bindingScope, {
1746
1791
  suspended: lessonId ? suspendedOperators : [],
1792
+ // Derived cards (rule N23) are a page's own, built by the
1793
+ // manifest for its body math — not the registry page's.
1794
+ derive: Boolean(lessonId || nonChapterPage),
1747
1795
  });
1748
1796
  } catch (error) {
1749
1797
  const message =
@@ -1758,13 +1806,15 @@ export default function remarkNotation(options = {}) {
1758
1806
  if (result.unresolved.length > 0) {
1759
1807
  const unresolved = result.unresolved
1760
1808
  .map(
1761
- ({ token, start, suspended, font }) =>
1809
+ ({ token, start, suspended, font, lenders }) =>
1762
1810
  `${tokenLocation(file, node, value, start)}: "${token}"${
1763
1811
  suspended
1764
1812
  ? ` (${suspended} is suspended on this page, so this form needs its own entry)`
1765
1813
  : font
1766
1814
  ? ' (a different symbol from the plain letter; it needs its own entry)'
1767
- : ''
1815
+ : lenders
1816
+ ? ` (${lenders.join(', ')} explains only its own instances with a subscript, never a bare "${token}", rule N4: give it its own entry, or \\explain{${lenders[0]}}{${token}} if it is that entry's shorthand)`
1817
+ : ''
1768
1818
  }`,
1769
1819
  )
1770
1820
  .join(', ');
@@ -1779,9 +1829,23 @@ export default function remarkNotation(options = {}) {
1779
1829
 
1780
1830
  for (const binding of result.bindings) {
1781
1831
  if (binding.level === 'explicit') continue;
1832
+ if (binding.match === 'derived') continue;
1782
1833
  resolve(binding.key, 'math', node);
1783
1834
  references.push({ key: binding.key, kind: 'math' });
1784
1835
  }
1836
+ // A derived card (rule N23) uses its base and its index entry; it
1837
+ // is recorded composed, so a table header reads its own label.
1838
+ for (const derived of result.derived) {
1839
+ const base = resolve(derived.base, 'math', node);
1840
+ const index = resolve(derived.index, 'math', node);
1841
+ references.push(
1842
+ { key: derived.base, kind: 'math' },
1843
+ { key: derived.index, kind: 'math' },
1844
+ );
1845
+ if (base && index) {
1846
+ resolved.set(derived.key, derivedResolution(derived.key, base, index));
1847
+ }
1848
+ }
1785
1849
  replaceMathSource(node, result.latex);
1786
1850
  }
1787
1851
  return;
@@ -2,6 +2,9 @@ import katex from './katex-delimiters.mjs';
2
2
  import { resolveMathGlyphs, stripNotationMarkers } from './math-glyphs.mjs';
3
3
  import { createNotationKatexOptions } from './katex-options.mjs';
4
4
  import { slotPlaceholder, type RenderedLabMath } from './lab-math-slot';
5
+ import { pageOfScope, registerPageDerivedNotation } from './page-notation';
6
+ import { indexFindings } from './gate-indices';
7
+ import type { DerivedUse, IndexedSymbol } from './types';
5
8
 
6
9
  // Re-exported so server code keeps one import. An island imports these from
7
10
  // `lab-math-slot` instead: this module is server-only.
@@ -10,6 +13,8 @@ export { slotPlaceholder, type RenderedLabMath };
10
13
  export interface LabMathScopeDefinition {
11
14
  readonly key: string;
12
15
  readonly notation: string;
16
+ /** Rule N23: the entry's index is a fixed label, so it is no family. */
17
+ readonly fixedIndex?: true;
13
18
  }
14
19
 
15
20
  export interface RenderLabMathOptions {
@@ -26,6 +31,8 @@ interface BindResult {
26
31
  latex: string;
27
32
  bindings: ReadonlyArray<{ key: string }>;
28
33
  unresolved: ReadonlyArray<{ token: string; start: number; font?: string }>;
34
+ derived: readonly DerivedUse[];
35
+ indices: readonly IndexedSymbol[];
29
36
  }
30
37
 
31
38
  // `\slot{name}{fallback}` — `name` is a lab-slot id, `fallback` may contain one
@@ -58,9 +65,14 @@ export function renderLabMath(
58
65
  },
59
66
  );
60
67
 
68
+ // A table `labMathScope` built knows its page: its indexed symbols take
69
+ // derived cards there (rule N23), registered so the page ships them.
70
+ const page = pageOfScope(scope);
61
71
  let result: BindResult;
62
72
  try {
63
- result = resolveMathGlyphs(forBinding, [...scope]) as BindResult;
73
+ result = resolveMathGlyphs(forBinding, [...scope], {
74
+ derive: page !== undefined,
75
+ }) as BindResult;
64
76
  } catch (error) {
65
77
  throw new Error(
66
78
  `Lab math failed to compile: ${
@@ -87,6 +99,17 @@ export function renderLabMath(
87
99
  );
88
100
  }
89
101
 
102
+ if (page?.checkIndices) {
103
+ const findings = indexFindings(result.indices);
104
+ if (findings.length > 0) {
105
+ throw new Error(
106
+ `Lab math has an index no card explains (rule N23, ${page.context}): ` +
107
+ `${findings.join('; ')}.\n template: ${template.trim()}`,
108
+ );
109
+ }
110
+ }
111
+ if (page) registerPageDerivedNotation(page.locals, result.derived, page.context);
112
+
90
113
  let bound = result.latex;
91
114
  let copyBound = result.latex;
92
115
  slots.forEach(({ name, fallback }, index) => {
@@ -104,10 +127,11 @@ export function renderLabMath(
104
127
  copyBound = copyBound.replace(sentinel, slotPlaceholder(name));
105
128
  });
106
129
 
107
- const html = katex.renderToString(
108
- bound,
109
- createNotationKatexOptions({ displayMode: display }),
110
- );
130
+ // The site search indexes a formula's glyphs, not its MathML copy and the
131
+ // LaTeX annotation in it (`rehype-fail-katex-errors.mjs`).
132
+ const html = katex
133
+ .renderToString(bound, createNotationKatexOptions({ displayMode: display }))
134
+ .replaceAll('<span class="katex-mathml">', '<span class="katex-mathml" data-pagefind-ignore="">');
111
135
 
112
136
  return {
113
137
  html,
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import type { MathGateDiagnostic } from './gate-math';
12
12
  import { lessonLine } from './gate-prose-notation';
13
- import { parseSectionRef } from './sections.mjs';
13
+ import { parseSectionRef, scanOutlineBlocks } from './sections.mjs';
14
14
 
15
15
  export interface SectionRefLessonInput {
16
16
  /** Lesson slug, e.g. `motion/position-and-coordinates`. */
@@ -36,10 +36,19 @@ function withoutCode(text: string): string {
36
36
  function unresolved(
37
37
  ref: { readonly lesson?: string; readonly key: string },
38
38
  lessonExists: boolean,
39
+ pages: ReadonlySet<string>,
39
40
  ): string {
40
41
  const written = ref.lesson
41
42
  ? `[[${ref.lesson}#sec-${ref.key}]]`
42
43
  : `[[sec-${ref.key}]]`;
44
+ const page = ref.lesson?.replace(/\/index$/, '');
45
+ if (page !== undefined && !lessonExists && pages.has(page)) {
46
+ return (
47
+ `${written} names ${page}, a page that is not a chapter: a section reference ` +
48
+ `reaches a chapter's top-level headings only (rule S10). Link the heading ` +
49
+ `with a Markdown link instead, [its words](/${page}/#${ref.key}).`
50
+ );
51
+ }
43
52
  if (ref.lesson && !lessonExists) {
44
53
  return `${written} names lesson ${ref.lesson}, which does not exist.`;
45
54
  }
@@ -52,10 +61,17 @@ function unresolved(
52
61
  );
53
62
  }
54
63
 
55
- /** One `sec-ref-resolves` error per `[[sec-…]]` that names no heading. */
64
+ /**
65
+ * One `sec-ref-resolves` error per `[[sec-…]]` that names no heading.
66
+ * `pageSlugs` are the pages that are not chapters (`motion/index`), which a
67
+ * section reference cannot reach; the message says so instead of calling the
68
+ * page missing.
69
+ */
56
70
  export function validateSectionRefs(
57
71
  lessons: readonly SectionRefLessonInput[],
72
+ pageSlugs: readonly string[] = [],
58
73
  ): MathGateDiagnostic[] {
74
+ const pages = new Set(pageSlugs.map((slug) => slug.replace(/\/index$/, '')));
59
75
  const headingsByLesson = new Map(
60
76
  lessons.map((lesson) => [lesson.slug, new Set(lesson.headingSlugs)]),
61
77
  );
@@ -73,9 +89,57 @@ export function validateSectionRefs(
73
89
  file: lesson.file,
74
90
  line: lessonLine(lesson, match.index ?? 0),
75
91
  token: match[0],
76
- message: unresolved(ref, headings !== undefined),
92
+ message: unresolved(ref, headings !== undefined, pages),
77
93
  });
78
94
  }
79
95
  }
80
96
  return diagnostics;
81
97
  }
98
+
99
+ export interface HeadingAnchorLessonInput {
100
+ readonly file: string;
101
+ readonly body: string;
102
+ readonly bodyLine?: number;
103
+ /** The lesson's outline headings in order (`manifest.lessons[].headings`). */
104
+ readonly headings: readonly { readonly slug: string; readonly text: string }[];
105
+ /** Every anchor the engine writes on the page, with what it belongs to. */
106
+ readonly anchors: ReadonlyMap<string, string>;
107
+ }
108
+
109
+ /**
110
+ * One `heading-anchor-collision` error per heading whose anchor the engine
111
+ * already gives something else on the page — `## References` on a page that
112
+ * cites (the generated References section is `#references`), or `## Tab x`
113
+ * beside `<Table id="x">` (`#tab-x`). Two elements with one id send every link
114
+ * to the first, and the LaTeX export defines the label twice.
115
+ */
116
+ export function validateHeadingAnchors(
117
+ lessons: readonly HeadingAnchorLessonInput[],
118
+ ): MathGateDiagnostic[] {
119
+ const diagnostics: MathGateDiagnostic[] = [];
120
+ for (const lesson of lessons) {
121
+ const offsets = scanOutlineBlocks(lesson.body)
122
+ .filter((block) => block.kind === 'heading')
123
+ .map((block) => block.index);
124
+ lesson.headings.forEach((heading, index) => {
125
+ const owner = lesson.anchors.get(heading.slug);
126
+ if (owner === undefined) return;
127
+ const advice =
128
+ heading.slug === 'references'
129
+ ? 'Rename the heading, or drop it: the References section is generated from the page’s citations.'
130
+ : `Rename the heading, or give ${owner} another id.`;
131
+ diagnostics.push({
132
+ context: 'structure',
133
+ code: 'heading-anchor-collision',
134
+ severity: 'error',
135
+ file: lesson.file,
136
+ line: lessonLine(lesson, offsets[index] ?? 0),
137
+ token: heading.text,
138
+ message:
139
+ `The heading "${heading.text}" gets the anchor #${heading.slug}, which ${owner} ` +
140
+ `already has on this page, so a link to either lands on the first. ${advice}`,
141
+ });
142
+ });
143
+ }
144
+ return diagnostics;
145
+ }
@@ -27,6 +27,8 @@
27
27
  */
28
28
 
29
29
  import { rehypeHeadingIds } from '@astrojs/markdown-remark';
30
+ import katex from 'katex';
31
+ import { createNotationKatexOptions } from './katex-options.mjs';
30
32
  import { headingCardTier, stripHeadingMarker } from './rehype-heading-anchors.mjs';
31
33
 
32
34
  /** The deepest heading level numbered when a course sets nothing (`####`). */
@@ -113,19 +115,18 @@ const ATX_HEADING = /^[ \t]{0,3}(#{1,6})(?:[ \t]+([^\n]*?))?(?:[ \t]+#+)?[ \t]*$
113
115
  const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
114
116
 
115
117
  /**
116
- * The text a heading's source renders as, near enough to slug it the way
117
- * Astro would: markup goes, the words stay. Inline code and math keep their
118
- * source (math renders as KaTeX, whose text is no use as a slug); a notation
119
- * term keeps its key; a link keeps its text; an image, a citation and an MDX
118
+ * A heading source's text, with `math` and `term` deciding what a `$…$` span
119
+ * and a `[[key]]` contribute. Markup goes, the words stay; inline code keeps
120
+ * its source; a link keeps its text; an image, a citation and an MDX
120
121
  * expression contribute nothing; the authoring marker is dropped.
121
122
  */
122
- export function headingPlainText(source) {
123
+ function headingText(source, { math, term }) {
123
124
  const literals = [];
124
125
  const keep = (value) => `\u0000${literals.push(value) - 1}\u0000`;
125
126
  let text = stripHeadingMarker(source ?? '')
126
127
  .replace(/(`+)([\s\S]*?[^`])\1(?!`)/g, (_, _ticks, code) => keep(code.trim()))
127
- .replace(/\$([^$\n]+)\$/g, (_, latex) => keep(latex.trim()))
128
- .replace(/\[\[\s*([^\][]+?)\s*\]\]/g, (_, key) => keep(key))
128
+ .replace(/\$([^$\n]+)\$/g, (_, latex) => keep(math(latex.trim())))
129
+ .replace(/\[\[\s*([^\][]+?)\s*\]\]/g, (_, key) => keep(term(key)))
129
130
  .replace(/\[@[^\]]*\]/g, '')
130
131
  .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
131
132
  .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
@@ -138,17 +139,84 @@ export function headingPlainText(source) {
138
139
  .replace(/(^|[^\w])_(?=\S)([\s\S]*?\S)_(?!\w)/g, '$1$2')
139
140
  .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '$1')
140
141
  .replace(/\\([!-/:-@[-`{-~])/g, '$1')
141
- .replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (entity, name) => {
142
- if (name[0] !== '#') return ENTITIES[name.toLowerCase()] ?? entity;
143
- const code = name[1] === 'x' || name[1] === 'X'
144
- ? Number.parseInt(name.slice(2), 16)
145
- : Number.parseInt(name.slice(1), 10);
146
- return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
147
- });
142
+ .replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (entity, name) => decodeEntity(entity, name));
148
143
  text = text.replace(/\u0000(\d+)\u0000/g, (_, index) => literals[Number(index)]);
149
144
  return text.replace(/\s+/g, ' ').trim();
150
145
  }
151
146
 
147
+ function decodeEntity(entity, name) {
148
+ if (name[0] !== '#') return ENTITIES[name.toLowerCase()] ?? entity;
149
+ const code = name[1] === 'x' || name[1] === 'X'
150
+ ? Number.parseInt(name.slice(2), 16)
151
+ : Number.parseInt(name.slice(1), 10);
152
+ return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
153
+ }
154
+
155
+ /**
156
+ * The text a heading's source slugs as, near enough to the way Astro would:
157
+ * math as it reads, a notation term its key. A heading's anchor is made from
158
+ * it, so a plain heading keeps the id it always had; math reads as it renders
159
+ * (`mathSlugText`), so `## The range $\explain{motion.range}{R}$` is
160
+ * `#the-range-r` and not `#the-range-explainmotionranger`.
161
+ */
162
+ export function headingPlainText(source) {
163
+ return headingText(source, { math: mathSlugText, term: (key) => key });
164
+ }
165
+
166
+ /** A Greek letter as LaTeX names it, for a slug that stays in ASCII. */
167
+ const GREEK_NAMES = {
168
+ α: 'alpha', β: 'beta', γ: 'gamma', δ: 'delta', ε: 'epsilon', ϵ: 'epsilon', ζ: 'zeta', η: 'eta',
169
+ θ: 'theta', ϑ: 'theta', ι: 'iota', κ: 'kappa', λ: 'lambda', μ: 'mu', ν: 'nu', ξ: 'xi', ο: 'omicron',
170
+ π: 'pi', ϖ: 'pi', ρ: 'rho', ϱ: 'rho', σ: 'sigma', ς: 'sigma', τ: 'tau', υ: 'upsilon', φ: 'phi',
171
+ ϕ: 'phi', χ: 'chi', ψ: 'psi', ω: 'omega', Γ: 'Gamma', Δ: 'Delta', Θ: 'Theta', Λ: 'Lambda',
172
+ Ξ: 'Xi', Π: 'Pi', Σ: 'Sigma', Υ: 'Upsilon', Φ: 'Phi', Ψ: 'Psi', Ω: 'Omega',
173
+ };
174
+
175
+ /**
176
+ * The text a heading's math slugs as: what it reads, each Greek letter spelled
177
+ * as LaTeX names it. `$e^{\lambda\cos(u)}$` reads `eλcos(u)` and slugs as
178
+ * `elambdacosu`, the anchor its source always gave it; what changes is the
179
+ * markup that never shows (`\explain{motion.range}{R}` is `R`).
180
+ */
181
+ function mathSlugText(latex) {
182
+ return mathPlainText(latex).replace(/[\u0370-\u03FF]/g, (char) => GREEK_NAMES[char] ?? char);
183
+ }
184
+
185
+ /**
186
+ * The text a reader sees for a math span: KaTeX's MathML without the LaTeX
187
+ * annotation, so `$\explain{motion.range}{R}$` reads `R` and `$v_0$` reads
188
+ * `v0`. Falls back to the source when KaTeX cannot render it.
189
+ */
190
+ export function mathPlainText(latex) {
191
+ let markup;
192
+ try {
193
+ markup = katex.renderToString(latex, createNotationKatexOptions());
194
+ } catch {
195
+ return latex;
196
+ }
197
+ const mathml = /<math[\s\S]*?<\/math>/.exec(markup)?.[0] ?? '';
198
+ return mathml
199
+ .replace(/<annotation[\s\S]*?<\/annotation>/g, '')
200
+ .replace(/<[^>]+>/g, '')
201
+ .replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (entity, name) => decodeEntity(entity, name))
202
+ .replace(/[\u200b\u2061-\u2064]/g, '')
203
+ .replace(/\s+/g, ' ')
204
+ .trim();
205
+ }
206
+
207
+ /**
208
+ * The text a reader sees for a heading: math as it reads (`mathPlainText`)
209
+ * and a `[[key]]` term as its label, from `labelFor` (the page's notation),
210
+ * else as its key. "On this page", an unnumbered section's quoted title and a
211
+ * copied link name use it; the anchor is `headingPlainText`'s.
212
+ */
213
+ export function headingDisplayText(source, { labelFor } = {}) {
214
+ return headingText(source, {
215
+ math: mathPlainText,
216
+ term: (key) => labelFor?.(key) ?? key,
217
+ });
218
+ }
219
+
152
220
  /**
153
221
  * Anchors for a page's headings, in order, from Astro's own `rehypeHeadingIds`
154
222
  * run over their plain text: the same slugger with the same duplicate
@@ -171,18 +239,24 @@ export function slugHeadings(texts) {
171
239
  /**
172
240
  * Every top-level heading of a lesson body in document order, as the
173
241
  * manifest records it: `{ level, text, slug, number? }`. `prefix` is the
174
- * chapter's `‹part›.‹chapter›`; without one nothing is numbered.
242
+ * chapter's `‹part›.‹chapter›`; without one nothing is numbered. `text` is
243
+ * what a reader sees (`headingDisplayText`, with `labelFor` naming a
244
+ * `[[key]]` term); the `slug` is made from `headingPlainText`.
245
+ * `math: true` marks a heading with inline math, whose text Astro's own
246
+ * heading list garbles (`LayoutPageSidebar.astro` relabels it).
175
247
  */
176
248
  export function scanSectionHeadings(markdown, options = {}) {
177
249
  const found = [];
178
250
  for (const match of outlineSource(markdown).matchAll(ATX_HEADING)) {
179
251
  found.push({
180
252
  level: match[1].length,
181
- text: headingPlainText(match[2] ?? ''),
253
+ slugText: headingPlainText(match[2] ?? ''),
254
+ text: headingDisplayText(match[2] ?? '', options),
255
+ math: /\$[^$\n]+\$/.test(stripHeadingMarker(match[2] ?? '')),
182
256
  meta: headingCardTier(match[2] ?? '') === 'meta',
183
257
  });
184
258
  }
185
- const slugs = slugHeadings(found.map((heading) => heading.text));
259
+ const slugs = slugHeadings(found.map((heading) => heading.slugText));
186
260
  const numbers = sectionNumbers(
187
261
  found.map((heading) => heading.level),
188
262
  { ...options, unnumbered: found.map((heading) => heading.meta) },
@@ -192,6 +266,7 @@ export function scanSectionHeadings(markdown, options = {}) {
192
266
  text: heading.text,
193
267
  slug: slugs[index],
194
268
  ...(numbers[index] === undefined ? {} : { number: numbers[index] }),
269
+ ...(heading.math ? { math: true } : {}),
195
270
  }));
196
271
  }
197
272
 
@@ -12,7 +12,9 @@ export type TableDiagnosticCode =
12
12
  | 'tab-duplicate-key'
13
13
  | 'tab-ref-resolves'
14
14
  | 'tab-key-unused'
15
- | 'tab-removed-prop';
15
+ | 'tab-removed-prop'
16
+ | 'tab-unread-prop'
17
+ | 'tab-several-tables';
16
18
 
17
19
  export interface TableDiagnostic {
18
20
  readonly code: TableDiagnosticCode;
@@ -33,9 +35,45 @@ export interface TableValidation extends KeyedFloatValidation {
33
35
  export function validateTables(
34
36
  lessons: readonly TableLessonInput[],
35
37
  ): TableValidation {
36
- return validateKeyedFloats(lessons, {
38
+ const validation = validateKeyedFloats(lessons, {
37
39
  tagName: 'Table',
38
40
  prefix: 'tab',
39
41
  noun: 'table',
40
42
  }) as TableValidation;
43
+ const several = lessons.flatMap(severalTables);
44
+ return several.length === 0
45
+ ? validation
46
+ : { ...validation, diagnostics: [...validation.diagnostics, ...several] };
47
+ }
48
+
49
+ /** A pipe table's delimiter row, `| --- | :-: |`: one per pipe table. */
50
+ const DELIMITER_ROW = /^[ \t]*\|?(?:[ \t]*:?-+:?[ \t]*\|)+(?:[ \t]*:?-+:?[ \t]*)?$/gm;
51
+
52
+ /**
53
+ * A `<Table>` holds one table: it has one number and one caption, and a
54
+ * reference to it names one table. The page showed a second under the same
55
+ * number, and every export kept only the first (`static-tree.ts`), silently.
56
+ */
57
+ function severalTables(lesson: TableLessonInput): TableDiagnostic[] {
58
+ const blank = (match: string) => match.replace(/[^\n]/g, ' ');
59
+ const body = lesson.body.replace(/```[\s\S]*?```/g, blank).replace(/\{\/\*[\s\S]*?\*\/\}/g, blank);
60
+ const found: TableDiagnostic[] = [];
61
+ for (const table of body.matchAll(/<Table\b((?:[^>"']|"[^"]*"|'[^']*')*?)(?<!\/)>([\s\S]*?)<\/Table>/g)) {
62
+ const inner = table[2] ?? '';
63
+ const count = (inner.match(DELIMITER_ROW)?.length ?? 0) + (inner.match(/<table\b/g)?.length ?? 0);
64
+ if (count < 2) continue;
65
+ const key = /\bid\s*=\s*["']([^"']*)["']/.exec(table[1] ?? '')?.[1];
66
+ found.push({
67
+ code: 'tab-several-tables',
68
+ severity: 'error',
69
+ message:
70
+ `${lesson.lessonId} has a <Table${key ? ` id="${key}"` : ''}> holding ${count} tables: a <Table> ` +
71
+ 'holds one, with one number and one caption, and every export keeps only the first. Give ' +
72
+ 'each table its own <Table id>, or join them into one.',
73
+ file: lesson.file,
74
+ lessonId: lesson.lessonId,
75
+ ...(key ? { key } : {}),
76
+ });
77
+ }
78
+ return found;
41
79
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The width of a diagram label, measured at build time.
3
+ *
4
+ * The site draws text in the reader's system font, so no width computed at
5
+ * build time is exact. DejaVu Sans is the widest of the common system
6
+ * sans-serifs (Segoe UI, San Francisco, Roboto, Helvetica, Arial, Noto Sans,
7
+ * Ubuntu, Cantarell), so a label measured with its advance widths
8
+ * (`text-widths.ts`, generated from the font) fits in every one of them.
9
+ * Kerning only ever narrows that, and is ignored: measured in Chromium, the
10
+ * table gives DejaVu Sans's own width or up to 3% more, and no label of the
11
+ * playground or of data-science-I is wider in Liberation Sans (Arial's
12
+ * metrics), Noto Sans or Lato than in DejaVu Sans.
13
+ */
14
+ import { UNITS_PER_EM, WIDTH_BLOCKS } from './text-widths';
15
+
16
+ const WIDTHS = new Map<number, number>();
17
+ for (const [first, widths] of WIDTH_BLOCKS) {
18
+ widths.forEach((width, offset) => {
19
+ if (width >= 0) WIDTHS.set(first + offset, width / UNITS_PER_EM);
20
+ });
21
+ }
22
+
23
+ /** A character outside the table (a CJK ideograph, an emoji) is about 1em. */
24
+ const FALLBACK_EM = 1;
25
+ const COMBINING_MARK = /\p{M}/u;
26
+
27
+ /** One character's advance, in em. */
28
+ function advanceEm(char: string): number {
29
+ if (COMBINING_MARK.test(char)) return 0;
30
+ return WIDTHS.get(char.codePointAt(0) ?? 0) ?? FALLBACK_EM;
31
+ }
32
+
33
+ /** The advance of `text` at `fontPx`, in px, rounded up to a whole pixel. */
34
+ export function textWidth(text: string, fontPx: number): number {
35
+ let em = 0;
36
+ for (const char of text) em += advanceEm(char);
37
+ return Math.ceil(em * fontPx - 1e-6);
38
+ }
39
+
40
+ /**
41
+ * Break `text` into lines no wider than `maxWidth` px, greedily at spaces,
42
+ * the way a browser wraps it. A word wider than `maxWidth` keeps a line of its
43
+ * own (the caller widens the box to it); a space never ends or starts a line.
44
+ */
45
+ export function wrapText(text: string, maxWidth: number, fontPx: number): string[] {
46
+ const words = text.trim().split(/[ \t\r\n]+/u).filter(Boolean);
47
+ const space = textWidth(' ', fontPx);
48
+ const lines: string[] = [];
49
+ let line = '';
50
+ let lineWidth = 0;
51
+ for (const word of words) {
52
+ const wordWidth = textWidth(word, fontPx);
53
+ if (line && lineWidth + space + wordWidth > maxWidth) {
54
+ lines.push(line);
55
+ line = '';
56
+ lineWidth = 0;
57
+ }
58
+ line = line ? `${line} ${word}` : word;
59
+ lineWidth = lineWidth ? lineWidth + space + wordWidth : wordWidth;
60
+ }
61
+ if (line) lines.push(line);
62
+ return lines;
63
+ }