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
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Rule V8: a diagram is no wider than the lesson column.
3
+ *
4
+ * `Diagram.astro` lays a diagram out at build time and so knows its width, but
5
+ * not the page it is on or what the course allows, and a diagram drawn by a
6
+ * wrapper component (`<CourseDiagram kind="…">`) never shows its nodes to the
7
+ * manifest walk. So the check runs on what was rendered: the component marks
8
+ * a diagram wider than {@link diagramWidthLimit} with `data-diagram-too-wide`,
9
+ * `compiler/astro.ts` finds each mark with {@link oversizedDiagrams} on every
10
+ * built page and reports a `dia-too-wide` warning with the lesson file and the
11
+ * diagram id, and `content warnings` lists what the last build reported
12
+ * (`compiler/render-diagnostics.ts`). Under `astro dev` the component prints
13
+ * the same message as it renders.
14
+ */
15
+
16
+ import { parse, type DefaultTreeAdapterMap } from 'parse5';
17
+
18
+ import type { ManifestRules } from '../rules/settings';
19
+
20
+ type HtmlNode = DefaultTreeAdapterMap['node'];
21
+ type HtmlElement = DefaultTreeAdapterMap['element'];
22
+
23
+ /**
24
+ * The lesson column at a 1366px-wide window, the most common laptop screen
25
+ * (632px at 1280, 832px at 1920), unless the course sets
26
+ * `rules.V8.columnWidth`.
27
+ */
28
+ export const DEFAULT_COLUMN_WIDTH = 707;
29
+
30
+ /**
31
+ * The widest a diagram may lay out, or `undefined` when nothing checks it: rule
32
+ * V8 is off, or the diagram says `warnAboveWidth={false}`. A number in
33
+ * `warnAboveWidth` replaces the course's column width for that diagram.
34
+ */
35
+ export function diagramWidthLimit(
36
+ warnAboveWidth: number | false | undefined,
37
+ rules: Pick<ManifestRules, 'disabled' | 'settings'> | undefined,
38
+ ): number | undefined {
39
+ if (warnAboveWidth === false || rules?.disabled.includes('V8')) return undefined;
40
+ if (warnAboveWidth !== undefined) return warnAboveWidth;
41
+ const columnWidth = rules?.settings.V8?.columnWidth;
42
+ return typeof columnWidth === 'number' ? columnWidth : DEFAULT_COLUMN_WIDTH;
43
+ }
44
+
45
+ /** What to tell the author about one diagram wider than its limit. */
46
+ export function diagramTooWideMessage(id: string, width: number, limit: number): string {
47
+ return (
48
+ `Diagram "${id}" lays out ${Math.round(width)}px wide, wider than the ${limit}px it may ` +
49
+ `take (rule V8), so a reader has to scroll it or widen it to see it whole. Try ` +
50
+ `direction="TB", a shorter label, or a narrower node \`width\` (its label wraps inside ` +
51
+ `it); set warnAboveWidth={false} only when the diagram is meant to scroll.`
52
+ );
53
+ }
54
+
55
+ /** A diagram on a built page that is wider than its limit. */
56
+ export interface OversizedDiagram {
57
+ readonly code: 'dia-too-wide';
58
+ /** The diagram's `id`. */
59
+ readonly key: string;
60
+ readonly width: number;
61
+ readonly limit: number;
62
+ }
63
+
64
+ function isElement(node: HtmlNode): node is HtmlElement {
65
+ return 'tagName' in node;
66
+ }
67
+
68
+ function attribute(node: HtmlElement, name: string): string | undefined {
69
+ return node.attrs.find((attr) => attr.name === name)?.value;
70
+ }
71
+
72
+ function viewportKey(node: HtmlNode): string | undefined {
73
+ for (const child of 'childNodes' in node ? node.childNodes : []) {
74
+ if (!isElement(child)) continue;
75
+ const match = /^diagram-(.+)-viewport$/.exec(attribute(child, 'id') ?? '');
76
+ if (match) return match[1];
77
+ const inner = viewportKey(child);
78
+ if (inner) return inner;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /** The diagrams `Diagram.astro` marked too wide in `html`. */
84
+ export function oversizedDiagrams(html: string): OversizedDiagram[] {
85
+ if (!html.includes('data-diagram-too-wide')) return [];
86
+ const found: OversizedDiagram[] = [];
87
+ const visit = (node: HtmlNode) => {
88
+ for (const child of 'childNodes' in node ? node.childNodes : []) {
89
+ if (!isElement(child)) continue;
90
+ const limit = attribute(child, 'data-diagram-too-wide');
91
+ if (limit !== undefined) {
92
+ found.push({
93
+ code: 'dia-too-wide',
94
+ key: viewportKey(child) ?? attribute(child, 'id') ?? '',
95
+ width: Number(attribute(child, 'data-diagram-width')),
96
+ limit: Number(limit),
97
+ });
98
+ }
99
+ visit(child);
100
+ }
101
+ };
102
+ visit(parse(html));
103
+ return found;
104
+ }
@@ -8,11 +8,11 @@
8
8
  * SVG; this module only computes geometry and is deliberately framework-free
9
9
  * so it is unit-testable without KaTeX, Astro, or a browser.
10
10
  *
11
- * Box sizes are not measured from a real render (there is no DOM at build
12
- * time) — `estimateBoxSize` is a deliberately generous character-count
13
- * heuristic. It is meant to avoid overlap and clipping in the common case,
14
- * not to be pixel-accurate; a diagram author can always pass an explicit
15
- * `width`/`height` on a node for a wide equation.
11
+ * A node's box is sized from what it holds (`nodeBoxSize`): its label's
12
+ * width in DejaVu Sans, the widest common system font (`text-measure.ts`),
13
+ * wrapped to a line count that sets the height, and its formula's width as
14
+ * KaTeX lays it out (`katex-measure.ts`, measured by the caller). An explicit
15
+ * `width` or `height` on the node overrides either side.
16
16
  */
17
17
  import dagre from '@dagrejs/dagre';
18
18
  import type {
@@ -21,6 +21,8 @@ import type {
21
21
  NodeLabel as DagreNodeLabel,
22
22
  } from '@dagrejs/dagre';
23
23
 
24
+ import { textWidth, wrapText } from './text-measure';
25
+
24
26
  export interface DiagramNodeInput {
25
27
  readonly id: string;
26
28
  readonly width: number;
@@ -98,6 +100,14 @@ export function layoutDiagram(
98
100
  if (!ids.has(edge.to)) {
99
101
  throw new Error(`Diagram edge references unknown node "${edge.to}".`);
100
102
  }
103
+ // The layout draws a self-loop as a stray line out of the node.
104
+ if (edge.from === edge.to) {
105
+ throw new Error(
106
+ `Diagram edge from "${edge.from}" to itself: a diagram draws no loop on one ` +
107
+ 'node. Name the repetition in the label ("Repeat until it converges"), or ' +
108
+ 'draw the step it returns to as its own node.',
109
+ );
110
+ }
101
111
  }
102
112
 
103
113
  const graph = new dagre.graphlib.Graph<
@@ -185,62 +195,183 @@ export function layoutDiagram(
185
195
  };
186
196
  }
187
197
 
188
- // --- box-size estimation (no DOM at build time) --------------------------
198
+ // --- node boxes -----------------------------------------------------------
199
+ //
200
+ // The constants mirror `Diagram.astro`'s `.diagram-node-content`, set in px
201
+ // so a reader's font size cannot outgrow a box: `font-size: 13.6px`,
202
+ // `line-height: 1.25`, `padding: 3.2px 8px`, `gap: 2.4px`.
189
203
 
190
- export interface BoxSizeEstimate {
204
+ /** A node's text font size, in px. */
205
+ export const NODE_FONT_PX = 13.6;
206
+ /**
207
+ * A node's formula font size, in px: KaTeX's own `.katex { font-size: 1.21em }`
208
+ * of the node's text. `katex.css` is unlayered, so no rule in the component's
209
+ * `@layer` can shrink it, and at that size KaTeX_Main's x-height matches the
210
+ * label's.
211
+ */
212
+ export const FORMULA_FONT_PX = NODE_FONT_PX * 1.21;
213
+ /** One line of node text, in px. */
214
+ const LINE_PX = 17;
215
+ /** The horizontal CSS padding inside a node's box, per side. */
216
+ const CSS_PADDING_X = 8;
217
+ /** Space between the content and the box, per side: the CSS padding and some air. */
218
+ const INSET_X = 16;
219
+ const INSET_Y = 14;
220
+ /** The gap between a node's text and its formula. */
221
+ const GAP_PX = 2.4;
222
+ /**
223
+ * A formula's line reaches at least this far above and below its baseline:
224
+ * the strut of the line it sits in, measured in Chromium, with room for a
225
+ * system font that sits higher than DejaVu Sans.
226
+ */
227
+ const FORMULA_ASCENT_PX = 14;
228
+ const FORMULA_DESCENT_PX = 5.5;
229
+ const MIN_WIDTH = 90;
230
+ const DEFAULT_MAX_WIDTH = 220;
231
+
232
+ /** What a node holds, measured: its label, and its formula's box in px. */
233
+ export interface NodeBoxContent {
234
+ readonly text?: string;
235
+ /** `measureFormula(latex, FORMULA_FONT_PX)`. */
236
+ readonly formula?: {
237
+ readonly width: number;
238
+ readonly height: number;
239
+ readonly depth: number;
240
+ };
241
+ }
242
+
243
+ export interface NodeBoxOptions {
244
+ /** The author's width: the label wraps inside it and the height follows. */
245
+ readonly width?: number;
246
+ /** The author's height. */
247
+ readonly height?: number;
248
+ /** The widest box a label is wrapped to fit before it takes a second line. */
249
+ readonly maxWidth?: number;
250
+ }
251
+
252
+ export interface NodeBox {
191
253
  readonly width: number;
192
254
  readonly height: number;
255
+ /** The label as it is wrapped in DejaVu Sans. */
256
+ readonly lines: readonly string[];
193
257
  }
194
258
 
195
- const DEFAULT_MAX_WIDTH = 220;
196
- const MIN_WIDTH = 90;
197
- const PADDING_X = 20;
198
- const PADDING_Y = 16;
199
- const LINE_HEIGHT = 22;
200
- /** Generous per-visible-character width estimates, in px, at diagram scale. */
201
- const CHAR_WIDTH: Record<'text' | 'formula', number> = {
202
- text: 7.2,
203
- formula: 11,
204
- };
259
+ /**
260
+ * The narrowest wrap of `text` that needs no more lines than it needs at
261
+ * `maxWidth`: a two-line label splits near its middle rather than leaving one
262
+ * word on the second line, and its box is no wider than that takes.
263
+ */
264
+ function balancedLines(text: string, maxWidth: number): string[] {
265
+ const greedy = wrapText(text, maxWidth, NODE_FONT_PX);
266
+ if (greedy.length <= 1) return greedy;
267
+ let low = 1;
268
+ let high = Math.ceil(maxWidth);
269
+ while (low < high) {
270
+ const middle = Math.floor((low + high) / 2);
271
+ if (wrapText(text, middle, NODE_FONT_PX).length <= greedy.length) high = middle;
272
+ else low = middle + 1;
273
+ }
274
+ return wrapText(text, high, NODE_FONT_PX);
275
+ }
205
276
 
206
277
  /**
207
- * A LaTeX command name (`\theta`, `\cdot`, …) renders as roughly one glyph,
208
- * not one character per letter of its name; braces are structural, not
209
- * visible. Collapsing both keeps the estimate from reading a formula as far
210
- * wider than it will actually render.
278
+ * Size a node's box from its measured content: as wide as its widest line of
279
+ * text or its formula, whichever is wider, plus the inset, and as tall as its
280
+ * lines of text, the gap and its formula's line. A label wider than `maxWidth`
281
+ * wraps; a formula never does, so a wide formula widens the box instead and the
282
+ * label wraps to it. An author's `width` or `height` makes the box larger,
283
+ * never too small for its formula or its lines.
211
284
  */
212
- function formulaVisibleLength(latex: string): number {
213
- const collapsed = latex
214
- .replace(/\\[a-zA-Z]+/g, 'x')
215
- .replace(/[{}$]/g, '')
216
- .trim();
217
- return Math.max(1, collapsed.length);
285
+ export function nodeBoxSize(content: NodeBoxContent, options: NodeBoxOptions = {}): NodeBox {
286
+ const text = content.text?.trim() ?? '';
287
+ const formula = content.formula;
288
+ const formulaWidth = formula?.width ?? 0;
289
+
290
+ let width: number;
291
+ let lines: string[];
292
+ if (options.width !== undefined) {
293
+ // A formula never wraps, so an author's width narrower than it would cut
294
+ // it off: the box takes the formula's width, and the label wraps to that.
295
+ width = Math.max(options.width, Math.ceil(formulaWidth + 2 * CSS_PADDING_X));
296
+ lines = text ? wrapText(text, width - 2 * CSS_PADDING_X, NODE_FONT_PX) : [];
297
+ } else {
298
+ const maxContent = Math.max(
299
+ (options.maxWidth ?? DEFAULT_MAX_WIDTH) - 2 * INSET_X,
300
+ formulaWidth,
301
+ );
302
+ lines = text ? balancedLines(text, maxContent) : [];
303
+ const contentWidth = Math.max(
304
+ formulaWidth,
305
+ ...lines.map((line) => textWidth(line, NODE_FONT_PX)),
306
+ );
307
+ width = Math.max(MIN_WIDTH, Math.ceil(contentWidth + 2 * INSET_X));
308
+ }
309
+
310
+ const formulaHeight = formula
311
+ ? Math.max(FORMULA_ASCENT_PX, formula.height) + Math.max(FORMULA_DESCENT_PX, formula.depth)
312
+ : 0;
313
+ const gap = lines.length > 0 && formula ? GAP_PX : 0;
314
+ // An author's height adds room; it never cuts what the box holds.
315
+ const contentHeight = Math.ceil(lines.length * LINE_PX + gap + formulaHeight + 2 * INSET_Y);
316
+ const height = Math.max(options.height ?? 0, contentHeight);
317
+
318
+ return { width, height, lines };
319
+ }
320
+
321
+ /** One node to fit: what it holds, and the author's size if any. */
322
+ export interface FitNodeInput {
323
+ readonly id: string;
324
+ readonly content: NodeBoxContent;
325
+ readonly width?: number;
326
+ readonly height?: number;
218
327
  }
219
328
 
329
+ export interface FitOptions extends DiagramLayoutOptions {
330
+ /** The widest the diagram should lay out (rule V8's column); omit to skip fitting. */
331
+ readonly fitWidth?: number;
332
+ }
333
+
334
+ /**
335
+ * The widest box a label wraps to fit, tried in turn while the diagram is
336
+ * still wider than its column: a left-to-right chain of four steps fits a
337
+ * 707px column once its labels wrap at 140px.
338
+ */
339
+ const FIT_MAX_WIDTHS = [DEFAULT_MAX_WIDTH, 200, 180, 160, 140, 120];
340
+
220
341
  /**
221
- * Estimate a diagram node's box from its label text (or raw, pre-resolution
222
- * LaTeX for a formula node). Deliberately generous: it is meant to avoid
223
- * clipping, not to match a real render pixel-for-pixel. Pass an explicit
224
- * `width`/`height` on the node to override for a label this estimate gets
225
- * badly wrong.
342
+ * Size every node from its content and lay the diagram out. When `fitWidth`
343
+ * is set and the diagram comes out wider, wrap the labels of the nodes without
344
+ * an author's `width` narrower, step by step, until it fits; a diagram that
345
+ * still does not fit keeps its narrowest layout, and the caller warns.
226
346
  */
227
- export function estimateBoxSize(
228
- content: string,
229
- kind: 'text' | 'formula',
230
- options: { readonly maxWidth?: number } = {},
231
- ): BoxSizeEstimate {
232
- const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
233
- const visibleLength =
234
- kind === 'formula' ? formulaVisibleLength(content) : content.trim().length || 1;
235
- const charWidth = CHAR_WIDTH[kind];
236
-
237
- const naturalWidth = visibleLength * charWidth + PADDING_X * 2;
238
- const width = Math.min(maxWidth, Math.max(MIN_WIDTH, naturalWidth));
239
-
240
- const usableWidth = width - PADDING_X * 2;
241
- const charsPerLine = Math.max(1, Math.floor(usableWidth / charWidth));
242
- const lines = Math.max(1, Math.ceil(visibleLength / charsPerLine));
243
- const height = lines * LINE_HEIGHT + PADDING_Y * 2;
244
-
245
- return { width: Math.round(width), height: Math.round(height) };
347
+ export function fitDiagramLayout(
348
+ nodes: readonly FitNodeInput[],
349
+ edges: readonly DiagramEdgeInput[],
350
+ options: FitOptions = {},
351
+ ): { readonly layout: DiagramLayout; readonly boxes: ReadonlyMap<string, NodeBox> } {
352
+ let result: { layout: DiagramLayout; boxes: Map<string, NodeBox> } | undefined;
353
+ for (const maxWidth of FIT_MAX_WIDTHS) {
354
+ const boxes = new Map(
355
+ nodes.map((node) => [
356
+ node.id,
357
+ nodeBoxSize(node.content, {
358
+ maxWidth,
359
+ ...(node.width !== undefined ? { width: node.width } : {}),
360
+ ...(node.height !== undefined ? { height: node.height } : {}),
361
+ }),
362
+ ]),
363
+ );
364
+ const layout = layoutDiagram(
365
+ nodes.map((node) => ({ id: node.id, ...sizeOf(boxes.get(node.id)!) })),
366
+ edges,
367
+ options,
368
+ );
369
+ result = { layout, boxes };
370
+ if (options.fitWidth === undefined || layout.width <= options.fitWidth) break;
371
+ }
372
+ return result!;
373
+ }
374
+
375
+ function sizeOf(box: NodeBox): { width: number; height: number } {
376
+ return { width: box.width, height: box.height };
246
377
  }
@@ -12,7 +12,8 @@ export type DiagramDiagnosticCode =
12
12
  | 'dia-duplicate-key'
13
13
  | 'dia-ref-resolves'
14
14
  | 'dia-key-unused'
15
- | 'dia-removed-prop';
15
+ | 'dia-removed-prop'
16
+ | 'dia-unread-prop';
16
17
 
17
18
  export interface DiagramDiagnostic {
18
19
  readonly code: DiagramDiagnosticCode;
@@ -10,7 +10,8 @@
10
10
  * fast `pnpm validate:content` the same coverage and feeds the manifest.
11
11
  */
12
12
 
13
- import { parseEquationRef, scanEquationLabels } from './equations.mjs';
13
+ import { parseEquationRef, repeatedEquationLabels, scanEquationLabels } from './equations.mjs';
14
+ import { proseOnly } from './gate-prose-notation';
14
15
 
15
16
  export interface EquationLabelRecord {
16
17
  readonly key: string;
@@ -31,7 +32,11 @@ export interface EquationRefRecord {
31
32
  }
32
33
 
33
34
  export type EquationDiagnosticCode =
34
- 'eq-duplicate-key' | 'eq-ref-resolves' | 'eq-key-unused';
35
+ | 'eq-duplicate-key'
36
+ | 'eq-ref-resolves'
37
+ | 'eq-key-unused'
38
+ | 'eq-label-repeated'
39
+ | 'eq-latex-ref';
35
40
 
36
41
  export interface EquationDiagnostic {
37
42
  readonly code: EquationDiagnosticCode;
@@ -88,6 +93,9 @@ function bodyEquationRefs(lesson: EquationLessonInput): EquationRefRecord[] {
88
93
  return refs;
89
94
  }
90
95
 
96
+ /** `\\eqref{eq:key}`, `\\ref{fig:key}` and their kin, as LaTeX writes a reference. */
97
+ const LATEX_REFERENCE = /\\(eqref|ref|autoref|cref|Cref)\s*\{([^{}\n]*)\}/g;
98
+
91
99
  export function validateEquations(
92
100
  lessons: readonly EquationLessonInput[],
93
101
  ): EquationValidation {
@@ -131,6 +139,37 @@ export function validateEquations(
131
139
  });
132
140
  }
133
141
  numbersBySlug[lesson.slug] = table;
142
+ // LaTeX's own reference in prose: MDX reads its `{…}` as JavaScript, so
143
+ // the page fails to build with a parse error that names neither.
144
+ for (const match of proseOnly(lesson.body).matchAll(LATEX_REFERENCE)) {
145
+ const [, command, target = ''] = match;
146
+ const [prefix, key] = target.includes(':') ? target.split(':', 2) : ['eq', target];
147
+ diagnostics.push({
148
+ code: 'eq-latex-ref',
149
+ severity: 'error',
150
+ message:
151
+ `${lesson.lessonId} writes \\${command}{${target}}, LaTeX's reference: MDX reads its ` +
152
+ `{…} as JavaScript, and the page fails to build. Write [[${prefix}-${key}]].`,
153
+ file: lesson.file,
154
+ lessonId: lesson.lessonId,
155
+ key: key ?? target,
156
+ });
157
+ }
158
+ for (const keys of repeatedEquationLabels(lesson.body) as string[][]) {
159
+ const [first, ...rest] = keys;
160
+ diagnostics.push({
161
+ code: 'eq-label-repeated',
162
+ severity: 'error',
163
+ message:
164
+ `${lesson.lessonId} puts ${keys.map((key) => `\\label{eq:${key}}`).join(' and ')} in one ` +
165
+ `display block, which is one numbered equation: eq:${first} names it, and ` +
166
+ `${rest.map((key) => `[[eq-${key}]]`).join(', ')} would point at nothing. Split the block ` +
167
+ 'into one $$…$$ block per equation you refer to.',
168
+ file: lesson.file,
169
+ lessonId: lesson.lessonId,
170
+ key: rest[0]!,
171
+ });
172
+ }
134
173
  }
135
174
 
136
175
  const referenced = new Set<string>();
@@ -204,6 +204,87 @@ export function stripJsxComponents(markdown, keep, transparent = []) {
204
204
  */
205
205
  export const TRANSPARENT_COMPONENTS = Object.freeze(['Memo', 'ShortNote', 'Fold']);
206
206
 
207
+ /**
208
+ * Blank the body of every `:::name … :::` container directive — a Starlight
209
+ * aside (`:::note`, `:::tip`, …) — keeping newlines, the text-scan twin of a
210
+ * tree walk that never enters a `containerDirective`. An aside is a callout,
211
+ * like `<Aside>`: what it holds is illustrative and numbers nothing. A fence
212
+ * of more colons closes only on as many; one left open runs to the end.
213
+ */
214
+ export function stripContainerDirectives(markdown) {
215
+ const lines = String(markdown).split('\n');
216
+ let fence = 0;
217
+ for (let index = 0; index < lines.length; index += 1) {
218
+ const line = lines[index] ?? '';
219
+ if (fence === 0) {
220
+ const open = /^[ \t]{0,3}(:{3,})[A-Za-z]/.exec(line);
221
+ if (!open) continue;
222
+ fence = open[1].length;
223
+ lines[index] = line.replace(/[^\n]/g, ' ');
224
+ continue;
225
+ }
226
+ const close = /^[ \t]{0,3}(:{3,})[ \t]*$/.exec(line);
227
+ if (close && close[1].length >= fence) fence = 0;
228
+ lines[index] = line.replace(/[^\n]/g, ' ');
229
+ }
230
+ return lines.join('\n');
231
+ }
232
+
233
+ /** A line with its blockquote markers, list marker and indentation removed. */
234
+ function containerContent(line) {
235
+ let rest = String(line);
236
+ for (;;) {
237
+ const next = rest
238
+ .replace(/^[ \t]*>[ \t]?/, '')
239
+ .replace(/^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+/, '');
240
+ if (next === rest) return rest.replace(/^[ \t]+/, '');
241
+ rest = next;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Every `$$` display block in a Markdown text the way remark-math reads one:
247
+ * an opening fence of two or more `$` that starts a line — inside a list item
248
+ * or a blockquote too — with nothing but a `$`-free meta after it, up to a
249
+ * closing fence of at least as many `$` on a line of its own (or the end of
250
+ * the text). `$$x$$` inside a paragraph, or on one line, is inline math and
251
+ * is not a display block. Each block is `{ index, body }`: the offset of its
252
+ * opening line and the text between the fences.
253
+ */
254
+ export function displayMathBlocks(markdown) {
255
+ const text = String(markdown);
256
+ const lines = text.split('\n');
257
+ const starts = [];
258
+ let offset = 0;
259
+ for (const line of lines) {
260
+ starts.push(offset);
261
+ offset += line.length + 1;
262
+ }
263
+ const blocks = [];
264
+ for (let index = 0; index < lines.length; index += 1) {
265
+ const open = /^(\${2,})[ \t]*[^$]*$/.exec(containerContent(lines[index]));
266
+ if (!open) continue;
267
+ const size = open[1].length;
268
+ let end = lines.length;
269
+ for (let next = index + 1; next < lines.length; next += 1) {
270
+ const close = /^(\${2,})[ \t]*$/.exec(containerContent(lines[next]));
271
+ if (close && close[1].length >= size) {
272
+ end = next;
273
+ break;
274
+ }
275
+ }
276
+ blocks.push({
277
+ index: starts[index],
278
+ body: lines
279
+ .slice(index + 1, end)
280
+ .map((line) => containerContent(line))
281
+ .join('\n'),
282
+ });
283
+ index = end;
284
+ }
285
+ return blocks;
286
+ }
287
+
207
288
  /**
208
289
  * Every display equation in a Markdown body, keyed or not, in appearance order
209
290
  * with its assigned number and its `\caption{}` short caption (`short`, or
@@ -219,25 +300,20 @@ export const TRANSPARENT_COMPONENTS = Object.freeze(['Memo', 'ShortNote', 'Fold'
219
300
  */
220
301
  export function scanDisplayEquations(markdown, numbering) {
221
302
  const { prefix, offset } = resolveNumbering(numbering);
222
- const text = stripJsxComponents(
223
- stripFencedCode(markdown),
224
- undefined,
225
- TRANSPARENT_COMPONENTS,
226
- );
303
+ const text = equationText(markdown);
227
304
  const tokens = [];
228
305
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
229
- const display = /\$\$([\s\S]+?)\$\$/g;
230
306
  for (const match of text.matchAll(heading)) {
231
307
  tokens.push({ kind: 'section', index: match.index });
232
308
  }
233
- for (const match of text.matchAll(display)) {
309
+ for (const block of displayMathBlocks(text)) {
234
310
  EQ_LABEL_PATTERN.lastIndex = 0;
235
- const label = EQ_LABEL_PATTERN.exec(match[1]);
311
+ const label = EQ_LABEL_PATTERN.exec(block.body);
236
312
  tokens.push({
237
313
  kind: 'equation',
238
- index: match.index,
314
+ index: block.index,
239
315
  key: label ? label[1] : null,
240
- short: equationCaption(match[1]),
316
+ short: equationCaption(block.body),
241
317
  });
242
318
  }
243
319
  tokens.sort((a, b) => a.index - b.index);
@@ -271,6 +347,31 @@ export function scanDisplayEquations(markdown, numbering) {
271
347
  return labels;
272
348
  }
273
349
 
350
+ /**
351
+ * A lesson body as the equation scans read it: no fenced code, no component
352
+ * but the transparent ones, no `:::` directive body.
353
+ */
354
+ function equationText(markdown) {
355
+ return stripContainerDirectives(
356
+ stripJsxComponents(stripFencedCode(markdown), undefined, TRANSPARENT_COMPONENTS),
357
+ );
358
+ }
359
+
360
+ /**
361
+ * The keys of each display block that carries more than one `\label{eq:…}`,
362
+ * in order. A block is one numbered equation: its first label names it, and
363
+ * a later one would name nothing.
364
+ */
365
+ export function repeatedEquationLabels(markdown) {
366
+ return displayMathBlocks(equationText(markdown))
367
+ // A copy: `matchAll` starts at the shared pattern's `lastIndex`, which
368
+ // `scanDisplayEquations`' `exec` leaves past the label it found.
369
+ .map((block) =>
370
+ [...block.body.matchAll(new RegExp(EQ_LABEL_PATTERN.source, 'g'))].map((match) => match[1]),
371
+ )
372
+ .filter((keys) => keys.length > 1);
373
+ }
374
+
274
375
  /** The keyed subset of `scanDisplayEquations` — the manifest builder's view. */
275
376
  export function scanEquationLabels(markdown, numbering) {
276
377
  return scanDisplayEquations(markdown, numbering).filter(
@@ -301,13 +402,16 @@ export function collectDisplayEquations(tree, numbering) {
301
402
  let indexInSection = 0;
302
403
  let indexInLesson = 0;
303
404
 
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.
405
+ // The lesson's flow — top-level blocks, list items, blockquotes — plus the
406
+ // bodies of the `TRANSPARENT_COMPONENTS`: other MDX components (a
407
+ // `<CompactExample>` worked example, an `<Aside>`) and `:::note`-style
408
+ // asides remain illustrative and outside the lesson's official equation
409
+ // sequence. The transparent ones occupy an exact point in the reading
410
+ // order, even while visually collapsed. `scanDisplayEquations` reads the
411
+ // same set from the text.
309
412
  const collectFromDetail = (node) => {
310
413
  if (!node || typeof node !== 'object') return;
414
+ if (node.type === 'containerDirective') return;
311
415
  if (node.type === 'math') {
312
416
  indexInSection += 1;
313
417
  indexInLesson += 1;
@@ -347,13 +451,7 @@ export function collectDisplayEquations(tree, numbering) {
347
451
  indexInSection = 0;
348
452
  continue;
349
453
  }
350
- if (node.type === 'math') collectFromDetail(node);
351
- else if (
352
- node.type === 'mdxJsxFlowElement' &&
353
- TRANSPARENT_COMPONENTS.includes(node.name)
354
- ) {
355
- collectFromDetail(node);
356
- }
454
+ collectFromDetail(node);
357
455
  }
358
456
  return equations;
359
457
  }
@@ -24,6 +24,7 @@ export type ExampleDiagnosticCode =
24
24
  | 'ex-ref-resolves'
25
25
  | 'ex-key-unused'
26
26
  | 'ex-removed-prop'
27
+ | 'ex-unread-prop'
27
28
  | 'ex-missing-prop';
28
29
 
29
30
  export interface ExampleDiagnostic {