explico 0.13.0 → 0.14.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 (239) hide show
  1. package/CHANGELOG.md +894 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +43 -0
  6. package/dist/compiler/cli-main.js +392 -0
  7. package/dist/compiler/cli.d.ts +424 -9
  8. package/dist/compiler/cli.js +1289 -8
  9. package/dist/compiler/collections.d.ts +88 -1
  10. package/dist/compiler/collections.js +170 -25
  11. package/dist/compiler/course-config.d.ts +1 -1
  12. package/dist/compiler/course-config.js +105 -1
  13. package/dist/compiler/decks.d.ts +161 -0
  14. package/dist/compiler/decks.js +235 -0
  15. package/dist/compiler/exports/combined-html.d.ts +5 -1
  16. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  17. package/dist/compiler/exports/deck-fit.js +90 -0
  18. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  19. package/dist/compiler/exports/export-chapters.js +64 -5
  20. package/dist/compiler/exports/export-pages.d.ts +30 -3
  21. package/dist/compiler/exports/export-pages.js +24 -1
  22. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  23. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  24. package/dist/compiler/exports/latex/cache.js +129 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  26. package/dist/compiler/exports/latex/capture.js +3 -1
  27. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  28. package/dist/compiler/exports/latex/compile.js +12 -1
  29. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  30. package/dist/compiler/exports/latex/deck.js +349 -0
  31. package/dist/compiler/exports/latex/document.d.ts +51 -6
  32. package/dist/compiler/exports/latex/document.js +71 -12
  33. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  34. package/dist/compiler/exports/latex/explico.cls +13 -183
  35. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  36. package/dist/compiler/exports/latex/serialize.js +4 -2
  37. package/dist/compiler/exports/latex-export.d.ts +24 -2
  38. package/dist/compiler/exports/latex-export.js +107 -26
  39. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  40. package/dist/compiler/exports/markdown/serialize.js +85 -8
  41. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  42. package/dist/compiler/exports/markdown-export.js +50 -11
  43. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  44. package/dist/compiler/exports/standalone-html.js +3 -3
  45. package/dist/compiler/exports/static-tree.d.ts +38 -1
  46. package/dist/compiler/exports/static-tree.js +120 -4
  47. package/dist/compiler/knowledge-version.d.ts +26 -2
  48. package/dist/compiler/knowledge-version.js +27 -17
  49. package/dist/compiler/lesson-derivation.d.ts +143 -6
  50. package/dist/compiler/lesson-derivation.js +145 -13
  51. package/dist/compiler/manifest.d.ts +133 -4
  52. package/dist/compiler/manifest.js +359 -58
  53. package/dist/compiler/page-versions.d.ts +160 -0
  54. package/dist/compiler/page-versions.js +198 -0
  55. package/dist/compiler/render-diagnostics.d.ts +5 -1
  56. package/dist/compiler/resolution-report.d.ts +24 -6
  57. package/dist/compiler/resolution-report.js +22 -4
  58. package/dist/compiler/validate-content.d.ts +7 -0
  59. package/dist/compiler/validate-content.js +160 -0
  60. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  61. package/dist/curriculum/competency-graph.d.ts +54 -0
  62. package/dist/curriculum/competency-graph.js +41 -0
  63. package/dist/reference/base-library.d.mts +8 -0
  64. package/dist/reference/base-library.mjs +19 -0
  65. package/dist/reference/consistency.d.ts +8 -4
  66. package/dist/reference/consistency.js +76 -26
  67. package/dist/reference/deck-remark.d.mts +123 -0
  68. package/dist/reference/deck-remark.mjs +600 -0
  69. package/dist/reference/deck-source.d.ts +107 -0
  70. package/dist/reference/deck-source.js +140 -0
  71. package/dist/reference/decks-validate.d.ts +133 -0
  72. package/dist/reference/decks-validate.js +359 -0
  73. package/dist/reference/derived-entries.d.ts +5 -1
  74. package/dist/reference/diagram-fit.d.ts +1 -1
  75. package/dist/reference/figure-symbols.d.ts +23 -6
  76. package/dist/reference/figure-symbols.js +51 -15
  77. package/dist/reference/float-name.d.ts +43 -0
  78. package/dist/reference/float-name.js +58 -0
  79. package/dist/reference/folds-validate.d.ts +1 -1
  80. package/dist/reference/gate-abbreviations.d.ts +1 -1
  81. package/dist/reference/gate-indices.js +5 -4
  82. package/dist/reference/gate-math.d.ts +29 -2
  83. package/dist/reference/gate-math.js +99 -18
  84. package/dist/reference/gate-part-pages.d.ts +1 -1
  85. package/dist/reference/lab-katex-validate.d.ts +10 -6
  86. package/dist/reference/lab-katex-validate.js +17 -9
  87. package/dist/reference/lab-math-scope.js +29 -4
  88. package/dist/reference/math-glyphs.d.mts +25 -0
  89. package/dist/reference/math-glyphs.mjs +189 -25
  90. package/dist/reference/page-notation.d.ts +22 -3
  91. package/dist/reference/page-notation.js +32 -10
  92. package/dist/reference/references.d.ts +14 -1
  93. package/dist/reference/references.js +13 -0
  94. package/dist/reference/registry-scope.d.mts +33 -0
  95. package/dist/reference/registry-scope.mjs +34 -0
  96. package/dist/reference/registry.js +29 -10
  97. package/dist/reference/rehype-deck.d.mts +6 -0
  98. package/dist/reference/rehype-deck.mjs +286 -0
  99. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  100. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  101. package/dist/reference/remark-notation.mjs +249 -40
  102. package/dist/reference/source-lines.d.mts +12 -0
  103. package/dist/reference/source-lines.mjs +65 -0
  104. package/dist/reference/types.d.ts +28 -0
  105. package/dist/rules/catalog.d.ts +21 -4
  106. package/dist/rules/catalog.js +221 -2
  107. package/dist/rules/course-rules.d.ts +5 -1
  108. package/dist/rules/course-rules.js +17 -3
  109. package/dist/rules/course-rules.schema.json +12 -0
  110. package/dist/rules/in-effect.d.ts +13 -3
  111. package/dist/rules/in-effect.js +30 -7
  112. package/dist/rules/rules.schema.json +259 -1
  113. package/dist/rules/settings.d.ts +1 -1
  114. package/dist/session/PreferenceStore.d.ts +6 -0
  115. package/dist/session/PreferenceStore.js +18 -1
  116. package/dist/session/deck-memory.d.ts +116 -0
  117. package/dist/session/deck-memory.js +143 -0
  118. package/dist/session/reading-memory.d.ts +96 -0
  119. package/dist/session/reading-memory.js +133 -0
  120. package/docs/VERSIONING.md +21 -18
  121. package/docs/authoring/README.md +29 -9
  122. package/docs/authoring/agents.md +63 -16
  123. package/docs/authoring/components.md +15 -0
  124. package/docs/authoring/deck.md +588 -0
  125. package/docs/authoring/migrating.md +1087 -14
  126. package/docs/authoring/notation.md +49 -6
  127. package/docs/authoring/structure.md +80 -1
  128. package/docs/authoring/versioning.md +54 -2
  129. package/docs/authoring/visualizations.md +7 -2
  130. package/docs/course-agent/AGENTS.md +37 -0
  131. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  132. package/docs/course-agent/skills/draft-lesson/SKILL.md +37 -0
  133. package/docs/course-agent/skills/migrate-course/SKILL.md +31 -0
  134. package/docs/course-agent/skills/release-content/SKILL.md +24 -0
  135. package/docs/diagram-scaling.md +148 -0
  136. package/docs/privacy.md +59 -0
  137. package/package.json +8 -2
  138. package/src/compiler/astro.ts +60 -21
  139. package/src/compiler/cli-main.ts +469 -0
  140. package/src/compiler/cli.ts +1905 -16
  141. package/src/compiler/collections.ts +284 -25
  142. package/src/compiler/course-config.ts +267 -0
  143. package/src/compiler/decks.ts +455 -0
  144. package/src/compiler/exports/deck-fit.ts +127 -0
  145. package/src/compiler/exports/export-chapters.ts +105 -6
  146. package/src/compiler/exports/export-pages.ts +46 -2
  147. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  148. package/src/compiler/exports/latex/cache.ts +183 -0
  149. package/src/compiler/exports/latex/capture.ts +5 -2
  150. package/src/compiler/exports/latex/compile.ts +18 -0
  151. package/src/compiler/exports/latex/deck.ts +419 -0
  152. package/src/compiler/exports/latex/document.ts +117 -21
  153. package/src/compiler/exports/latex/explico-common.sty +198 -0
  154. package/src/compiler/exports/latex/explico.cls +13 -183
  155. package/src/compiler/exports/latex/serialize.ts +25 -21
  156. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  157. package/src/compiler/exports/latex-export.ts +187 -45
  158. package/src/compiler/exports/markdown/serialize.ts +118 -9
  159. package/src/compiler/exports/markdown-export.ts +56 -11
  160. package/src/compiler/exports/standalone-html.ts +8 -7
  161. package/src/compiler/exports/static-tree.ts +175 -3
  162. package/src/compiler/knowledge-version.ts +56 -21
  163. package/src/compiler/lesson-derivation.ts +282 -16
  164. package/src/compiler/manifest.ts +558 -63
  165. package/src/compiler/page-versions.ts +359 -0
  166. package/src/compiler/resolution-report.ts +38 -7
  167. package/src/compiler/validate-content.ts +212 -0
  168. package/src/components/CourseContents.astro +124 -6
  169. package/src/components/CurriculumMap.astro +49 -1
  170. package/src/components/FloatIndex.astro +1 -1
  171. package/src/components/PartOverview.astro +51 -5
  172. package/src/components/assessments/AssessmentSet.astro +41 -15
  173. package/src/components/block-heading.ts +15 -6
  174. package/src/components/card-carry.ts +7 -4
  175. package/src/components/card-manager.ts +14 -2
  176. package/src/components/card-memory.ts +13 -9
  177. package/src/components/deck/DeckEnhancer.astro +13 -0
  178. package/src/components/deck/deck-context.ts +104 -0
  179. package/src/components/deck/deck-controller.ts +1287 -0
  180. package/src/components/deck/deck-map.ts +91 -0
  181. package/src/components/deck-lane.ts +163 -0
  182. package/src/components/diagram/Diagram.astro +131 -86
  183. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  184. package/src/components/figure/Figure.astro +7 -2
  185. package/src/components/figure/FloatCaption.astro +5 -3
  186. package/src/components/fold/Fold.astro +2 -2
  187. package/src/components/notation/NotationFormula.astro +81 -11
  188. package/src/components/notation/NotationGlossary.astro +132 -1
  189. package/src/components/notation/NotationLayer.astro +29 -0
  190. package/src/components/starlight/AboutPanel.astro +186 -3
  191. package/src/components/starlight/CollapsibleSections.astro +24 -3
  192. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  193. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  194. package/src/components/starlight/LayoutHeader.astro +17 -0
  195. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  196. package/src/components/starlight/LayoutSidebar.astro +807 -21
  197. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  198. package/src/components/starlight/LessonExportActions.astro +27 -9
  199. package/src/components/starlight/LessonFooter.astro +59 -0
  200. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  201. package/src/components/starlight/TextSizeControl.astro +24 -13
  202. package/src/components/starlight/page-marks.ts +139 -0
  203. package/src/components/starlight/section-summary.ts +1 -1
  204. package/src/components/starlight/sidebar-editions.ts +245 -0
  205. package/src/components/table/Table.astro +15 -7
  206. package/src/content-config.ts +87 -1
  207. package/src/curriculum/competency-graph.ts +95 -0
  208. package/src/reference/base-library.mjs +19 -0
  209. package/src/reference/consistency.ts +126 -39
  210. package/src/reference/deck-remark.mjs +600 -0
  211. package/src/reference/deck-source.ts +273 -0
  212. package/src/reference/decks-validate.ts +604 -0
  213. package/src/reference/diagram-layout.ts +3 -1
  214. package/src/reference/figure-symbols.ts +108 -31
  215. package/src/reference/folds-validate.ts +1 -1
  216. package/src/reference/gate-indices.ts +5 -3
  217. package/src/reference/gate-math.ts +169 -25
  218. package/src/reference/lab-katex-validate.ts +30 -15
  219. package/src/reference/lab-math-scope.ts +34 -3
  220. package/src/reference/math-glyphs.mjs +189 -25
  221. package/src/reference/page-notation.ts +69 -15
  222. package/src/reference/references.ts +28 -0
  223. package/src/reference/registry-scope.mjs +34 -0
  224. package/src/reference/registry.ts +41 -11
  225. package/src/reference/rehype-deck.mjs +286 -0
  226. package/src/reference/rehype-heading-anchors.mjs +48 -6
  227. package/src/reference/remark-notation.mjs +249 -40
  228. package/src/reference/source-lines.mjs +65 -0
  229. package/src/reference/types.ts +28 -0
  230. package/src/rules/catalog.ts +257 -4
  231. package/src/rules/course-rules.ts +21 -1
  232. package/src/rules/in-effect.ts +54 -8
  233. package/src/session/PreferenceStore.ts +23 -0
  234. package/src/session/deck-memory.ts +263 -0
  235. package/src/session/reading-memory.ts +243 -0
  236. package/src/styles/deck.css +1150 -0
  237. package/src/styles/global.css +958 -27
  238. package/src/styles/zones.css +277 -0
  239. /package/src/{components → reference}/float-name.ts +0 -0
@@ -0,0 +1,604 @@
1
+ /**
2
+ * The deck rules (`docs/authoring/deck.md`): the `D` area and the `decks`
3
+ * invariant, over each deck read as frames (`deck-source.ts`). One pass per
4
+ * deck; the manifest runs it and drops the codes of a rule the course turned
5
+ * off, as for every gate.
6
+ *
7
+ * What it cannot see is the rendered fit of a frame (rule D2's
8
+ * `frame-overflow`), which the build measures in a browser after Astro has
9
+ * built the deck.
10
+ */
11
+ import type { MathGateDiagnostic } from './gate-math';
12
+ import { lessonLine } from './gate-prose-notation';
13
+ import { frameAt, type DeckFrameSource, type DeckPause, type DeckSource } from './deck-source';
14
+
15
+ /**
16
+ * What a chapter numbers, by kind: each key with its number, and an
17
+ * equation's LaTeX (for rule D8's retyped check). What a deck may recall.
18
+ */
19
+ export interface ChapterFloats {
20
+ readonly equations: ReadonlyMap<string, { readonly number: string; readonly latex?: string }>;
21
+ readonly figures: ReadonlyMap<string, string>;
22
+ readonly tables: ReadonlyMap<string, string>;
23
+ readonly diagrams: ReadonlyMap<string, string>;
24
+ }
25
+
26
+ /** A part, as its deck's rules see it (`<part>/index.deck.mdx`). */
27
+ export interface PartDeckFacts {
28
+ /** The part's directory: `motion`. */
29
+ readonly dir: string;
30
+ /**
31
+ * The part's chapters in reading order: each by its name in the part's
32
+ * directory (what `[chapter: <name>]` writes), with its lesson slug and
33
+ * what it numbers.
34
+ */
35
+ readonly chapters: readonly {
36
+ readonly name: string;
37
+ readonly slug: string;
38
+ readonly floats: ChapterFloats;
39
+ }[];
40
+ /** The part's lessons that are only slides, by name: they have no chapter to cover. */
41
+ readonly slidesFirst?: readonly string[];
42
+ }
43
+
44
+ /** One deck, as the manifest hands it to the rules. */
45
+ export interface DeckValidationInput {
46
+ /** Repository-relative path of the deck file. */
47
+ readonly file: string;
48
+ /** The deck's page id (`motion.range.slides`, the lesson's own, or a part's `motion.slides`). */
49
+ readonly pageId: string;
50
+ /** True when the lesson has a chapter beside the deck. */
51
+ readonly hasChapter: boolean;
52
+ /** True when the deck sits in a part's directory, as a lesson does. */
53
+ readonly inPart: boolean;
54
+ readonly body: string;
55
+ /** 1-based file line the body starts on. */
56
+ readonly bodyLine: number;
57
+ /** The deck file's frontmatter. */
58
+ readonly data: Readonly<Record<string, unknown>>;
59
+ readonly source: DeckSource;
60
+ /** What the deck may recall from its chapter, by kind. */
61
+ readonly chapter?: ChapterFloats;
62
+ /** A part's deck: its part, whose chapters its sections may cover. */
63
+ readonly part?: PartDeckFacts;
64
+ /** Per frame of {@link source}, the notation keys it shows for the first time in the deck. */
65
+ readonly newSymbols?: readonly (readonly string[])[];
66
+ }
67
+
68
+ /** One chapter a part deck's section covers. */
69
+ export interface CoveredChapter {
70
+ readonly name: string;
71
+ readonly slug: string;
72
+ readonly floats: ChapterFloats;
73
+ /** The section's place in {@link DeckSource.frames}: its divider. */
74
+ readonly position: number;
75
+ }
76
+
77
+ /** What a part's deck recalls, and from where (`resolvePartRecalls`). */
78
+ export interface PartRecalls {
79
+ /** The chapters its sections cover, in deck order, each once. */
80
+ readonly covered: readonly CoveredChapter[];
81
+ /** Each frame's chapter, by its place in {@link DeckSource.frames}: its section's, when that names one of the part. */
82
+ readonly frameChapters: readonly (CoveredChapter | undefined)[];
83
+ /** The chapter each recalled float is the chapter's own, by its ref (`eq-range`). */
84
+ readonly chapterOf: ReadonlyMap<string, CoveredChapter>;
85
+ /** Why a recall names nothing, by its offset in the deck body. */
86
+ readonly problems: ReadonlyMap<number, string>;
87
+ }
88
+
89
+ const RECALL_TABLE = { eq: 'equations', fig: 'figures', tab: 'tables', dia: 'diagrams' } as const;
90
+
91
+ /** The number a chapter gives the float `ref` names (`eq-range`, `fig-…`), if it numbers one. */
92
+ export function chapterNumberOf(ref: string, floats: ChapterFloats | undefined): string | undefined {
93
+ const match = RECALL_REF.exec(ref);
94
+ if (!match || floats === undefined) return undefined;
95
+ const [, kind, key] = match;
96
+ if (kind === 'eq') return floats.equations.get(key ?? '')?.number;
97
+ return floats[RECALL_TABLE[kind as 'fig' | 'tab' | 'dia']].get(key ?? '');
98
+ }
99
+
100
+ /**
101
+ * The chapters a part deck's sections cover: each a `## Title [chapter:
102
+ * <name>]` whose name is one of `chapters`, in deck order and once (a
103
+ * repeat is the same chapter), with the place of its divider in
104
+ * `source.frames`; and each frame's chapter, its section's. The divider's
105
+ * frame number is that place plus 2, after the title frame.
106
+ */
107
+ export function coveredSections(
108
+ source: DeckSource,
109
+ chapters: ReadonlySet<string>,
110
+ ): {
111
+ readonly covered: readonly { readonly name: string; readonly position: number }[];
112
+ readonly frameChapters: readonly (string | undefined)[];
113
+ } {
114
+ const covered: { name: string; position: number }[] = [];
115
+ const frameChapters: (string | undefined)[] = [];
116
+ let current: string | undefined;
117
+ source.frames.forEach((frame, position) => {
118
+ if (frame.kind === 'section') {
119
+ current = frame.chapter !== undefined && chapters.has(frame.chapter) ? frame.chapter : undefined;
120
+ if (current !== undefined && !covered.some((entry) => entry.name === current)) {
121
+ covered.push({ name: current, position });
122
+ }
123
+ }
124
+ frameChapters.push(current);
125
+ });
126
+ return { covered, frameChapters };
127
+ }
128
+
129
+ /**
130
+ * Where each recall of a part's deck comes from. A section that names a
131
+ * chapter covers it; a recall is the float of the chapter its section
132
+ * covers when that chapter numbers it, and otherwise of the one other
133
+ * covered chapter that does. Two that do, with neither the section's own,
134
+ * or a ref one frame recalls from one chapter and another from a second,
135
+ * name no float of their own: a deck page holds one float per anchor.
136
+ */
137
+ export function resolvePartRecalls(source: DeckSource, part: PartDeckFacts): PartRecalls {
138
+ const byName = new Map(part.chapters.map((chapter) => [chapter.name, chapter]));
139
+ const sections = coveredSections(source, new Set(byName.keys()));
140
+ const covered: CoveredChapter[] = sections.covered.map(({ name, position }) => ({ ...byName.get(name)!, position }));
141
+ const frameChapters = sections.frameChapters.map((name) =>
142
+ name === undefined ? undefined : covered.find((chapter) => chapter.name === name),
143
+ );
144
+
145
+ const chapterOf = new Map<string, CoveredChapter>();
146
+ const problems = new Map<number, string>();
147
+ source.frames.forEach((frame, position) => {
148
+ const own = frameChapters[position];
149
+ for (const recall of frame.recalls) {
150
+ if (recall.ref === undefined || !RECALL_REF.test(recall.ref)) continue;
151
+ const ref = recall.ref;
152
+ const numbering = covered.filter((chapter) => chapterNumberOf(ref, chapter.floats) !== undefined);
153
+ const from =
154
+ own !== undefined && numbering.includes(own) ? own : numbering.length === 1 ? numbering[0] : undefined;
155
+ const earlier = chapterOf.get(ref);
156
+ if (from === undefined) {
157
+ problems.set(
158
+ recall.index,
159
+ numbering.length === 0
160
+ ? covered.length === 0
161
+ ? `recalls \`${ref}\`, but no section of this deck names a chapter to recall from (\`## Title [chapter: <name>]\`)`
162
+ : `recalls \`${ref}\`, which no chapter this deck covers numbers (${covered.map((chapter) => chapter.slug).join(', ')})`
163
+ : `recalls \`${ref}\`, which ${numbering.map((chapter) => chapter.slug).join(' and ')} each number: ` +
164
+ `recall it in the section that names the chapter you mean`,
165
+ );
166
+ } else if (earlier !== undefined && earlier !== from) {
167
+ problems.set(
168
+ recall.index,
169
+ `recalls \`${ref}\` from ${from.slug}, where an earlier frame recalls it from ${earlier.slug}: ` +
170
+ 'a deck shows one float under one anchor',
171
+ );
172
+ } else {
173
+ chapterOf.set(ref, from);
174
+ }
175
+ }
176
+ });
177
+ return { covered, frameChapters, chapterOf, problems };
178
+ }
179
+
180
+ export interface DeckRuleSettings {
181
+ /** D2 */
182
+ readonly maxWords: number;
183
+ readonly maxBullets: number;
184
+ /** D5 */
185
+ readonly maxTitleLength: number;
186
+ /** D6 */
187
+ readonly maxSteps: number;
188
+ /** D9 */
189
+ readonly maxNewSymbols: number;
190
+ }
191
+
192
+ export const DECK_RULE_DEFAULTS: DeckRuleSettings = {
193
+ maxWords: 70,
194
+ maxBullets: 7,
195
+ maxTitleLength: 50,
196
+ maxSteps: 4,
197
+ maxNewSymbols: 4,
198
+ };
199
+
200
+ /** Frontmatter a lesson's chapter holds for the lesson; a deck beside it does not set it. */
201
+ const LESSON_FIELDS = ['teaches', 'assumptions', 'studyMinutes', 'notation'] as const;
202
+ const RECALL_REF = /^(eq|fig|tab|dia)-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
203
+ const NOTATION_KEY = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
204
+
205
+ /**
206
+ * A display equation's LaTeX as rule D8 compares it: no `\label` or
207
+ * `\caption`, each `\explain{key}{body}` as its body, no spacing commands, no
208
+ * `\left` / `\right`, no whitespace.
209
+ */
210
+ export function comparableLatex(latex: string): string {
211
+ let text = String(latex)
212
+ .replace(/\\label\{[^{}]*\}/g, '')
213
+ .replace(/\\caption\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
214
+ // `\explain{key}{body}` → `body`, innermost first, until none is left.
215
+ for (let guard = 0; guard < 20 && /\\explain\s*\{/.test(text); guard += 1) {
216
+ text = text.replace(/\\explain\s*\{[^{}]*\}\s*\{((?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*)\}/g, '$1');
217
+ }
218
+ return text
219
+ .replace(/\\(?:left|right|big|Big|bigg|Bigg)(?![A-Za-z])/g, '')
220
+ .replace(/\\(?:,|;|:|!|quad|qquad)(?![A-Za-z])/g, '')
221
+ .replace(/\s+/g, '');
222
+ }
223
+
224
+ function frameName(frame: DeckFrameSource): string {
225
+ return `${frame.kind === 'section' ? '##' : '###'} ${JSON.stringify(frame.title)}`;
226
+ }
227
+
228
+ export function validateDecks(
229
+ decks: readonly DeckValidationInput[],
230
+ settings: DeckRuleSettings = DECK_RULE_DEFAULTS,
231
+ ): MathGateDiagnostic[] {
232
+ const diagnostics: MathGateDiagnostic[] = [];
233
+ for (const deck of decks) {
234
+ const lineAt = (index: number) => lessonLine({ body: deck.body, bodyLine: deck.bodyLine }, index);
235
+ const push = (finding: Finding) => {
236
+ const { index, ...rest } = finding;
237
+ diagnostics.push({
238
+ context: 'structure',
239
+ file: deck.file,
240
+ ...(index === undefined ? {} : { line: lineAt(index) }),
241
+ ...rest,
242
+ });
243
+ };
244
+
245
+ const { source, part } = deck;
246
+ if (!deck.inPart) {
247
+ push({
248
+ code: 'deck-outside-part',
249
+ severity: 'error',
250
+ message:
251
+ part !== undefined
252
+ ? `${deck.file} is the deck of content/docs/${part.dir}/, which no part in course.settings.json \`parts\` lists. ` +
253
+ `A part's deck sits in a part's directory: add { "dir": "${part.dir}", "label": "…" } to \`parts\`, or move it.`
254
+ : `${deck.file} is a deck outside a part's directory. A deck is the slide edition of a lesson: ` +
255
+ "put it beside the lesson, `content/docs/<part>/<lesson>.deck.mdx`, or make it a part's, `content/docs/<part>/index.deck.mdx`.",
256
+ });
257
+ }
258
+ if (deck.hasChapter || part !== undefined) {
259
+ const set = LESSON_FIELDS.filter((field) => deck.data[field] !== undefined);
260
+ if (set.length > 0) {
261
+ push({
262
+ code: 'deck-frontmatter',
263
+ severity: 'error',
264
+ message:
265
+ part !== undefined
266
+ ? `${deck.file} sets ${set.map((field) => `\`${field}\``).join(', ')}. A part's deck is no lesson: it sets only ` +
267
+ "`title` and `description`, and shows its chapters' symbols and outcomes."
268
+ : `${deck.file} sets ${set.map((field) => `\`${field}\``).join(', ')}, which the lesson's chapter holds. ` +
269
+ 'A deck beside its chapter sets only `title` and `description`; move the rest into the chapter.',
270
+ });
271
+ }
272
+ }
273
+
274
+ // A part's deck: each `## Title [chapter: <name>]` names one chapter of
275
+ // the part. A chapter may run over several sections in a row, each
276
+ // naming it, the first one's divider its address in the deck; never two
277
+ // runs. Elsewhere the marker means nothing.
278
+ const partRecalls = part === undefined ? undefined : resolvePartRecalls(source, part);
279
+ const named = new Set<string>();
280
+ // The chapter the section before named: a run goes on while it is the same.
281
+ let previous: string | undefined;
282
+ for (const frame of source.frames) {
283
+ if (frame.kind !== 'section') continue;
284
+ const before = previous;
285
+ previous = frame.chapter;
286
+ if (frame.chapter === undefined) continue;
287
+ const chapter = frame.chapter;
288
+ if (part === undefined) {
289
+ push({
290
+ code: 'deck-chapter-outside-part',
291
+ severity: 'error',
292
+ message:
293
+ `${frameName(frame)} names the chapter \`${chapter}\`, but this is a lesson's deck, which covers its own lesson. ` +
294
+ "Only a part's deck (`content/docs/<part>/index.deck.mdx`) names the chapter a section covers; drop the `[chapter: …]` marker.",
295
+ index: frame.index,
296
+ token: chapter,
297
+ });
298
+ } else if (!part.chapters.some((entry) => entry.name === chapter)) {
299
+ push({
300
+ code: 'deck-chapter-unknown',
301
+ severity: 'error',
302
+ message: (part.slidesFirst ?? []).includes(chapter)
303
+ ? `${frameName(frame)} names \`${chapter}\`, a lesson of ${part.dir} taught as slides only: it has no chapter to cover. ` +
304
+ 'Drop the marker, or name a chapter of the part.'
305
+ : `${frameName(frame)} names \`${chapter}\`, which is no chapter of ${part.dir}. ` +
306
+ (part.chapters.length > 0
307
+ ? `Its chapters are ${part.chapters.map((entry) => `\`${entry.name}\``).join(', ')}: name one by its file's name.`
308
+ : 'The part has no chapter: drop the marker.'),
309
+ index: frame.index,
310
+ token: chapter,
311
+ });
312
+ } else if (named.has(chapter) && before !== chapter) {
313
+ push({
314
+ code: 'deck-chapter-repeated',
315
+ severity: 'error',
316
+ message:
317
+ `${frameName(frame)} names \`${chapter}\`, which an earlier section of the deck covers already, with a section of ` +
318
+ "another chapter or of the deck's own between them. A chapter's sections follow one another, the first one's divider " +
319
+ "the chapter's address in the deck: move this section up to the others.",
320
+ index: frame.index,
321
+ token: chapter,
322
+ });
323
+ } else {
324
+ named.add(chapter);
325
+ }
326
+ }
327
+ if (part !== undefined && [...part.chapters.map((entry) => entry.name), ...(part.slidesFirst ?? [])].includes('slides')) {
328
+ push({
329
+ code: 'deck-route-taken',
330
+ severity: 'error',
331
+ message:
332
+ `${deck.file} is served at /${part.dir}/slides/, which is the address of the lesson content/docs/${part.dir}/slides. ` +
333
+ "Rename the lesson's file, or the part keeps no deck of its own.",
334
+ });
335
+ }
336
+ for (const marker of source.misplacedMarkers) {
337
+ push({
338
+ code: 'deck-marker-misplaced',
339
+ severity: 'error',
340
+ message:
341
+ marker.marker === 'chapter'
342
+ ? `### ${JSON.stringify(marker.title)} carries \`[chapter: …]\`, which a section's \`##\` heading carries: ` +
343
+ 'it names the chapter the whole section covers. Move it to the section.'
344
+ : `## ${JSON.stringify(marker.title)} carries \`[morph]\`, which a frame's \`###\` heading carries: ` +
345
+ 'a section divider shows its title alone and has nothing to morph from. Drop it, or move it to the frame.',
346
+ index: marker.index,
347
+ token: marker.title,
348
+ });
349
+ }
350
+
351
+ const frames = source.frames.filter((frame) => frame.kind === 'frame');
352
+ if (frames.length === 0) {
353
+ push({
354
+ code: 'deck-empty',
355
+ severity: 'error',
356
+ message: `${deck.file} has no frame. A frame is a \`###\` heading and what follows it (rule D1).`,
357
+ });
358
+ }
359
+ for (const heading of source.deepHeadings) {
360
+ push({
361
+ code: 'frame-depth',
362
+ severity: 'error',
363
+ message:
364
+ `A ${'#'.repeat(heading.level)} heading ${JSON.stringify(heading.text)} in a deck (rule D1): ` +
365
+ '`##` opens a section and `###` is a frame; nothing else is a heading here. Make it a frame, or bold text in the frame.',
366
+ index: heading.index,
367
+ token: heading.text,
368
+ });
369
+ }
370
+ for (const loose of source.loose) {
371
+ const section = loose.where === 'section' ? frameAt(source, loose.index) : undefined;
372
+ push({
373
+ code: 'deck-loose-content',
374
+ severity: 'error',
375
+ message:
376
+ loose.where === 'before'
377
+ ? "Content before the deck's first heading belongs to no frame (rule D1). Put it in a `###` frame; the title frame is generated."
378
+ : `Content under ${section ? frameName(section) : 'a section heading'} before its first \`###\` frame (rule D1): ` +
379
+ 'a section is a divider that shows only its title. Put the content in a `###` frame.',
380
+ index: loose.index,
381
+ });
382
+ }
383
+
384
+ for (const [position, frame] of source.frames.entries()) {
385
+ if (frame.title.length > settings.maxTitleLength) {
386
+ push({
387
+ code: 'frame-title-length',
388
+ severity: 'error',
389
+ message:
390
+ `${frameName(frame)} is ${frame.title.length} characters, over the ${settings.maxTitleLength}-character ` +
391
+ 'limit (rule D5, `rules.D5.maxLength`): a frame title stays on one line. Shorten it and move the rest into the frame.',
392
+ index: frame.index,
393
+ token: frame.title,
394
+ });
395
+ }
396
+ if (frame.kind !== 'frame') continue;
397
+ checkFrame(frame, position);
398
+ }
399
+
400
+ function checkFrame(frame: DeckFrameSource, position: number): void {
401
+ if (frame.words > settings.maxWords || frame.bullets > settings.maxBullets) {
402
+ const over = [
403
+ frame.words > settings.maxWords ? `${frame.words} words (most ${settings.maxWords})` : '',
404
+ frame.bullets > settings.maxBullets ? `${frame.bullets} list items (most ${settings.maxBullets})` : '',
405
+ ].filter(Boolean);
406
+ push({
407
+ code: 'frame-dense',
408
+ severity: 'warning',
409
+ message:
410
+ `${frameName(frame)} shows ${over.join(' and ')} (rule D2): it will not fit its canvas. ` +
411
+ 'Split it into two frames, or move the argument into its `<Notes>`.',
412
+ index: frame.index,
413
+ token: frame.title,
414
+ });
415
+ }
416
+ if ((frame.words > 0 || frame.bullets > 0) && frame.notes.length === 0) {
417
+ push({
418
+ code: 'frame-notes-missing',
419
+ severity: 'warning',
420
+ message:
421
+ `${frameName(frame)} has text but no \`<Notes>\` (rule D4): write what the speaker says — the reasoning, ` +
422
+ 'the transition, the question to ask — in `<Notes>…</Notes>` on the frame.',
423
+ index: frame.index,
424
+ token: frame.title,
425
+ });
426
+ }
427
+ const steps = frame.pauses.length + 1;
428
+ if (steps > settings.maxSteps) {
429
+ push({
430
+ code: 'frame-steps',
431
+ severity: 'warning',
432
+ message:
433
+ `${frameName(frame)} shows in ${steps} steps, more than ${settings.maxSteps} (rule D6, \`rules.D6.maxSteps\`). ` +
434
+ 'Make the later steps a frame of their own.',
435
+ index: frame.index,
436
+ token: frame.title,
437
+ });
438
+ }
439
+
440
+ const pictures =
441
+ frame.floats.filter((float) => float.name === 'Figure' || float.name === 'Table' || float.name === 'Diagram').length +
442
+ frame.recalls.filter((recall) => recall.ref !== undefined && !recall.ref.startsWith('eq-')).length;
443
+ if (pictures > 1) {
444
+ push({
445
+ code: 'frame-floats',
446
+ severity: 'warning',
447
+ message:
448
+ `${frameName(frame)} holds ${pictures} figures, tables or diagrams (rule D7): one per frame, at the canvas width. ` +
449
+ 'Give the second its own frame.',
450
+ index: frame.index,
451
+ token: frame.title,
452
+ });
453
+ }
454
+
455
+ const drivesSomething =
456
+ frame.floats.some((float) => float.name === 'Figure') ||
457
+ frame.recalls.some((recall) => recall.ref?.startsWith('fig-') === true);
458
+ for (const pause of frame.pauses) {
459
+ const problems = pauseProblems(pause, drivesSomething);
460
+ if (problems.length > 0) {
461
+ push({
462
+ code: 'pause-invalid',
463
+ severity: 'error',
464
+ message: `<Pause> on ${frameName(frame)}: ${problems.join('; ')}. See docs/authoring/deck.md, "Steps and animation".`,
465
+ index: pause.index,
466
+ });
467
+ }
468
+ }
469
+
470
+ for (const recall of frame.recalls) {
471
+ const problem =
472
+ partRecalls === undefined
473
+ ? recallProblem(recall.ref, deck)
474
+ : (recallFormProblem(recall.ref) ?? partRecalls.problems.get(recall.index));
475
+ if (problem !== undefined) {
476
+ push({
477
+ code: 'recall-unresolved',
478
+ severity: 'error',
479
+ message:
480
+ `<Recall> on ${frameName(frame)} ${problem}. A recall names a labelled equation (\`eq-<key>\`) or a ` +
481
+ (partRecalls === undefined
482
+ ? "keyed `<Figure>`, `<Table>` or `<Diagram>` of the deck's own chapter."
483
+ : 'keyed `<Figure>`, `<Table>` or `<Diagram>` of a chapter a section of the deck covers.'),
484
+ index: recall.index,
485
+ ...(recall.ref === undefined ? {} : { token: recall.ref }),
486
+ });
487
+ }
488
+ }
489
+
490
+ // A question frame on a part's deck asks its section's chapter's checks.
491
+ if (
492
+ part !== undefined &&
493
+ frame.chapter === undefined &&
494
+ frame.floats.some((float) => float.name === 'AssessmentSet')
495
+ ) {
496
+ push({
497
+ code: 'deck-check-no-chapter',
498
+ severity: 'error',
499
+ message:
500
+ `${frameName(frame)} holds <AssessmentSet>, but its section names no chapter. On a part's deck a question ` +
501
+ "frame asks the checks of the chapter its section covers: move it into a `## Title [chapter: <name>]` section.",
502
+ index: frame.floats.find((float) => float.name === 'AssessmentSet')?.index ?? frame.index,
503
+ token: frame.title,
504
+ });
505
+ }
506
+
507
+ // Rule D8: on a part's deck, the equations of the chapters it covers.
508
+ const chapterEquations = (
509
+ partRecalls === undefined
510
+ ? [...(deck.chapter?.equations ?? new Map<string, { number: string; latex?: string }>())]
511
+ : partRecalls.covered.flatMap((chapter) => [...chapter.floats.equations])
512
+ ).filter(([, equation]) => equation.latex !== undefined);
513
+ if (chapterEquations.length > 0) {
514
+ for (const math of frame.displayMath) {
515
+ const mine = comparableLatex(math.latex);
516
+ if (mine === '') continue;
517
+ const same = chapterEquations.find(([, equation]) => comparableLatex(equation.latex ?? '') === mine);
518
+ if (same) {
519
+ push({
520
+ code: 'frame-retyped',
521
+ severity: 'warning',
522
+ message:
523
+ `${frameName(frame)} retypes the chapter's equation (${same[1].number}) (rule D8). ` +
524
+ `Write <Recall ref="eq-${same[0]}" /> instead: it shows the chapter's number and links to it.`,
525
+ index: math.index,
526
+ token: `eq-${same[0]}`,
527
+ });
528
+ }
529
+ }
530
+ }
531
+
532
+ const fresh = deck.newSymbols?.[position] ?? [];
533
+ if (fresh.length > settings.maxNewSymbols) {
534
+ push({
535
+ code: 'frame-new-symbols',
536
+ severity: 'info',
537
+ message:
538
+ `${frameName(frame)} introduces ${fresh.length} symbols the deck has not shown before ` +
539
+ `(${fresh.join(', ')}), more than ${settings.maxNewSymbols} (rule D9). Introduce some on an earlier frame.`,
540
+ index: frame.index,
541
+ token: frame.title,
542
+ });
543
+ }
544
+ }
545
+ }
546
+ return diagnostics;
547
+ }
548
+
549
+ interface Finding {
550
+ readonly code: string;
551
+ readonly severity: 'error' | 'warning' | 'info';
552
+ readonly message: string;
553
+ /** Offset in the deck body; the finding's line is worked out from it. */
554
+ readonly index?: number;
555
+ readonly token?: string;
556
+ }
557
+
558
+ /** What is wrong with a `<Pause>`'s props, if anything. */
559
+ function pauseProblems(pause: DeckPause, drivesSomething: boolean): string[] {
560
+ const problems: string[] = [];
561
+ if (pause.unknown.length > 0) {
562
+ problems.push(`takes no ${pause.unknown.map((name) => `\`${name}\``).join(', ')}`);
563
+ }
564
+ const drives =
565
+ pause.control !== undefined || pause.to !== undefined || pause.from !== undefined || pause.seconds !== undefined;
566
+ if (drives && pause.highlight !== undefined) {
567
+ problems.push('either drives a figure (`control`, `to`) or lights a symbol (`highlight`), not both');
568
+ }
569
+ if (drives && (pause.control === undefined || pause.control.trim() === '' || pause.to === undefined)) {
570
+ problems.push('drives a figure with both `control` (the label of the lab’s control) and `to`');
571
+ }
572
+ for (const name of ['from', 'to', 'seconds'] as const) {
573
+ const value = pause[name];
574
+ if (value !== undefined && !Number.isFinite(Number(value))) {
575
+ problems.push(`\`${name}="${value}"\` is not a number`);
576
+ }
577
+ }
578
+ if (pause.seconds !== undefined && Number(pause.seconds) <= 0) {
579
+ problems.push('`seconds` is more than zero');
580
+ }
581
+ if (pause.control !== undefined && !drivesSomething) {
582
+ problems.push('drives a lab control, but the frame has no `<Figure>` or recalled figure to drive');
583
+ }
584
+ if (pause.highlight !== undefined && !NOTATION_KEY.test(pause.highlight)) {
585
+ problems.push(`\`highlight="${pause.highlight}"\` is not a notation key`);
586
+ }
587
+ return problems;
588
+ }
589
+
590
+ /** What is wrong with a `<Recall ref>` as written, whatever it names, or `undefined`. */
591
+ function recallFormProblem(ref: string | undefined): string | undefined {
592
+ if (ref === undefined) return 'has no `ref`';
593
+ return RECALL_REF.test(ref) ? undefined : `\`ref="${ref}"\` is not \`eq-\`, \`fig-\`, \`tab-\` or \`dia-\` and a key`;
594
+ }
595
+
596
+ /** Why a `<Recall ref>` on a lesson's deck names nothing, or `undefined` when it resolves. */
597
+ function recallProblem(ref: string | undefined, deck: DeckValidationInput): string | undefined {
598
+ const form = recallFormProblem(ref);
599
+ if (form !== undefined || ref === undefined) return form;
600
+ if (!deck.hasChapter || deck.chapter === undefined) {
601
+ return `recalls \`${ref}\`, but this lesson has no chapter to recall from`;
602
+ }
603
+ return chapterNumberOf(ref, deck.chapter) === undefined ? `recalls \`${ref}\`, which the chapter does not number` : undefined;
604
+ }
@@ -199,7 +199,9 @@ export function layoutDiagram(
199
199
  //
200
200
  // The constants mirror `Diagram.astro`'s `.diagram-node-content`, set in px
201
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`.
202
+ // `line-height: 1.25`, `padding: 3.2px 8px`, `gap: 2.4px`. The page's text-size
203
+ // menu shrinks the finished drawing as a whole (`.diagram-frame` in
204
+ // `Diagram.astro`), so these stay the build-time numbers at every size.
203
205
 
204
206
  /** A node's text font size, in px. */
205
207
  export const NODE_FONT_PX = 13.6;