explico 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/CHANGELOG.md +1292 -1
  2. package/README.md +91 -13
  3. package/dist/compiler/astro.d.ts +11 -4
  4. package/dist/compiler/astro.js +48 -15
  5. package/dist/compiler/cli.d.ts +27 -12
  6. package/dist/compiler/cli.js +96 -25
  7. package/dist/compiler/collections.d.ts +33 -2
  8. package/dist/compiler/collections.js +91 -19
  9. package/dist/compiler/content-schema.d.ts +31 -0
  10. package/dist/compiler/content-schema.js +82 -0
  11. package/dist/compiler/content-schemas.d.ts +422 -0
  12. package/dist/compiler/content-schemas.js +229 -0
  13. package/dist/compiler/course-config.d.ts +1 -1
  14. package/dist/compiler/course-config.js +30 -2
  15. package/dist/compiler/exports/combined-html.d.ts +97 -0
  16. package/dist/compiler/exports/combined-html.js +492 -0
  17. package/dist/compiler/exports/download-index.d.ts +9 -0
  18. package/dist/compiler/exports/download-index.js +33 -0
  19. package/dist/compiler/exports/export-chapters.d.ts +98 -0
  20. package/dist/compiler/exports/export-chapters.js +164 -0
  21. package/dist/compiler/exports/export-pages.d.ts +48 -0
  22. package/dist/compiler/exports/export-pages.js +22 -0
  23. package/dist/compiler/exports/html-tree.d.ts +22 -0
  24. package/dist/compiler/exports/html-tree.js +47 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +106 -0
  26. package/dist/compiler/exports/latex/capture.js +435 -0
  27. package/dist/compiler/exports/latex/compile.d.ts +73 -0
  28. package/dist/compiler/exports/latex/compile.js +115 -0
  29. package/dist/compiler/exports/latex/document.d.ts +102 -0
  30. package/dist/compiler/exports/latex/document.js +349 -0
  31. package/dist/compiler/exports/latex/explico.cls +415 -0
  32. package/dist/compiler/exports/latex/numbering.d.ts +18 -0
  33. package/dist/compiler/exports/latex/numbering.js +57 -0
  34. package/dist/compiler/exports/latex/serialize.d.ts +112 -0
  35. package/dist/compiler/exports/latex/serialize.js +606 -0
  36. package/dist/compiler/exports/latex-export.d.ts +44 -0
  37. package/dist/compiler/exports/latex-export.js +169 -0
  38. package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
  39. package/dist/compiler/exports/markdown/serialize.js +527 -0
  40. package/dist/compiler/exports/markdown-export.d.ts +39 -0
  41. package/dist/compiler/exports/markdown-export.js +240 -0
  42. package/dist/compiler/exports/standalone-html.d.ts +15 -5
  43. package/dist/compiler/exports/standalone-html.js +23 -51
  44. package/dist/compiler/exports/static-tree.d.ts +253 -0
  45. package/dist/compiler/exports/static-tree.js +625 -0
  46. package/dist/compiler/exports/zip.d.ts +16 -0
  47. package/dist/compiler/exports/zip.js +72 -0
  48. package/dist/compiler/lesson-derivation.d.ts +8 -2
  49. package/dist/compiler/lesson-derivation.js +6 -7
  50. package/dist/compiler/manifest.d.ts +115 -8
  51. package/dist/compiler/manifest.js +221 -39
  52. package/dist/compiler/render-diagnostics.d.ts +65 -0
  53. package/dist/compiler/render-diagnostics.js +56 -0
  54. package/dist/compiler/resolution-report.d.ts +3 -1
  55. package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
  56. package/dist/curriculum/validation.d.ts +9 -3
  57. package/dist/curriculum/validation.js +35 -17
  58. package/dist/reference/base-abbreviations.d.mts +2 -2
  59. package/dist/reference/base-abbreviations.mjs +2 -2
  60. package/dist/reference/base-operators.d.mts +8 -2
  61. package/dist/reference/base-operators.mjs +8 -10
  62. package/dist/reference/base-roman-labels.d.mts +4 -3
  63. package/dist/reference/base-roman-labels.mjs +4 -3
  64. package/dist/reference/citation-format.d.mts +35 -0
  65. package/dist/reference/citation-format.mjs +74 -0
  66. package/dist/reference/consistency.d.ts +5 -9
  67. package/dist/reference/consistency.js +34 -38
  68. package/dist/reference/curriculum-alignment.js +3 -24
  69. package/dist/reference/derived-entries.d.ts +45 -0
  70. package/dist/reference/derived-entries.js +50 -0
  71. package/dist/reference/derived-notation.d.mts +125 -0
  72. package/dist/reference/derived-notation.mjs +121 -0
  73. package/dist/reference/diagram-fit.d.ts +45 -0
  74. package/dist/reference/diagram-fit.js +54 -0
  75. package/dist/reference/diagram-layout.d.ts +59 -10
  76. package/dist/reference/diagram-layout.js +85 -24
  77. package/dist/reference/diagrams-validate.d.ts +1 -1
  78. package/dist/reference/equations-validate.d.ts +1 -1
  79. package/dist/reference/equations-validate.js +26 -1
  80. package/dist/reference/equations.d.mts +27 -0
  81. package/dist/reference/equations.mjs +120 -22
  82. package/dist/reference/examples-validate.d.ts +1 -1
  83. package/dist/reference/figure-fit.d.ts +70 -0
  84. package/dist/reference/figure-fit.js +60 -0
  85. package/dist/reference/figure-pictures.d.ts +27 -0
  86. package/dist/reference/figure-pictures.js +49 -0
  87. package/dist/reference/figure-symbols.d.ts +32 -0
  88. package/dist/reference/figure-symbols.js +55 -0
  89. package/dist/reference/figures-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-abbreviations.js +1 -1
  92. package/dist/reference/gate-course-rules.d.ts +4 -3
  93. package/dist/reference/gate-course-rules.js +9 -4
  94. package/dist/reference/gate-heading-length.d.ts +2 -0
  95. package/dist/reference/gate-heading-length.js +7 -5
  96. package/dist/reference/gate-heading-structure.js +26 -0
  97. package/dist/reference/gate-indices.d.ts +35 -0
  98. package/dist/reference/gate-indices.js +299 -0
  99. package/dist/reference/gate-math.d.ts +32 -2
  100. package/dist/reference/gate-math.js +68 -12
  101. package/dist/reference/gate-mdx-expression.d.ts +17 -0
  102. package/dist/reference/gate-mdx-expression.js +29 -0
  103. package/dist/reference/gate-non-chapter-pages.d.ts +39 -0
  104. package/dist/reference/gate-non-chapter-pages.js +156 -0
  105. package/dist/reference/gate-part-pages.d.ts +4 -2
  106. package/dist/reference/gate-part-pages.js +2 -1
  107. package/dist/reference/gate-prop-text.d.ts +28 -0
  108. package/dist/reference/gate-prop-text.js +106 -0
  109. package/dist/reference/gate-prose-notation.d.ts +3 -1
  110. package/dist/reference/gate-prose-notation.js +4 -2
  111. package/dist/reference/gate-retired-keys.d.ts +35 -0
  112. package/dist/reference/gate-retired-keys.js +58 -0
  113. package/dist/reference/gloss.d.ts +2 -0
  114. package/dist/reference/katex-measure.d.ts +17 -0
  115. package/dist/reference/katex-measure.js +223 -0
  116. package/dist/reference/keyed-float-validate.js +12 -0
  117. package/dist/reference/keyed-jsx-floats.d.mts +2 -2
  118. package/dist/reference/keyed-jsx-floats.mjs +11 -14
  119. package/dist/reference/lab-katex-validate.d.ts +43 -0
  120. package/dist/reference/lab-katex-validate.js +96 -0
  121. package/dist/reference/lab-math-scope.d.ts +1 -0
  122. package/dist/reference/lab-math-scope.js +13 -2
  123. package/dist/reference/lint-ignore.js +1 -1
  124. package/dist/reference/math-glyphs.d.mts +94 -2
  125. package/dist/reference/math-glyphs.mjs +851 -46
  126. package/dist/reference/mixed-content.d.ts +8 -3
  127. package/dist/reference/mixed-content.js +4 -2
  128. package/dist/reference/page-notation.d.ts +39 -7
  129. package/dist/reference/page-notation.js +62 -10
  130. package/dist/reference/raw-floats.mjs +37 -13
  131. package/dist/reference/references.d.ts +11 -1
  132. package/dist/reference/references.js +6 -0
  133. package/dist/reference/registry.js +115 -11
  134. package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
  135. package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
  136. package/dist/reference/rehype-heading-anchors.mjs +11 -1
  137. package/dist/reference/remark-citation.mjs +0 -0
  138. package/dist/reference/remark-notation.mjs +275 -23
  139. package/dist/reference/render-lab-math.d.ts +13 -2
  140. package/dist/reference/render-lab-math.js +18 -6
  141. package/dist/reference/sections-validate.d.ts +28 -3
  142. package/dist/reference/sections-validate.js +31 -4
  143. package/dist/reference/sections.d.mts +24 -6
  144. package/dist/reference/sections.mjs +92 -17
  145. package/dist/reference/tables-validate.d.ts +1 -1
  146. package/dist/reference/tables-validate.js +24 -1
  147. package/dist/reference/text-measure.d.ts +10 -0
  148. package/dist/reference/text-measure.js +41 -0
  149. package/dist/reference/text-widths.d.ts +10 -0
  150. package/dist/reference/text-widths.js +1238 -0
  151. package/dist/reference/types.d.ts +64 -8
  152. package/dist/rules/catalog.js +223 -32
  153. package/dist/rules/in-effect.d.ts +1 -1
  154. package/dist/rules/rules.schema.json +100 -27
  155. package/dist/rules/settings.d.ts +1 -1
  156. package/dist/rules/settings.js +7 -0
  157. package/dist/session/PreferenceStore.d.ts +18 -1
  158. package/dist/session/PreferenceStore.js +29 -1
  159. package/docs/authoring/README.md +33 -21
  160. package/docs/authoring/agents.md +38 -7
  161. package/docs/authoring/components.md +115 -10
  162. package/docs/authoring/migrating.md +878 -0
  163. package/docs/authoring/notation.md +285 -30
  164. package/docs/authoring/retired.md +1 -0
  165. package/docs/authoring/structure-rules.md +84 -45
  166. package/docs/authoring/structure.md +67 -2
  167. package/docs/authoring/visualizations.md +167 -25
  168. package/package.json +12 -7
  169. package/src/compiler/astro.ts +72 -21
  170. package/src/compiler/cli.ts +143 -35
  171. package/src/compiler/collections.ts +158 -27
  172. package/src/compiler/content-schema.ts +108 -0
  173. package/src/compiler/content-schemas.ts +391 -0
  174. package/src/compiler/course-config.ts +86 -15
  175. package/src/compiler/exports/combined-html.ts +669 -0
  176. package/src/compiler/exports/download-index.ts +36 -0
  177. package/src/compiler/exports/export-chapters.ts +233 -0
  178. package/src/compiler/exports/export-pages.ts +44 -0
  179. package/src/compiler/exports/html-tree.ts +62 -0
  180. package/src/compiler/exports/latex/capture.ts +651 -0
  181. package/src/compiler/exports/latex/compile.ts +168 -0
  182. package/src/compiler/exports/latex/document.ts +479 -0
  183. package/src/compiler/exports/latex/explico.cls +415 -0
  184. package/src/compiler/exports/latex/numbering.ts +74 -0
  185. package/src/compiler/exports/latex/serialize.ts +787 -0
  186. package/src/compiler/exports/latex/texlive-packages.txt +76 -0
  187. package/src/compiler/exports/latex-export.ts +255 -0
  188. package/src/compiler/exports/markdown/serialize.ts +632 -0
  189. package/src/compiler/exports/markdown-export.ts +329 -0
  190. package/src/compiler/exports/standalone-html.ts +39 -70
  191. package/src/compiler/exports/static-tree.ts +944 -0
  192. package/src/compiler/exports/zip.ts +88 -0
  193. package/src/compiler/lesson-derivation.ts +13 -8
  194. package/src/compiler/manifest.ts +387 -40
  195. package/src/compiler/render-diagnostics.ts +111 -0
  196. package/src/components/CourseContents.astro +6 -0
  197. package/src/components/HoverPanel.astro +204 -6
  198. package/src/components/PartOverview.astro +18 -9
  199. package/src/components/abbreviation/AbbreviationGlossary.astro +6 -2
  200. package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
  201. package/src/components/assessments/AssessmentRunner.tsx +7 -1
  202. package/src/components/assessments/AssessmentSet.astro +14 -1
  203. package/src/components/block-heading.ts +38 -0
  204. package/src/components/card-arrow.ts +18 -4
  205. package/src/components/card-carry.ts +406 -0
  206. package/src/components/card-drag.ts +1 -30
  207. package/src/components/card-fetch.ts +87 -0
  208. package/src/components/card-manager.ts +1222 -38
  209. package/src/components/card-memory.ts +5 -1
  210. package/src/components/card-placement.ts +211 -13
  211. package/src/components/card-side.ts +281 -31
  212. package/src/components/citation/CitationLayer.astro +45 -40
  213. package/src/components/diagram/Diagram.astro +123 -44
  214. package/src/components/disclosure/print-as-shown.ts +42 -6
  215. package/src/components/figure/Figure.astro +17 -1
  216. package/src/components/figure/FloatCaption.astro +4 -17
  217. package/src/components/figure/figure-state.ts +26 -17
  218. package/src/components/float-toolbar.ts +274 -0
  219. package/src/components/memo/Memo.astro +21 -10
  220. package/src/components/memo/ShortNote.astro +4 -0
  221. package/src/components/notation/NotationGlossary.astro +22 -17
  222. package/src/components/notation/NotationLayer.astro +300 -79
  223. package/src/components/notation/NotationMath.astro +8 -1
  224. package/src/components/notation/render-notation-math.ts +51 -5
  225. package/src/components/readable-text.ts +25 -0
  226. package/src/components/reference/FloatRefLayer.astro +162 -89
  227. package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
  228. package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
  229. package/src/components/starlight/LayoutPageSidebar.astro +17 -3
  230. package/src/components/starlight/LayoutSocialIcons.astro +79 -29
  231. package/src/components/starlight/LessonExportActions.astro +384 -310
  232. package/src/components/starlight/LessonFooter.astro +66 -8
  233. package/src/components/starlight/PrintController.astro +28 -0
  234. package/src/components/starlight/TextSizeControl.astro +137 -0
  235. package/src/components/starlight/print-controller.ts +380 -0
  236. package/src/components/starlight/section-summary.ts +13 -5
  237. package/src/components/table/Table.astro +14 -22
  238. package/src/components/table/table-truncation.ts +15 -0
  239. package/src/content-config.ts +20 -312
  240. package/src/curriculum/validation.ts +72 -24
  241. package/src/reference/base-abbreviations.mjs +2 -2
  242. package/src/reference/base-operators.mjs +8 -10
  243. package/src/reference/base-roman-labels.mjs +4 -3
  244. package/src/reference/citation-format.mjs +74 -0
  245. package/src/reference/consistency.ts +47 -61
  246. package/src/reference/curriculum-alignment.ts +4 -41
  247. package/src/reference/derived-entries.ts +68 -0
  248. package/src/reference/derived-notation.mjs +121 -0
  249. package/src/reference/diagram-fit.ts +104 -0
  250. package/src/reference/diagram-layout.ts +182 -51
  251. package/src/reference/diagrams-validate.ts +2 -1
  252. package/src/reference/equations-validate.ts +41 -2
  253. package/src/reference/equations.mjs +120 -22
  254. package/src/reference/examples-validate.ts +1 -0
  255. package/src/reference/figure-fit.ts +139 -0
  256. package/src/reference/figure-pictures.ts +96 -0
  257. package/src/reference/figure-symbols.ts +117 -0
  258. package/src/reference/figures-validate.ts +2 -1
  259. package/src/reference/gate-abbreviations.ts +1 -1
  260. package/src/reference/gate-course-rules.ts +22 -7
  261. package/src/reference/gate-heading-length.ts +25 -7
  262. package/src/reference/gate-heading-structure.ts +40 -1
  263. package/src/reference/gate-indices.ts +470 -0
  264. package/src/reference/gate-math.ts +102 -13
  265. package/src/reference/gate-mdx-expression.ts +45 -0
  266. package/src/reference/gate-non-chapter-pages.ts +241 -0
  267. package/src/reference/gate-part-pages.ts +1 -1
  268. package/src/reference/gate-prop-text.ts +145 -0
  269. package/src/reference/gate-prose-notation.ts +19 -5
  270. package/src/reference/gate-retired-keys.ts +101 -0
  271. package/src/reference/gloss.ts +2 -0
  272. package/src/reference/katex-measure.ts +384 -0
  273. package/src/reference/keyed-float-validate.ts +22 -0
  274. package/src/reference/keyed-jsx-floats.mjs +11 -14
  275. package/src/reference/lab-katex-validate.ts +156 -0
  276. package/src/reference/lab-math-scope.ts +15 -1
  277. package/src/reference/lint-ignore.ts +4 -1
  278. package/src/reference/math-glyphs.mjs +851 -46
  279. package/src/reference/mixed-content.ts +11 -2
  280. package/src/reference/page-notation.ts +114 -14
  281. package/src/reference/raw-floats.mjs +37 -13
  282. package/src/reference/references.ts +14 -0
  283. package/src/reference/registry.ts +170 -9
  284. package/src/reference/rehype-fail-katex-errors.mjs +19 -0
  285. package/src/reference/rehype-heading-anchors.mjs +11 -1
  286. package/src/reference/remark-citation.mjs +0 -0
  287. package/src/reference/remark-notation.mjs +275 -23
  288. package/src/reference/render-lab-math.ts +40 -5
  289. package/src/reference/sections-validate.ts +67 -3
  290. package/src/reference/sections.mjs +92 -17
  291. package/src/reference/tables-validate.ts +40 -2
  292. package/src/reference/text-measure.ts +63 -0
  293. package/src/reference/text-widths.ts +133 -0
  294. package/src/reference/types.ts +75 -8
  295. package/src/rules/catalog.ts +241 -31
  296. package/src/rules/settings.ts +10 -0
  297. package/src/session/PreferenceStore.ts +53 -0
  298. package/src/styles/global.css +322 -83
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The content-schema check (invariant `content-schema`). Every content file
3
+ * must satisfy its collection's schema — the same zod schema `astro build`
4
+ * loads it with (`content-schemas.ts`) — so `pnpm validate:content` and
5
+ * `pnpm content check` reject what the build would, at the key's line,
6
+ * instead of passing it and leaving `astro build` to fail with
7
+ * `InvalidContentEntryDataError`.
8
+ */
9
+ import type { CurriculumIssue } from '../curriculum/validation';
10
+ import { keyLine, type ContentRecord } from '../reference/gate-retired-keys';
11
+ import { CONTENT_SCHEMAS } from './content-schemas';
12
+
13
+ export type ContentCollection = keyof typeof CONTENT_SCHEMAS;
14
+
15
+ /**
16
+ * The collection a repository-relative content file belongs to, or `null`
17
+ * for one Astro's loaders skip (`src/content-config.ts`): a docs page whose
18
+ * name starts with `_`, a notation or abbreviation file that is not `.md`.
19
+ */
20
+ export function collectionOf(file: string): ContentCollection | null {
21
+ const parts = file.replace(/\\/g, '/').split('/');
22
+ if (parts[0] !== 'content' || parts.length < 3) return null;
23
+ const directory = parts[1] ?? '';
24
+ const name = parts[parts.length - 1] ?? '';
25
+ if (!Object.hasOwn(CONTENT_SCHEMAS, directory)) return null;
26
+ const collection = directory as ContentCollection;
27
+ if (collection === 'docs') {
28
+ return /\.mdx?$/.test(name) && !name.startsWith('_') ? collection : null;
29
+ }
30
+ if (collection === 'notation' || collection === 'abbreviations') {
31
+ return name.endsWith('.md') ? collection : null;
32
+ }
33
+ return name.endsWith('.json') ? collection : null;
34
+ }
35
+
36
+ /**
37
+ * The 1-based line of the value at `path` in a YAML or JSON `text`: each name
38
+ * is searched for after the previous one, and an array index skips that many
39
+ * `- ` items of a YAML list. Falls back to the deepest line found.
40
+ */
41
+ export function lineOfPath(
42
+ text: string,
43
+ path: readonly (string | number)[],
44
+ ): number | undefined {
45
+ const first = path[0];
46
+ if (typeof first !== 'string') return undefined;
47
+ const start = keyLine(text, first);
48
+ if (start === undefined) return undefined;
49
+ const lines = text.split('\n');
50
+ let at = start - 1;
51
+ for (const segment of path.slice(1)) {
52
+ if (typeof segment === 'number') {
53
+ let seen = -1;
54
+ let found = -1;
55
+ for (let index = at + 1; index < lines.length; index += 1) {
56
+ if (/^\s*-\s/.test(lines[index] ?? '')) seen += 1;
57
+ if (seen === segment) {
58
+ found = index;
59
+ break;
60
+ }
61
+ }
62
+ if (found === -1) break;
63
+ at = found;
64
+ continue;
65
+ }
66
+ const name = segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ const pattern = new RegExp(`^[\\s-]*(?:"${name}"|${name})[ \\t]*:`);
68
+ let found = -1;
69
+ for (let index = at; index < lines.length; index += 1) {
70
+ if (pattern.test(lines[index] ?? '')) {
71
+ found = index;
72
+ break;
73
+ }
74
+ }
75
+ if (found === -1) break;
76
+ at = found;
77
+ }
78
+ return at + 1;
79
+ }
80
+
81
+ /** One `content-schema` issue per schema problem in each record, in record order. */
82
+ export function validateContentSchemas(
83
+ records: readonly ContentRecord[],
84
+ ): CurriculumIssue[] {
85
+ const issues: CurriculumIssue[] = [];
86
+ for (const { file, data, text } of records) {
87
+ const collection = collectionOf(file);
88
+ if (!collection) continue;
89
+ const result = CONTENT_SCHEMAS[collection].safeParse(data);
90
+ if (result.success) continue;
91
+ for (const problem of result.error.issues) {
92
+ const path = problem.path.filter(
93
+ (segment): segment is string | number =>
94
+ typeof segment === 'string' || typeof segment === 'number',
95
+ );
96
+ const line = lineOfPath(text, path);
97
+ const at = line === undefined ? file : `${file}:${line}`;
98
+ const field = path.length > 0 ? `${path.join('.')}: ` : '';
99
+ issues.push({
100
+ kind: 'content-schema',
101
+ message: `${at}: ${field}${problem.message} (the ${collection} collection's schema, which astro build applies too)`,
102
+ file,
103
+ ...(line === undefined ? {} : { line }),
104
+ });
105
+ }
106
+ }
107
+ return issues;
108
+ }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * The content collections' schemas, one definition for both readers of
3
+ * `content/`: Astro's collections (`src/content-config.ts`, which wraps each
4
+ * in `defineCollection`) and the manifest walk, which checks every record
5
+ * against the same schema (`content-schema.ts`, `validateContentSchemas`) so
6
+ * `pnpm validate:content` rejects what `astro build` would.
7
+ *
8
+ * Plain zod only — no `astro:content` — so the manifest can import it outside
9
+ * Vite.
10
+ */
11
+ import { z } from 'astro/zod';
12
+ import { isSubstantiveMeaning } from '../reference/prose';
13
+ import { glossKeySegment } from '../reference/gloss';
14
+ import { BASE_PLACEHOLDER } from '../reference/derived-notation.mjs';
15
+
16
+ export const contentId = z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/);
17
+ const id = contentId;
18
+
19
+ /**
20
+ * Notation `meaning` / `summary`: plain prose (no `$`, `\`, backticks), and —
21
+ * the C1b safeguard — substantive, not a placeholder. A short or blocklisted
22
+ * phrase fails the build so a local symbol cannot get a glossary-style panel
23
+ * for a throwaway definition.
24
+ */
25
+ export const notationProse = z
26
+ .string()
27
+ .min(1)
28
+ .refine((value) => !/[\\$`]/.test(value), {
29
+ message:
30
+ 'Notation meaning/summary must be plain prose: no `$`, `\\` or backtick. Put a symbol or formula in its own field (`latex`, `formula`), and write an amount of money in words or with its currency code ("USD 5").',
31
+ })
32
+ .refine(isSubstantiveMeaning, {
33
+ message:
34
+ 'Notation meaning must be substantive prose (at least four words, not a placeholder).',
35
+ });
36
+
37
+ const notationAlignment = z.discriminatedUnion('kind', [
38
+ z.object({
39
+ kind: z.literal('competency'),
40
+ introducedByCompetency: id,
41
+ introducedInLesson: id,
42
+ }),
43
+ z.object({
44
+ kind: z.literal('general'),
45
+ rationale: z.string().min(1),
46
+ }),
47
+ ]);
48
+ const notationAlignmentDefault = notationAlignment.default({
49
+ kind: 'general',
50
+ rationale: 'General notation.',
51
+ });
52
+
53
+ /**
54
+ * A citation on a notation definition: a bare id (locator pending, see D6/g) or
55
+ * `{id, locator}` — a per-claim locator verified against the cited text.
56
+ */
57
+ const notationSource = z.union([
58
+ id,
59
+ z.object({ id, locator: z.string().min(1) }),
60
+ ]);
61
+
62
+ /**
63
+ * A **gloss** on a notation card: a symbol the card's own `formula` needs in
64
+ * order to be written down, carrying a name and nothing else (D15).
65
+ *
66
+ * A gloss is deliberately cheaper than a card. It has no description, no
67
+ * sources, no curriculum alignment, no review state, and no glossary card of
68
+ * its own; it is not a node in the curriculum graph and never creates a
69
+ * reference edge. Its key is derived, not authored — `<card key>.<slug(name)>`
70
+ * — so a gloss is scoped to the card that declares it and two cards may each
71
+ * gloss `T`. Because a formula spells LaTeX and never a key, **promoting** a
72
+ * gloss is exactly: delete this line, add `content/notation/<key>.md`.
73
+ *
74
+ * `units` is optional and stays a bare unit string: "T — time" is ambiguous in
75
+ * a corpus with several `T`s, "T — time, years from valuation" is not, and it
76
+ * is still one line. Prose belongs on a card.
77
+ */
78
+ const notationGloss = z.object({
79
+ latex: z.string().min(1),
80
+ name: z
81
+ .string()
82
+ .min(1)
83
+ .refine((value) => glossKeySegment(value) !== undefined, {
84
+ message:
85
+ 'A gloss name must contain letters or digits so a key can be derived from it.',
86
+ })
87
+ // The symbol goes in `latex`, the name names it. A name carrying LaTeX is
88
+ // the one unambiguous way to get this field wrong, so it blocks; a name
89
+ // that is merely long is a Tier-3 `gloss-name-shape` warning instead,
90
+ // because a complicated quantity may honestly need several words.
91
+ .refine((value) => !/[\\$`\n]/.test(value), {
92
+ message:
93
+ 'A gloss name is a plain noun phrase; put the symbol in `latex`, not in the name.',
94
+ }),
95
+ units: z.string().min(1).optional(),
96
+ });
97
+
98
+ /** An index entry's latex: a subscript on an empty group, `{}_0`. */
99
+ export function isIndexEntryLatex(latex: string): boolean {
100
+ return /^\s*\{\s*\}\s*_/.test(latex);
101
+ }
102
+
103
+ /**
104
+ * One notation entry shape (Phase C1b), shared by page-local `notation.local`
105
+ * and the standalone `notation` collection.
106
+ *
107
+ * Authored: `key`, `latex`, `meaning`, optional `formula`, optional `units`
108
+ * (or `dimensionless: true`), `seeAlso`, `sources` (`{id, locator}`),
109
+ * optional `alignment` (defaults to `general`), optional `label`; and for
110
+ * rule N23, `reads` on an index entry, `range` and `fixedIndex` on a letter.
111
+ *
112
+ * The display label defaults to the humanized `key` (see `reference/label.ts`);
113
+ * `label` is authored only where that reads wrong. The `notationProse`
114
+ * refinement rejects an under-specified `meaning`.
115
+ */
116
+ const notationEntryObject = z.object({
117
+ key: id,
118
+ latex: z.string().min(1),
119
+ meaning: notationProse,
120
+ formula: z.string().min(1).optional(),
121
+ units: z.string().min(1).optional(),
122
+ dimensionless: z.literal(true).optional(),
123
+ seeAlso: z.array(id).default([]),
124
+ sources: z.array(notationSource).default([]),
125
+ alignment: notationAlignmentDefault,
126
+ label: z.string().min(1).optional(),
127
+ glosses: z.array(notationGloss).default([]),
128
+ /**
129
+ * An index entry's title for the symbol it indexes (rule N23):
130
+ * `"{base} evaluated at t = 0"`. Defaults to `"<label> of {base}"`.
131
+ */
132
+ reads: z
133
+ .string()
134
+ .min(1)
135
+ .refine((value) => value.includes(BASE_PLACEHOLDER), {
136
+ message: `reads names the indexed symbol with ${BASE_PLACEHOLDER}, e.g. "${BASE_PLACEHOLDER} evaluated at t = 0".`,
137
+ })
138
+ .refine((value) => !/[\\$`]/.test(value), {
139
+ message: 'reads is plain prose: no `$`, `\\` or backtick.',
140
+ })
141
+ .optional(),
142
+ /** The set a running index letter runs over (rule N23): `1, \dots, n`. */
143
+ range: z.string().min(1).optional(),
144
+ /** As an index, this letter (or this entry's own index) is a fixed label. */
145
+ fixedIndex: z.literal(true).optional(),
146
+ });
147
+
148
+ /**
149
+ * Rule N23's fields belong to one kind of entry each: `reads` to an index
150
+ * entry (`{}_0`), and `range`, `fixedIndex` and units to anything else — a
151
+ * subscript has no unit, and a derived card takes the indexed symbol's.
152
+ */
153
+ function refineIndexFields(
154
+ entry: z.infer<typeof notationEntryObject>,
155
+ context: z.RefinementCtx,
156
+ ): void {
157
+ const index = isIndexEntryLatex(entry.latex);
158
+ if (entry.reads !== undefined && !index) {
159
+ context.addIssue({
160
+ code: z.ZodIssueCode.custom,
161
+ path: ['reads'],
162
+ message:
163
+ 'reads belongs on an index entry, whose latex is a subscript on an empty group ({}_0).',
164
+ });
165
+ }
166
+ for (const field of ['range', 'fixedIndex', 'units', 'dimensionless'] as const) {
167
+ if (index && entry[field] !== undefined) {
168
+ context.addIssue({
169
+ code: z.ZodIssueCode.custom,
170
+ path: [field],
171
+ message: `An index entry ({}_…) takes no ${field}: a derived card takes its units from the symbol it indexes, and the index it names is fixed already.`,
172
+ });
173
+ }
174
+ }
175
+ }
176
+
177
+ export const notationEntryShape = notationEntryObject.superRefine(refineIndexFields);
178
+
179
+ /**
180
+ * One `notation.suspend` item (rule N22): a decoration (`'`, `\hat`, …) that
181
+ * is not an operator on this page. The loader (`collections.ts`) checks the
182
+ * name against the decorations the resolver knows.
183
+ */
184
+ const notationSuspension = z.object({
185
+ operator: z.string().min(1),
186
+ reason: z.string().min(1).optional(),
187
+ });
188
+
189
+ const lessonNotation = z
190
+ .object({
191
+ // `notation.uses` is retired (D3): a lesson pulls a shared key into scope
192
+ // by referencing it in prose (`[[key]]`) or math (`\explain`).
193
+ local: z.array(notationEntryShape).default([]),
194
+ suspend: z.array(notationSuspension).default([]),
195
+ })
196
+ .default({ local: [], suspend: [] });
197
+
198
+ /**
199
+ * How long this chapter takes to study: `40`, or a `[low, high]` range in
200
+ * minutes. It is a reader-facing estimate the author makes, aggregated by
201
+ * `<PartOverview>` and `<CourseContents>` into a part and course total — not
202
+ * the provenance field `estimatedMinutes` used to be, and nothing derives it.
203
+ *
204
+ * A part only reports a total when every one of its chapters states one, so a
205
+ * half-annotated course shows the chapters it knows about and no misleading
206
+ * sum.
207
+ */
208
+ const studyMinutes = z
209
+ .union([
210
+ z.number().int().positive(),
211
+ z.tuple([z.number().int().positive(), z.number().int().positive()]),
212
+ ])
213
+ .refine((value) => !Array.isArray(value) || value[0] <= value[1], {
214
+ message:
215
+ 'studyMinutes as a range is [low, high]; the low end cannot exceed the high end.',
216
+ });
217
+
218
+ /**
219
+ * Lesson frontmatter — reduced shape (Phase C1 schema, C2 codemod applied):
220
+ * the fields explico adds to Starlight's own (`title`, `description`, …).
221
+ *
222
+ * Authored: `teaches` (ordered — the curriculum contract, never derived),
223
+ * `assumptions`, `studyMinutes` (optional reader-facing estimate),
224
+ * `notation.local` (page-local symbols only).
225
+ * Derived (in `compiler/collections.ts` + `compiler/lesson-derivation.ts`,
226
+ * never authored): `lessonId` (doc slug), `requires` (direct
227
+ * competency-DAG prerequisites of `teaches`, minus `teaches`),
228
+ * `sources` (`[@…]` occurrences), `assessments` (colocated
229
+ * `<lesson>.checks.yml`), `sidebar` order (track order).
230
+ * Dropped: `aiAssisted`, `lastReviewed`, `riskTier` (git history carries
231
+ * provenance); `editorialStatus`, the draft / in-review / reviewed
232
+ * flag, because lessons are reviewed continuously and never earn a
233
+ * one-time stamp (a file that still sets it fails the build, see
234
+ * `reference/gate-retired-keys.ts`); `estimatedMinutes`, whose
235
+ * reader-facing half returned as the optional `studyMinutes` once
236
+ * part pages had somewhere to total it; `notation.uses` (D3 — a
237
+ * shared symbol is imported by using it).
238
+ */
239
+ export const lessonFrontmatterSchema = z.object({
240
+ teaches: z.array(id).default([]),
241
+ assumptions: z.array(z.string().min(1)).default([]),
242
+ studyMinutes: studyMinutes.optional(),
243
+ notation: lessonNotation,
244
+ });
245
+
246
+ export const competencySchema = z.object({
247
+ id,
248
+ title: z.string().min(1),
249
+ domain: id,
250
+ facet: z.enum([
251
+ 'knowledge',
252
+ 'calculation',
253
+ 'interpretation',
254
+ 'convention',
255
+ 'risk',
256
+ ]),
257
+ outcome: z.string().min(1),
258
+ prerequisites: z.array(id).default([]),
259
+ evidence: z.object({
260
+ minimumIndependentItems: z.number().int().min(1),
261
+ requiresTransfer: z.boolean(),
262
+ requiresUnassistedPass: z.boolean(),
263
+ }),
264
+ misconceptions: z.array(z.string().min(1)).default([]),
265
+ });
266
+
267
+ const assessmentItemBase = z.object({
268
+ id,
269
+ competencyId: id,
270
+ evidenceKind: z.enum(['direct', 'transfer']),
271
+ prompt: z.string().min(1),
272
+ explanation: z.string().min(1),
273
+ });
274
+
275
+ /**
276
+ * `AssessmentSet.astro` renders the numbered heading itself — `<h2>Knowledge
277
+ * check {number} — {title}</h2>` — so `title` must be just the topic. A title
278
+ * that repeats the "Knowledge check" label duplicates it on every page that
279
+ * uses `AssessmentSet` (the standalone `AssessmentRunner` heading fallback
280
+ * reads fine either way, so this only bites the common path).
281
+ */
282
+ const assessmentTitle = z
283
+ .string()
284
+ .min(1)
285
+ .refine((value) => !/^knowledge check\b/i.test(value), {
286
+ message:
287
+ '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".',
288
+ });
289
+
290
+ export const assessmentSchema = z.object({
291
+ id,
292
+ title: assessmentTitle,
293
+ items: z
294
+ .array(
295
+ z.discriminatedUnion('type', [
296
+ assessmentItemBase.extend({
297
+ type: z.literal('numeric'),
298
+ answer: z.object({
299
+ value: z.number(),
300
+ tolerance: z.number().positive(),
301
+ }),
302
+ }),
303
+ assessmentItemBase.extend({
304
+ type: z.literal('single-choice'),
305
+ options: z
306
+ .array(
307
+ z.object({
308
+ id,
309
+ label: z.string().min(1),
310
+ }),
311
+ )
312
+ .min(2),
313
+ correctOptionId: id,
314
+ }),
315
+ ]),
316
+ )
317
+ .min(1),
318
+ });
319
+
320
+ export const trackSchema = z.object({
321
+ id,
322
+ title: z.string().min(1),
323
+ description: z.string().min(1),
324
+ audience: z.string().min(1),
325
+ entryAssumptions: z.array(z.string().min(1)),
326
+ lessons: z.array(id).min(1),
327
+ });
328
+
329
+ export const sourceSchema = z.object({
330
+ id,
331
+ type: z.enum([
332
+ 'primary',
333
+ 'official-guidance',
334
+ 'paper',
335
+ 'book',
336
+ 'secondary',
337
+ ]),
338
+ title: z.string().min(1),
339
+ authors: z.array(z.string().min(1)).optional(),
340
+ organization: z.string().min(1).optional(),
341
+ edition: z.string().optional(),
342
+ publisher: z.string().optional(),
343
+ year: z.number().int().optional(),
344
+ isbn: z.string().optional(),
345
+ url: z.url().optional(),
346
+ locator: z.string().optional(),
347
+ accessed: z.coerce.date().optional(),
348
+ licenseNotes: z.string().optional(),
349
+ });
350
+
351
+ /**
352
+ * Standalone notation collection — the one `notationEntry` shape plus the
353
+ * shared-only extras: `domain`, `aliases`, and a Markdown body.
354
+ */
355
+ export const notationSchema = notationEntryObject
356
+ .extend({
357
+ domain: id,
358
+ aliases: z.array(z.string().min(1)).default([]),
359
+ aiAssisted: z.boolean().default(false),
360
+ })
361
+ .superRefine(refineIndexFields);
362
+
363
+ /**
364
+ * Abbreviations table (rule N14) — deliberately a lighter tier than a
365
+ * `notation` card: just a name resolution, no `formula`, `units`, or
366
+ * curriculum `alignment`. `abbr` is the canonical written form (`OLS`);
367
+ * `aliases` are other written forms of the same abbreviation that should
368
+ * resolve the same way (`w.r.t.` might alias `wrt`).
369
+ */
370
+ export const abbreviationSchema = z.object({
371
+ key: id,
372
+ abbr: z.string().min(1),
373
+ expansion: notationProse,
374
+ aliases: z.array(z.string().min(1)).default([]),
375
+ domain: id.optional(),
376
+ });
377
+
378
+ /**
379
+ * Each collection's directory under `content/` and the schema a record there
380
+ * must satisfy. `docs` checks only the fields explico adds; Starlight's own
381
+ * (`title`, `description`, …) are Starlight's schema's to judge.
382
+ */
383
+ export const CONTENT_SCHEMAS = {
384
+ docs: lessonFrontmatterSchema,
385
+ competencies: competencySchema,
386
+ assessments: assessmentSchema,
387
+ tracks: trackSchema,
388
+ sources: sourceSchema,
389
+ notation: notationSchema,
390
+ abbreviations: abbreviationSchema,
391
+ } as const;
@@ -36,13 +36,6 @@ export interface CoursePart {
36
36
  */
37
37
  export type CourseSection = CoursePart;
38
38
 
39
- export interface CourseConventions {
40
- /** Notation key a lesson's stated sign convention must resolve to. */
41
- readonly signConventionKey: string;
42
- /** Competency id that also counts as declaring the cash-flow perspective. */
43
- readonly cashFlowPerspectiveCompetency: string;
44
- }
45
-
46
39
  /**
47
40
  * A course's edits to the shipped named-operator baseline (`BASE_OPERATORS`),
48
41
  * consumed by the `operator-explained` consistency check (rule N8). An operator
@@ -360,6 +353,39 @@ export function normalizeBlockLabels(config: CourseConfig): BlockLabels {
360
353
  return labels;
361
354
  }
362
355
 
356
+ /**
357
+ * How a course's explanation cards start for a reader who has not chosen
358
+ * yet (`cards` in the course settings). Every field is optional;
359
+ * {@link normalizeCards} fills in the defaults.
360
+ */
361
+ export interface CourseCards {
362
+ /**
363
+ * Whether "Carry cards" starts on (rule K1, `card-carry.ts`): a kept card
364
+ * stays on screen as the page scrolls and comes along to every page of the
365
+ * course. A reader's own choice always wins. Default `false`.
366
+ */
367
+ readonly carry?: boolean;
368
+ }
369
+
370
+ /** {@link CourseCards} with every default applied. */
371
+ export interface NormalizedCards {
372
+ readonly carry: boolean;
373
+ }
374
+
375
+ /** The engine's defaults: a reader turns carrying on (D8). */
376
+ export const CARDS_DEFAULTS: NormalizedCards = { carry: false };
377
+
378
+ /** Validate `CourseConfig.cards` and apply its defaults. Throws on a bad value. */
379
+ export function normalizeCards(config: CourseConfig): NormalizedCards {
380
+ const carry: unknown = config.cards?.carry ?? CARDS_DEFAULTS.carry;
381
+ if (typeof carry !== 'boolean') {
382
+ throw new Error(
383
+ `CourseConfig cards.carry must be true or false; received ${JSON.stringify(carry)}.`,
384
+ );
385
+ }
386
+ return { carry };
387
+ }
388
+
363
389
  /**
364
390
  * Rule S2's word ranges (`rules.S2.bridgeMinWords` … in the course settings),
365
391
  * in the shape `gate-part-pages.ts` takes. Built by `partPageLimits` in
@@ -370,15 +396,33 @@ export interface NormalizedPartPages {
370
396
  readonly scope: { readonly minWords: number; readonly maxWords: number };
371
397
  }
372
398
 
373
- /** Optional, build-time lesson artifacts exposed to readers. */
399
+ /**
400
+ * Optional, build-time downloads and print actions: each chapter, each part and
401
+ * the course download; every page prints.
402
+ */
374
403
  export interface LessonExportsConfig {
375
- /** Emit one self-contained, offline-capable HTML file per lesson. */
404
+ /** Emit one self-contained, offline-capable HTML file per page. */
376
405
  readonly standaloneHtml?: boolean;
377
- /** Offer the browser's native print dialog with explico's lesson print layout. */
406
+ /** Offer the browser's native print dialog with explico's print layout. */
378
407
  readonly print?: boolean;
408
+ /**
409
+ * Emit each chapter's LaTeX source — a zipped folder with explico's class,
410
+ * the chapter and its figures — and the PDF compiled from that folder.
411
+ */
412
+ readonly latex?: boolean;
413
+ /**
414
+ * Paper size of the LaTeX documents: `letter` (default, LaTeX's own) or
415
+ * `a4`. A plain `string` because the settings arrive as JSON.
416
+ */
417
+ readonly latexPaper?: string;
418
+ /**
419
+ * Emit each chapter, each part and the course as Markdown — GitHub's
420
+ * flavour with `$…$` math, its figures as PNG — zipped with its assets.
421
+ */
422
+ readonly markdown?: boolean;
379
423
  /**
380
424
  * Published course root used for links that intentionally leave the exported
381
- * lesson. Required at export time when such a link is present.
425
+ * page. Required at export time when such a link is present.
382
426
  */
383
427
  readonly canonicalBaseUrl?: string;
384
428
  }
@@ -387,6 +431,9 @@ export interface LessonExportsConfig {
387
431
  export interface NormalizedLessonExports {
388
432
  readonly standaloneHtml: boolean;
389
433
  readonly print: boolean;
434
+ readonly latex: boolean;
435
+ readonly latexPaper: 'letter' | 'a4';
436
+ readonly markdown: boolean;
390
437
  readonly canonicalBaseUrl?: string;
391
438
  }
392
439
 
@@ -405,7 +452,12 @@ export interface CourseConfig {
405
452
  readonly sections?: readonly CoursePart[];
406
453
  /** Namespaces used by competency / notation `domain` fields. */
407
454
  readonly domains: readonly string[];
408
- readonly conventions: CourseConventions;
455
+ /**
456
+ * @deprecated No longer read: rule S5, the only reader, was retired. A
457
+ * course that still sets it gets a `config-deprecated-key` warning; delete
458
+ * the block. Removed from the type in the next MINOR release.
459
+ */
460
+ readonly conventions?: unknown;
409
461
  /** Reader-facing identity + knowledge version (the `[?]` About panel). */
410
462
  readonly about: CourseAbout;
411
463
  /**
@@ -428,12 +480,14 @@ export interface CourseConfig {
428
480
  readonly romanLabels?: CourseRomanLabels;
429
481
  /** @deprecated Moved to `rules.N14` (same shape). Honoured for now; removed in a later MINOR. */
430
482
  readonly abbreviations?: CourseAbbreviations;
431
- /** Per-lesson export actions and downloadable artifacts. Disabled when omitted. */
483
+ /** Per-page export actions and downloadable artifacts. Disabled when omitted. */
432
484
  readonly lessonExports?: LessonExportsConfig;
433
485
  /** Every collapse default: headings, checks, memos, floats, tables. */
434
486
  readonly disclosure?: CourseDisclosure;
435
487
  /** Outline and float numbering. The defaults apply when omitted. */
436
488
  readonly numbering?: CourseNumbering;
489
+ /** How explanation cards start for a reader: "Carry cards" on or off. */
490
+ readonly cards?: CourseCards;
437
491
  /**
438
492
  * How a check's and an example set's heading names its kind: `icon-word`
439
493
  * (default) or `icon` ({@link BlockLabels}). A plain `string` because the
@@ -449,8 +503,18 @@ export function normalizeLessonExports(
449
503
  ): NormalizedLessonExports {
450
504
  const standaloneHtml = config.lessonExports?.standaloneHtml === true;
451
505
  const print = config.lessonExports?.print === true;
506
+ const latex = config.lessonExports?.latex === true;
507
+ const rawPaper = config.lessonExports?.latexPaper ?? 'letter';
508
+ if (rawPaper !== 'letter' && rawPaper !== 'a4') {
509
+ throw new Error(
510
+ `CourseConfig lessonExports.latexPaper must be "letter" or "a4"; ` +
511
+ `received ${JSON.stringify(rawPaper)}.`,
512
+ );
513
+ }
514
+ const latexPaper: 'letter' | 'a4' = rawPaper;
515
+ const markdown = config.lessonExports?.markdown === true;
452
516
  const rawBase = config.lessonExports?.canonicalBaseUrl;
453
- if (rawBase === undefined) return { standaloneHtml, print };
517
+ if (rawBase === undefined) return { standaloneHtml, print, latex, latexPaper, markdown };
454
518
 
455
519
  let parsed: URL;
456
520
  try {
@@ -473,7 +537,14 @@ export function normalizeLessonExports(
473
537
  );
474
538
  }
475
539
  if (!parsed.pathname.endsWith('/')) parsed.pathname += '/';
476
- return { standaloneHtml, print, canonicalBaseUrl: parsed.toString() };
540
+ return {
541
+ standaloneHtml,
542
+ print,
543
+ latex,
544
+ latexPaper,
545
+ markdown,
546
+ canonicalBaseUrl: parsed.toString(),
547
+ };
477
548
  }
478
549
 
479
550
  /**