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
@@ -97,6 +97,13 @@ export function describeHiddenContent(
97
97
  const CONTAINERS =
98
98
  '.keyed-figure, .keyed-table, .diagram, .memo-block, .compact-examples, .compact-example, .assessment-shell, .short-note';
99
99
 
100
+ /**
101
+ * A short note's prose belongs to the paragraph it opens in, but a numbered
102
+ * float or equation inside it is numbered like any other, and hidden with
103
+ * the section: those count, and a float is named, as in a `<Fold>`.
104
+ */
105
+ const FLOAT_CONTAINERS = CONTAINERS.replace(', .short-note', '');
106
+
100
107
  /** Whether any ancestor of `element` strictly between it and `root` matches `selector`. */
101
108
  function hasAncestorWithin(element: Element, root: Element, selector: string): boolean {
102
109
  for (
@@ -127,25 +134,26 @@ export function collectHiddenContent(
127
134
  headings: string,
128
135
  { nameFloats = true }: { readonly nameFloats?: boolean } = {},
129
136
  ): HiddenContent {
130
- const outside = (selector: string, alsoExclude = '') =>
137
+ const outside = (selector: string, alsoExclude = '', containers = CONTAINERS) =>
131
138
  Array.from(body.querySelectorAll(selector)).filter(
132
139
  (element) =>
133
- !hasAncestorWithin(element, body, CONTAINERS) &&
140
+ !hasAncestorWithin(element, body, containers) &&
134
141
  !(alsoExclude && hasAncestorWithin(element, body, alsoExclude)),
135
142
  );
143
+ const numbered = (selector: string) => outside(selector, '', FLOAT_CONTAINERS);
136
144
  const itemsIn = (container: string, item: string) =>
137
145
  outside(container).reduce(
138
146
  (total, element) => total + element.querySelectorAll(item).length,
139
147
  0,
140
148
  );
141
149
  const named = nameFloats
142
- ? outside('.keyed-figure, .keyed-table, .diagram').flatMap((element) => {
150
+ ? numbered('.keyed-figure, .keyed-table, .diagram').flatMap((element) => {
143
151
  const float = floatOf(element);
144
152
  return float?.short ? [float] : [];
145
153
  })
146
154
  : [];
147
155
  const unnamed = (selector: string) =>
148
- outside(selector).filter(
156
+ numbered(selector).filter(
149
157
  (element) => !named.some((float) => float.anchorId === element.id),
150
158
  ).length;
151
159
 
@@ -161,7 +169,7 @@ export function collectHiddenContent(
161
169
  ).length,
162
170
  // A loose list's `<li><p>` is part of the list, not a paragraph of its own.
163
171
  paragraphs: outside('p', 'ul, ol').length,
164
- equations: outside('.katex-display').length,
172
+ equations: numbered('.katex-display').length,
165
173
  figures: unnamed('.keyed-figure'),
166
174
  // Keyed tables, plus any raw Markdown table (warned about, but possible).
167
175
  tables: unnamed('.keyed-table') + outside('table').length,
@@ -68,19 +68,6 @@ if (!number || !anchorId) {
68
68
  href={`#${anchorId}`}
69
69
  aria-label={`Table ${number}`}>Table {number}</a
70
70
  >
71
- <a
72
- class="float-caption__anchor"
73
- href={`#${anchorId}`}
74
- aria-label={`Copy the link to Table ${number}`}
75
- title="Copy link"
76
- data-copy-link={`Table ${number}`}
77
- >
78
- <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
79
- ><path
80
- d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"
81
- ></path></svg
82
- >
83
- </a>
84
71
  {short && <b class="float-caption__title">{captionTitle(short)}</b>}{short && ' '}<span
85
72
  class="keyed-table__caption-text float-caption__text">{caption}</span
86
73
  >
@@ -201,6 +188,8 @@ if (!number || !anchorId) {
201
188
  </style>
202
189
 
203
190
  <script>
191
+ import { tableTruncation } from './table-truncation';
192
+
204
193
  /**
205
194
  * Table row show/hide pagination
206
195
  * (docs/plans/collapsible-disclosure.md Part 5) — three states per table,
@@ -274,12 +263,15 @@ if (!number || !anchorId) {
274
263
  if (totalRows === 0) return;
275
264
 
276
265
  // Long enough to also need truncation, on top of the minimize toggle
277
- // every table gets below: more rows than the course's
278
- // `disclosure.tables.truncateAfter`. A truncated table shows up to that
279
- // many rows, half from each end.
280
- const truncateAfter = Number(figure.dataset.truncateAfter) || 6;
281
- const keepEachEnd = Math.floor(truncateAfter / 2);
282
- const paginated = totalRows > truncateAfter;
266
+ // every table gets below (`tableTruncation`): more rows than the
267
+ // course's `disclosure.tables.truncateAfter`, and at least two to hide.
268
+ // A truncated table shows up to that many rows, half from each end.
269
+ const truncation = tableTruncation(
270
+ totalRows,
271
+ Number(figure.dataset.truncateAfter) || 6,
272
+ );
273
+ const paginated = truncation !== undefined;
274
+ const keepEachEnd = truncation?.keepEachEnd ?? 0;
283
275
 
284
276
  const caption = figure.querySelector<HTMLElement>(
285
277
  ':scope > .keyed-table__caption',
@@ -295,7 +287,7 @@ if (!number || !anchorId) {
295
287
  let minimizeAll: HTMLButtonElement | null = null;
296
288
 
297
289
  if (paginated) {
298
- const hiddenCount = totalRows - keepEachEnd * 2;
290
+ const hiddenCount = truncation.hidden;
299
291
  const headerCells = table.tHead?.rows[0]?.cells ?? rows[0].cells;
300
292
  const colCount =
301
293
  Array.from(headerCells).reduce(
@@ -321,7 +313,7 @@ if (!number || !anchorId) {
321
313
  const moreLabel = document.createElement('span');
322
314
  moreLabel.className = 'keyed-table__more-label';
323
315
  moreLabel.textContent =
324
- hiddenCount === 1 ? '… 1 more row …' : `… ${hiddenCount} more rows …`;
316
+ `… ${hiddenCount} more rows …`;
325
317
  moreCell.append(moreLabel);
326
318
  moreRow.append(moreCell);
327
319
  tbody.insertBefore(moreRow, rows[keepEachEnd]);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * How a long table starts (`disclosure.tables.truncateAfter`): the rows kept
3
+ * at each end and how many the "… N more rows …" line stands for, or
4
+ * `undefined` when the table shows whole — it has no more rows than the
5
+ * setting, or truncating it would hide a single row, which the line is as
6
+ * tall as and would cost the reader a click to see.
7
+ */
8
+ export function tableTruncation(
9
+ totalRows: number,
10
+ truncateAfter: number,
11
+ ): { readonly keepEachEnd: number; readonly hidden: number } | undefined {
12
+ const keepEachEnd = Math.floor(truncateAfter / 2);
13
+ const hidden = totalRows - keepEachEnd * 2;
14
+ return totalRows > truncateAfter && hidden >= 2 ? { keepEachEnd, hidden } : undefined;
15
+ }
@@ -1,9 +1,19 @@
1
1
  import { defineCollection } from 'astro:content';
2
2
  import { glob } from 'astro/loaders';
3
- import { z } from 'astro/zod';
4
3
  import { docsSchema } from '@astrojs/starlight/schema';
5
- import { isSubstantiveMeaning } from './reference/prose';
6
- import { glossKeySegment } from './reference/gloss';
4
+ import {
5
+ abbreviationSchema,
6
+ assessmentSchema,
7
+ competencySchema,
8
+ lessonFrontmatterSchema,
9
+ notationSchema,
10
+ sourceSchema,
11
+ trackSchema,
12
+ } from './compiler/content-schemas';
13
+
14
+ // The schemas live in `compiler/content-schemas.ts`, where the manifest walk
15
+ // checks every record against them too (`validate:content` rejects what this
16
+ // build would). This file only wires them to Astro's loaders.
7
17
 
8
18
  // Phase F: content lives in the top-level `content/` folder, not `src/content/`.
9
19
  // `docsLoader()` is hard-wired to `<srcDir>/content/docs`, so the `docs`
@@ -14,186 +24,9 @@ const docsGlob = glob({
14
24
  pattern: '**/[^_]*.{md,mdx}',
15
25
  });
16
26
 
17
- const editorialStatus = z.enum(['draft', 'in-review', 'reviewed']);
18
- const id = z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/);
19
-
20
- /**
21
- * Notation `meaning` / `summary`: plain prose (no `$`, `\`, backticks), and —
22
- * the C1b safeguard — substantive, not a placeholder. A short or blocklisted
23
- * phrase fails the build so a local symbol cannot get a glossary-style panel
24
- * for a throwaway definition.
25
- */
26
- const notationProse = z
27
- .string()
28
- .min(1)
29
- .refine((value) => !/[\\$`]/.test(value), {
30
- message:
31
- 'Notation meaning/summary must be plain prose; render the symbol or formula in its dedicated field.',
32
- })
33
- .refine(isSubstantiveMeaning, {
34
- message:
35
- 'Notation meaning must be substantive prose (at least four words, not a placeholder).',
36
- });
37
-
38
- const notationAlignment = z.discriminatedUnion('kind', [
39
- z.object({
40
- kind: z.literal('competency'),
41
- introducedByCompetency: id,
42
- introducedInLesson: id,
43
- }),
44
- z.object({
45
- kind: z.literal('general'),
46
- rationale: z.string().min(1),
47
- }),
48
- ]);
49
- const notationAlignmentDefault = notationAlignment.default({
50
- kind: 'general',
51
- rationale: 'General notation.',
52
- });
53
-
54
- /**
55
- * A citation on a notation definition: a bare id (locator pending, see D6/g) or
56
- * `{id, locator}` — a per-claim locator verified against the cited text.
57
- */
58
- const notationSource = z.union([
59
- id,
60
- z.object({ id, locator: z.string().min(1) }),
61
- ]);
62
-
63
- /**
64
- * One notation entry shape (Phase C1b), shared by page-local `notation.local`
65
- * and the standalone `notation` collection.
66
- *
67
- * Authored: `key`, `latex`, `meaning`, optional `formula`, optional `units`
68
- * (or `dimensionless: true`), `seeAlso`, `sources` (`{id, locator}`),
69
- * optional `alignment` (defaults to `general`), optional `label`.
70
- *
71
- * The display label defaults to the humanized `key` (see `reference/label.ts`);
72
- * `label` is authored only where that reads wrong. The `notationProse`
73
- * refinement rejects an under-specified `meaning`.
74
- */
75
- /**
76
- * A **gloss** on a notation card: a symbol the card's own `formula` needs in
77
- * order to be written down, carrying a name and nothing else (D15).
78
- *
79
- * A gloss is deliberately cheaper than a card. It has no description, no
80
- * sources, no curriculum alignment, no review state, and no glossary card of
81
- * its own; it is not a node in the curriculum graph and never creates a
82
- * reference edge. Its key is derived, not authored — `<card key>.<slug(name)>`
83
- * — so a gloss is scoped to the card that declares it and two cards may each
84
- * gloss `T`. Because a formula spells LaTeX and never a key, **promoting** a
85
- * gloss is exactly: delete this line, add `content/notation/<key>.md`.
86
- *
87
- * `units` is optional and stays a bare unit string: "T — time" is ambiguous in
88
- * a corpus with several `T`s, "T — time, years from valuation" is not, and it
89
- * is still one line. Prose belongs on a card.
90
- */
91
- const notationGloss = z.object({
92
- latex: z.string().min(1),
93
- name: z
94
- .string()
95
- .min(1)
96
- .refine((value) => glossKeySegment(value) !== undefined, {
97
- message:
98
- 'A gloss name must contain letters or digits so a key can be derived from it.',
99
- })
100
- // The symbol goes in `latex`, the name names it. A name carrying LaTeX is
101
- // the one unambiguous way to get this field wrong, so it blocks; a name
102
- // that is merely long is a Tier-3 `gloss-name-shape` warning instead,
103
- // because a complicated quantity may honestly need several words.
104
- .refine((value) => !/[\\$`\n]/.test(value), {
105
- message:
106
- 'A gloss name is a plain noun phrase; put the symbol in `latex`, not in the name.',
107
- }),
108
- units: z.string().min(1).optional(),
109
- });
110
-
111
- const notationEntryShape = z.object({
112
- key: id,
113
- latex: z.string().min(1),
114
- meaning: notationProse,
115
- formula: z.string().min(1).optional(),
116
- units: z.string().min(1).optional(),
117
- dimensionless: z.literal(true).optional(),
118
- seeAlso: z.array(id).default([]),
119
- sources: z.array(notationSource).default([]),
120
- alignment: notationAlignmentDefault,
121
- label: z.string().min(1).optional(),
122
- glosses: z.array(notationGloss).default([]),
123
- });
124
-
125
- const localNotationDefinition = notationEntryShape;
126
-
127
- /**
128
- * One `notation.suspend` item (rule N22): a decoration (`'`, `\hat`, …) that
129
- * is not an operator on this page. The loader (`collections.ts`) checks the
130
- * name against the decorations the resolver knows.
131
- */
132
- const notationSuspension = z.object({
133
- operator: z.string().min(1),
134
- reason: z.string().min(1).optional(),
135
- });
136
-
137
- const lessonNotation = z
138
- .object({
139
- // `notation.uses` is retired (D3): a lesson pulls a shared key into scope
140
- // by referencing it in prose (`[[key]]`) or math (`\explain`).
141
- local: z.array(localNotationDefinition).default([]),
142
- suspend: z.array(notationSuspension).default([]),
143
- })
144
- .default({ local: [], suspend: [] });
145
-
146
- /**
147
- * Lesson frontmatter — reduced shape (Phase C1 schema, C2 codemod applied).
148
- *
149
- * Authored: `title`, `description` (Starlight base), `teaches` (ordered — the
150
- * curriculum contract, never derived), `assumptions`,
151
- * `studyMinutes` (optional reader-facing estimate),
152
- * `notation.local` (page-local symbols only).
153
- * Artifact: `editorialStatus` (human-set trust flag).
154
- * Derived (in `content/collections.ts` + `content/lesson-derivation.ts`, never
155
- * authored): `lessonId` (doc slug), `requires` (direct competency-DAG
156
- * prerequisites of `teaches`, minus `teaches`), `sources` (`[@…]`
157
- * occurrences), `assessments` (colocated `<lesson>.checks.yml`),
158
- * `sidebar` order (track order).
159
- * Dropped: `aiAssisted`, `lastReviewed`, `riskTier` (git history +
160
- * `editorialStatus` + `NEEDS_SOURCE` carry provenance);
161
- * `estimatedMinutes`, whose reader-facing half returned as the
162
- * optional `studyMinutes` once part pages had somewhere to total
163
- * it; `notation.uses` (D3 — a shared symbol is imported by using
164
- * it).
165
- */
166
- /**
167
- * How long this chapter takes to study: `40`, or a `[low, high]` range in
168
- * minutes. It is a reader-facing estimate the author makes, aggregated by
169
- * `<PartOverview>` and `<CourseContents>` into a part and course total — not
170
- * the provenance field `estimatedMinutes` used to be, and nothing derives it.
171
- *
172
- * A part only reports a total when every one of its chapters states one, so a
173
- * half-annotated course shows the chapters it knows about and no misleading
174
- * sum.
175
- */
176
- const studyMinutes = z
177
- .union([
178
- z.number().int().positive(),
179
- z.tuple([z.number().int().positive(), z.number().int().positive()]),
180
- ])
181
- .refine((value) => !Array.isArray(value) || value[0] <= value[1], {
182
- message:
183
- 'studyMinutes as a range is [low, high]; the low end cannot exceed the high end.',
184
- });
185
-
186
27
  const docs = defineCollection({
187
28
  loader: docsGlob,
188
- schema: docsSchema({
189
- extend: z.object({
190
- editorialStatus: editorialStatus.default('draft'),
191
- teaches: z.array(id).default([]),
192
- assumptions: z.array(z.string().min(1)).default([]),
193
- studyMinutes: studyMinutes.optional(),
194
- notation: lessonNotation,
195
- }),
196
- }),
29
+ schema: docsSchema({ extend: lessonFrontmatterSchema }),
197
30
  });
198
31
 
199
32
  const competencies = defineCollection({
@@ -201,160 +34,35 @@ const competencies = defineCollection({
201
34
  pattern: '**/*.json',
202
35
  base: './content/competencies',
203
36
  }),
204
- schema: z.object({
205
- id,
206
- title: z.string().min(1),
207
- domain: id,
208
- facet: z.enum([
209
- 'knowledge',
210
- 'calculation',
211
- 'interpretation',
212
- 'convention',
213
- 'risk',
214
- ]),
215
- outcome: z.string().min(1),
216
- prerequisites: z.array(id).default([]),
217
- evidence: z.object({
218
- minimumIndependentItems: z.number().int().min(1),
219
- requiresTransfer: z.boolean(),
220
- requiresUnassistedPass: z.boolean(),
221
- }),
222
- misconceptions: z.array(z.string().min(1)).default([]),
223
- editorialStatus,
224
- }),
225
- });
226
-
227
- const assessmentItemBase = z.object({
228
- id,
229
- competencyId: id,
230
- evidenceKind: z.enum(['direct', 'transfer']),
231
- prompt: z.string().min(1),
232
- explanation: z.string().min(1),
37
+ schema: competencySchema,
233
38
  });
234
39
 
235
- /**
236
- * `AssessmentSet.astro` renders the numbered heading itself — `<h2>Knowledge
237
- * check {number} — {title}</h2>` — so `title` must be just the topic. A title
238
- * that repeats the "Knowledge check" label duplicates it on every page that
239
- * uses `AssessmentSet` (the standalone `AssessmentRunner` heading fallback
240
- * reads fine either way, so this only bites the common path).
241
- */
242
- const assessmentTitle = z
243
- .string()
244
- .min(1)
245
- .refine((value) => !/^knowledge check\b/i.test(value), {
246
- message:
247
- 'Assessment title must not start with "Knowledge check" — AssessmentSet already renders "Knowledge check {number} — {title}", so a title with the same prefix duplicates it. Use just the topic, e.g. "AIC and KL risk".',
248
- });
249
-
250
40
  const assessments = defineCollection({
251
41
  loader: glob({
252
42
  pattern: '**/*.json',
253
43
  base: './content/assessments',
254
44
  }),
255
- schema: z.object({
256
- id,
257
- title: assessmentTitle,
258
- editorialStatus,
259
- items: z
260
- .array(
261
- z.discriminatedUnion('type', [
262
- assessmentItemBase.extend({
263
- type: z.literal('numeric'),
264
- answer: z.object({
265
- value: z.number(),
266
- tolerance: z.number().positive(),
267
- }),
268
- }),
269
- assessmentItemBase.extend({
270
- type: z.literal('single-choice'),
271
- options: z
272
- .array(
273
- z.object({
274
- id,
275
- label: z.string().min(1),
276
- }),
277
- )
278
- .min(2),
279
- correctOptionId: id,
280
- }),
281
- ]),
282
- )
283
- .min(1),
284
- }),
45
+ schema: assessmentSchema,
285
46
  });
286
47
 
287
48
  const tracks = defineCollection({
288
49
  loader: glob({ pattern: '**/*.json', base: './content/tracks' }),
289
- schema: z.object({
290
- id,
291
- title: z.string().min(1),
292
- description: z.string().min(1),
293
- audience: z.string().min(1),
294
- entryAssumptions: z.array(z.string().min(1)),
295
- lessons: z.array(id).min(1),
296
- editorialStatus,
297
- }),
50
+ schema: trackSchema,
298
51
  });
299
52
 
300
53
  const sources = defineCollection({
301
54
  loader: glob({ pattern: '**/*.json', base: './content/sources' }),
302
- schema: z.object({
303
- id,
304
- type: z.enum([
305
- 'primary',
306
- 'official-guidance',
307
- 'paper',
308
- 'book',
309
- 'secondary',
310
- ]),
311
- title: z.string().min(1),
312
- authors: z.array(z.string().min(1)).optional(),
313
- organization: z.string().min(1).optional(),
314
- edition: z.string().optional(),
315
- publisher: z.string().optional(),
316
- year: z.number().int().optional(),
317
- isbn: z.string().optional(),
318
- url: z.url().optional(),
319
- locator: z.string().optional(),
320
- accessed: z.coerce.date().optional(),
321
- licenseNotes: z.string().optional(),
322
- editorialStatus,
323
- }),
55
+ schema: sourceSchema,
324
56
  });
325
57
 
326
- /**
327
- * Standalone notation collection — the one `notationEntry` shape plus the
328
- * shared-only extras: `domain`, `aliases`, its own `editorialStatus`, and a
329
- * Markdown body.
330
- */
331
58
  const notation = defineCollection({
332
59
  loader: glob({ pattern: '**/*.md', base: './content/notation' }),
333
- schema: notationEntryShape.extend({
334
- domain: id,
335
- aliases: z.array(z.string().min(1)).default([]),
336
- editorialStatus,
337
- aiAssisted: z.boolean().default(false),
338
- }),
60
+ schema: notationSchema,
339
61
  });
340
62
 
341
- /**
342
- * Abbreviations table (rule N14) — deliberately a lighter tier than a
343
- * `notation` card: just a name resolution, no `formula`, `units`, or
344
- * curriculum `alignment`. `abbr` is the canonical written form (`OLS`);
345
- * `aliases` are other written forms of the same abbreviation that should
346
- * resolve the same way (`w.r.t.` might alias `wrt`).
347
- */
348
63
  const abbreviations = defineCollection({
349
64
  loader: glob({ pattern: '**/*.md', base: './content/abbreviations' }),
350
- schema: z.object({
351
- key: id,
352
- abbr: z.string().min(1),
353
- expansion: notationProse,
354
- aliases: z.array(z.string().min(1)).default([]),
355
- domain: id.optional(),
356
- editorialStatus: editorialStatus.default('draft'),
357
- }),
65
+ schema: abbreviationSchema,
358
66
  });
359
67
 
360
68
  export const collections = {
@@ -1,3 +1,5 @@
1
+ import { citationsInLinks, citedIds, withoutLinkCitations } from '../reference/citation-format.mjs';
2
+
1
3
  export interface CompetencyDefinition {
2
4
  readonly id: string;
3
5
  readonly title: string;
@@ -11,7 +13,6 @@ export interface CompetencyDefinition {
11
13
 
12
14
  export interface LessonDefinition {
13
15
  readonly id: string;
14
- readonly status: 'draft' | 'in-review' | 'reviewed';
15
16
  readonly requires: readonly string[];
16
17
  readonly teaches: readonly string[];
17
18
  readonly assessments: readonly string[];
@@ -25,8 +26,17 @@ interface AssessmentItemBase {
25
26
  readonly id: string;
26
27
  readonly competencyId: string;
27
28
  readonly evidenceKind: 'direct' | 'transfer';
29
+ readonly prompt?: string;
30
+ readonly explanation?: string;
28
31
  }
29
32
 
33
+ /**
34
+ * A `[[…]]` reference or an `[@…]` citation in a check's text. A knowledge
35
+ * check renders its prompt, explanation and option labels as text and math
36
+ * only, so either would reach the reader exactly as written.
37
+ */
38
+ const CHECK_TEXT_REFERENCE = /\[\[[^\]\n]*\]\]|\[@[^\]\n]*\]/;
39
+
30
40
  export interface NumericAssessmentItem extends AssessmentItemBase {
31
41
  readonly type: 'numeric';
32
42
  readonly answer: { readonly value: number; readonly tolerance: number };
@@ -45,7 +55,6 @@ export interface AssessmentDefinition {
45
55
 
46
56
  export interface SourceDefinition {
47
57
  readonly id: string;
48
- readonly editorialStatus: 'draft' | 'in-review' | 'reviewed';
49
58
  }
50
59
 
51
60
  export interface TrackDefinition {
@@ -73,9 +82,23 @@ export interface CurriculumIssue {
73
82
  | 'track-order'
74
83
  | 'assessment-coverage'
75
84
  | 'invalid-assessment'
76
- | 'review-state'
77
- | 'teaches-support';
85
+ | 'missing-assumptions'
86
+ | 'teaches-support'
87
+ | 'retired-key'
88
+ | 'content-schema'
89
+ | 'part-empty'
90
+ | 'lesson-outside-parts'
91
+ | 'unsupported-key'
92
+ | 'ignored-key'
93
+ | 'markup-in-prop'
94
+ | 'citation-in-link';
78
95
  readonly message: string;
96
+ /**
97
+ * The content file the issue is about, when it is about one file rather
98
+ * than the graph (`retired-key`, `content-schema`), and the 1-based line in it.
99
+ */
100
+ readonly file?: string;
101
+ readonly line?: number;
79
102
  /**
80
103
  * `error` (default) blocks the build; `warning` is reported but non-blocking.
81
104
  * New checks enter as warnings and are promoted once they run clean on real
@@ -122,13 +145,8 @@ function citedSourceIds(body: string | undefined): Set<string> {
122
145
  const withoutCode = body
123
146
  .replace(/```[\s\S]*?```/g, '')
124
147
  .replace(/`[^`\n]*`/g, '');
125
- const ids = new Set<string>();
126
- for (const match of withoutCode.matchAll(
127
- /\[@([a-z0-9][a-z0-9.-]*)(?:;[^\]]*)?\]/g,
128
- )) {
129
- ids.add((match[1] ?? '').trim());
130
- }
131
- return ids;
148
+ // A citation in a link's text renders as typed, so it cites nothing.
149
+ return citedIds(withoutLinkCitations(withoutCode)) as Set<string>;
132
150
  }
133
151
 
134
152
  function findDuplicates(values: readonly string[]): readonly string[] {
@@ -314,21 +332,24 @@ export function validateCurriculum(
314
332
  }
315
333
  }
316
334
  const cited = citedSourceIds(lesson.body);
335
+ if (lesson.body !== undefined) {
336
+ const blank = (code: string) => code.replace(/[^\n]/g, ' ');
337
+ const prose = lesson.body.replace(/```[\s\S]*?```/g, blank).replace(/`[^`\n]*`/g, blank);
338
+ for (const { text } of citationsInLinks(prose)) {
339
+ issues.push({
340
+ kind: 'citation-in-link',
341
+ message:
342
+ `${lesson.id} puts the citation ${text} inside a link's text, where it prints as typed: ` +
343
+ `a link cannot hold the citation's own link. Cite after the link: [the words](https://…) ${text}.`,
344
+ });
345
+ }
346
+ }
317
347
  for (const sourceId of lesson.sources) {
318
- const source = sources.get(sourceId);
319
- if (!source) {
348
+ if (!sources.has(sourceId)) {
320
349
  issues.push({
321
350
  kind: 'unknown-reference',
322
351
  message: `${lesson.id} references unknown source ${sourceId}`,
323
352
  });
324
- } else if (
325
- lesson.status === 'reviewed' &&
326
- source.editorialStatus !== 'reviewed'
327
- ) {
328
- issues.push({
329
- kind: 'review-state',
330
- message: `${lesson.id} is reviewed but source ${sourceId} is ${source.editorialStatus}`,
331
- });
332
353
  }
333
354
  if (lesson.body !== undefined && !cited.has(sourceId)) {
334
355
  issues.push({
@@ -347,7 +368,7 @@ export function validateCurriculum(
347
368
  }
348
369
  if (lesson.teaches.length > 0 && lesson.assumptions.length === 0) {
349
370
  issues.push({
350
- kind: 'review-state',
371
+ kind: 'missing-assumptions',
351
372
  message: `${lesson.id} teaches quantitative content but declares no assumptions`,
352
373
  });
353
374
  }
@@ -385,14 +406,41 @@ export function validateCurriculum(
385
406
  message: `${assessment.id}/${item.id} has unknown correct option ${item.correctOptionId}`,
386
407
  });
387
408
  }
409
+ } else if (!Number.isFinite(item.answer.value)) {
410
+ issues.push({
411
+ kind: 'invalid-assessment',
412
+ message: `${assessment.id}/${item.id} answer.value must be a number`,
413
+ });
388
414
  } else if (
389
- !Number.isFinite(item.answer.value) ||
390
415
  !Number.isFinite(item.answer.tolerance) ||
391
416
  item.answer.tolerance <= 0
392
417
  ) {
393
418
  issues.push({
394
419
  kind: 'invalid-assessment',
395
- message: `${assessment.id}/${item.id} has an invalid numeric answer or tolerance`,
420
+ message:
421
+ `${assessment.id}/${item.id} answer.tolerance must be a number greater than 0, ` +
422
+ `not ${String(item.answer.tolerance)}: an answer within it of answer.value is correct. ` +
423
+ 'For an exact whole-number answer, use a tolerance below one half, such as 0.4.',
424
+ });
425
+ }
426
+
427
+ const texts: [string, string | undefined][] = [
428
+ ['prompt', item.prompt],
429
+ ['explanation', item.explanation],
430
+ ...(item.type === 'single-choice'
431
+ ? item.options.map((option): [string, string] => [`option ${option.id}`, option.label])
432
+ : []),
433
+ ];
434
+ for (const [field, text] of texts) {
435
+ const found = text === undefined ? null : CHECK_TEXT_REFERENCE.exec(text);
436
+ if (!found) continue;
437
+ issues.push({
438
+ kind: 'invalid-assessment',
439
+ message:
440
+ `${assessment.id}/${item.id} ${field} contains ${found[0]}, which a knowledge ` +
441
+ 'check shows exactly as written: its text renders math, not references or ' +
442
+ 'citations. Name the equation, section or source in words, or cite it in the ' +
443
+ 'lesson text the check follows.',
396
444
  });
397
445
  }
398
446