explico 0.13.1 → 0.15.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 (256) hide show
  1. package/CHANGELOG.md +990 -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 +100 -0
  6. package/dist/compiler/cli-main.js +433 -0
  7. package/dist/compiler/cli-reference.d.ts +50 -0
  8. package/dist/compiler/cli-reference.js +213 -0
  9. package/dist/compiler/cli-verbs.d.ts +45 -0
  10. package/dist/compiler/cli-verbs.js +224 -0
  11. package/dist/compiler/cli-verify.d.ts +51 -0
  12. package/dist/compiler/cli-verify.js +151 -0
  13. package/dist/compiler/cli.d.ts +408 -24
  14. package/dist/compiler/cli.js +1328 -8
  15. package/dist/compiler/collections.d.ts +88 -1
  16. package/dist/compiler/collections.js +170 -25
  17. package/dist/compiler/content-release.d.ts +49 -0
  18. package/dist/compiler/content-release.js +63 -0
  19. package/dist/compiler/content-schemas.d.ts +2 -2
  20. package/dist/compiler/course-config.d.ts +1 -1
  21. package/dist/compiler/course-config.js +105 -1
  22. package/dist/compiler/decks.d.ts +161 -0
  23. package/dist/compiler/decks.js +235 -0
  24. package/dist/compiler/exports/combined-html.d.ts +5 -1
  25. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  26. package/dist/compiler/exports/deck-fit.js +90 -0
  27. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  28. package/dist/compiler/exports/export-chapters.js +64 -5
  29. package/dist/compiler/exports/export-pages.d.ts +30 -3
  30. package/dist/compiler/exports/export-pages.js +24 -1
  31. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  32. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  33. package/dist/compiler/exports/latex/cache.js +129 -0
  34. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  35. package/dist/compiler/exports/latex/capture.js +3 -1
  36. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  37. package/dist/compiler/exports/latex/compile.js +12 -1
  38. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  39. package/dist/compiler/exports/latex/deck.js +349 -0
  40. package/dist/compiler/exports/latex/document.d.ts +51 -6
  41. package/dist/compiler/exports/latex/document.js +71 -12
  42. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  43. package/dist/compiler/exports/latex/explico.cls +13 -183
  44. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  45. package/dist/compiler/exports/latex/serialize.js +4 -2
  46. package/dist/compiler/exports/latex-export.d.ts +24 -2
  47. package/dist/compiler/exports/latex-export.js +107 -26
  48. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  49. package/dist/compiler/exports/markdown/serialize.js +85 -8
  50. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  51. package/dist/compiler/exports/markdown-export.js +50 -11
  52. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  53. package/dist/compiler/exports/standalone-html.js +3 -3
  54. package/dist/compiler/exports/static-tree.d.ts +38 -1
  55. package/dist/compiler/exports/static-tree.js +120 -4
  56. package/dist/compiler/knowledge-version.d.ts +26 -2
  57. package/dist/compiler/knowledge-version.js +27 -17
  58. package/dist/compiler/lesson-derivation.d.ts +143 -6
  59. package/dist/compiler/lesson-derivation.js +145 -13
  60. package/dist/compiler/manifest.d.ts +133 -4
  61. package/dist/compiler/manifest.js +364 -58
  62. package/dist/compiler/page-versions.d.ts +184 -0
  63. package/dist/compiler/page-versions.js +217 -0
  64. package/dist/compiler/render-diagnostics.d.ts +5 -1
  65. package/dist/compiler/resolution-report.d.ts +24 -6
  66. package/dist/compiler/resolution-report.js +22 -4
  67. package/dist/compiler/validate-content.d.ts +7 -0
  68. package/dist/compiler/validate-content.js +160 -0
  69. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  70. package/dist/curriculum/competency-graph.d.ts +54 -0
  71. package/dist/curriculum/competency-graph.js +41 -0
  72. package/dist/reference/base-library.d.mts +8 -0
  73. package/dist/reference/base-library.mjs +19 -0
  74. package/dist/reference/consistency.d.ts +8 -4
  75. package/dist/reference/consistency.js +82 -26
  76. package/dist/reference/deck-remark.d.mts +123 -0
  77. package/dist/reference/deck-remark.mjs +600 -0
  78. package/dist/reference/deck-source.d.ts +107 -0
  79. package/dist/reference/deck-source.js +140 -0
  80. package/dist/reference/decks-validate.d.ts +133 -0
  81. package/dist/reference/decks-validate.js +359 -0
  82. package/dist/reference/derived-entries.d.ts +5 -1
  83. package/dist/reference/diagram-fit.d.ts +6 -2
  84. package/dist/reference/diagram-fit.js +5 -1
  85. package/dist/reference/figure-symbols.d.ts +23 -6
  86. package/dist/reference/figure-symbols.js +51 -15
  87. package/dist/reference/float-name.d.ts +43 -0
  88. package/dist/reference/float-name.js +58 -0
  89. package/dist/reference/folds-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-indices.js +5 -4
  92. package/dist/reference/gate-math.d.ts +29 -2
  93. package/dist/reference/gate-math.js +99 -18
  94. package/dist/reference/gate-part-pages.d.ts +1 -1
  95. package/dist/reference/lab-katex-validate.d.ts +10 -6
  96. package/dist/reference/lab-katex-validate.js +17 -9
  97. package/dist/reference/lab-math-scope.js +29 -4
  98. package/dist/reference/math-glyphs.d.mts +25 -0
  99. package/dist/reference/math-glyphs.mjs +189 -25
  100. package/dist/reference/page-notation.d.ts +22 -3
  101. package/dist/reference/page-notation.js +32 -10
  102. package/dist/reference/references.d.ts +14 -1
  103. package/dist/reference/references.js +13 -0
  104. package/dist/reference/registry-scope.d.mts +33 -0
  105. package/dist/reference/registry-scope.mjs +34 -0
  106. package/dist/reference/registry.js +29 -10
  107. package/dist/reference/rehype-deck.d.mts +6 -0
  108. package/dist/reference/rehype-deck.mjs +286 -0
  109. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  110. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  111. package/dist/reference/remark-notation.mjs +249 -40
  112. package/dist/reference/source-lines.d.mts +12 -0
  113. package/dist/reference/source-lines.mjs +65 -0
  114. package/dist/reference/types.d.ts +28 -0
  115. package/dist/rules/catalog.d.ts +21 -4
  116. package/dist/rules/catalog.js +237 -2
  117. package/dist/rules/course-rules.d.ts +5 -1
  118. package/dist/rules/course-rules.js +17 -3
  119. package/dist/rules/course-rules.schema.json +12 -0
  120. package/dist/rules/in-effect.d.ts +13 -3
  121. package/dist/rules/in-effect.js +34 -10
  122. package/dist/rules/rules.schema.json +280 -1
  123. package/dist/rules/settings.d.ts +1 -1
  124. package/dist/session/PreferenceStore.d.ts +6 -0
  125. package/dist/session/PreferenceStore.js +18 -1
  126. package/dist/session/deck-memory.d.ts +116 -0
  127. package/dist/session/deck-memory.js +143 -0
  128. package/dist/session/reading-memory.d.ts +96 -0
  129. package/dist/session/reading-memory.js +133 -0
  130. package/docs/VERSIONING.md +21 -18
  131. package/docs/authoring/README.md +31 -9
  132. package/docs/authoring/agents.md +72 -18
  133. package/docs/authoring/cli-reference.json +2172 -0
  134. package/docs/authoring/cli.md +887 -0
  135. package/docs/authoring/components.md +15 -0
  136. package/docs/authoring/deck.md +612 -0
  137. package/docs/authoring/migrating.md +1195 -16
  138. package/docs/authoring/notation.md +49 -6
  139. package/docs/authoring/structure.md +80 -1
  140. package/docs/authoring/versioning.md +56 -3
  141. package/docs/authoring/visualizations.md +7 -2
  142. package/docs/course-agent/AGENTS.md +40 -0
  143. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  144. package/docs/course-agent/skills/draft-lesson/SKILL.md +38 -0
  145. package/docs/course-agent/skills/migrate-course/SKILL.md +32 -0
  146. package/docs/course-agent/skills/release-content/SKILL.md +26 -0
  147. package/docs/diagram-scaling.md +148 -0
  148. package/docs/privacy.md +59 -0
  149. package/package.json +8 -2
  150. package/src/compiler/astro.ts +60 -21
  151. package/src/compiler/cli-main.ts +557 -0
  152. package/src/compiler/cli-reference.ts +285 -0
  153. package/src/compiler/cli-verbs.ts +270 -0
  154. package/src/compiler/cli-verify.ts +241 -0
  155. package/src/compiler/cli.ts +2119 -170
  156. package/src/compiler/collections.ts +284 -25
  157. package/src/compiler/content-release.ts +91 -0
  158. package/src/compiler/course-config.ts +267 -0
  159. package/src/compiler/decks.ts +455 -0
  160. package/src/compiler/exports/deck-fit.ts +127 -0
  161. package/src/compiler/exports/export-chapters.ts +105 -6
  162. package/src/compiler/exports/export-pages.ts +46 -2
  163. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  164. package/src/compiler/exports/latex/cache.ts +183 -0
  165. package/src/compiler/exports/latex/capture.ts +5 -2
  166. package/src/compiler/exports/latex/compile.ts +18 -0
  167. package/src/compiler/exports/latex/deck.ts +419 -0
  168. package/src/compiler/exports/latex/document.ts +117 -21
  169. package/src/compiler/exports/latex/explico-common.sty +198 -0
  170. package/src/compiler/exports/latex/explico.cls +13 -183
  171. package/src/compiler/exports/latex/serialize.ts +25 -21
  172. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  173. package/src/compiler/exports/latex-export.ts +187 -45
  174. package/src/compiler/exports/markdown/serialize.ts +118 -9
  175. package/src/compiler/exports/markdown-export.ts +56 -11
  176. package/src/compiler/exports/standalone-html.ts +8 -7
  177. package/src/compiler/exports/static-tree.ts +175 -3
  178. package/src/compiler/knowledge-version.ts +56 -21
  179. package/src/compiler/lesson-derivation.ts +282 -16
  180. package/src/compiler/manifest.ts +568 -63
  181. package/src/compiler/page-versions.ts +401 -0
  182. package/src/compiler/resolution-report.ts +38 -7
  183. package/src/compiler/validate-content.ts +215 -0
  184. package/src/components/CourseContents.astro +124 -6
  185. package/src/components/CurriculumMap.astro +49 -1
  186. package/src/components/FloatIndex.astro +1 -1
  187. package/src/components/PartOverview.astro +51 -5
  188. package/src/components/assessments/AssessmentSet.astro +41 -15
  189. package/src/components/block-heading.ts +15 -6
  190. package/src/components/card-carry.ts +7 -4
  191. package/src/components/card-manager.ts +14 -2
  192. package/src/components/card-memory.ts +13 -9
  193. package/src/components/deck/DeckEnhancer.astro +13 -0
  194. package/src/components/deck/deck-context.ts +104 -0
  195. package/src/components/deck/deck-controller.ts +1287 -0
  196. package/src/components/deck/deck-map.ts +91 -0
  197. package/src/components/deck-lane.ts +163 -0
  198. package/src/components/diagram/Diagram.astro +131 -86
  199. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  200. package/src/components/figure/Figure.astro +7 -2
  201. package/src/components/figure/FloatCaption.astro +5 -3
  202. package/src/components/fold/Fold.astro +2 -2
  203. package/src/components/notation/NotationFormula.astro +81 -11
  204. package/src/components/notation/NotationGlossary.astro +132 -1
  205. package/src/components/notation/NotationLayer.astro +15 -7
  206. package/src/components/starlight/AboutPanel.astro +186 -3
  207. package/src/components/starlight/CollapsibleSections.astro +24 -3
  208. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  209. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  210. package/src/components/starlight/LayoutHeader.astro +17 -0
  211. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  212. package/src/components/starlight/LayoutSidebar.astro +807 -21
  213. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  214. package/src/components/starlight/LessonExportActions.astro +27 -9
  215. package/src/components/starlight/LessonFooter.astro +59 -0
  216. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  217. package/src/components/starlight/TextSizeControl.astro +24 -13
  218. package/src/components/starlight/page-marks.ts +139 -0
  219. package/src/components/starlight/section-summary.ts +1 -1
  220. package/src/components/starlight/sidebar-editions.ts +245 -0
  221. package/src/components/table/Table.astro +15 -7
  222. package/src/content-config.ts +87 -1
  223. package/src/curriculum/competency-graph.ts +95 -0
  224. package/src/reference/base-library.mjs +19 -0
  225. package/src/reference/consistency.ts +137 -39
  226. package/src/reference/deck-remark.mjs +600 -0
  227. package/src/reference/deck-source.ts +273 -0
  228. package/src/reference/decks-validate.ts +604 -0
  229. package/src/reference/diagram-fit.ts +18 -4
  230. package/src/reference/diagram-layout.ts +3 -1
  231. package/src/reference/figure-symbols.ts +108 -31
  232. package/src/reference/folds-validate.ts +1 -1
  233. package/src/reference/gate-indices.ts +5 -3
  234. package/src/reference/gate-math.ts +169 -25
  235. package/src/reference/lab-katex-validate.ts +30 -15
  236. package/src/reference/lab-math-scope.ts +34 -3
  237. package/src/reference/math-glyphs.mjs +189 -25
  238. package/src/reference/page-notation.ts +69 -15
  239. package/src/reference/references.ts +28 -0
  240. package/src/reference/registry-scope.mjs +34 -0
  241. package/src/reference/registry.ts +41 -11
  242. package/src/reference/rehype-deck.mjs +286 -0
  243. package/src/reference/rehype-heading-anchors.mjs +48 -6
  244. package/src/reference/remark-notation.mjs +249 -40
  245. package/src/reference/source-lines.mjs +65 -0
  246. package/src/reference/types.ts +28 -0
  247. package/src/rules/catalog.ts +274 -4
  248. package/src/rules/course-rules.ts +21 -1
  249. package/src/rules/in-effect.ts +58 -11
  250. package/src/session/PreferenceStore.ts +23 -0
  251. package/src/session/deck-memory.ts +263 -0
  252. package/src/session/reading-memory.ts +243 -0
  253. package/src/styles/deck.css +1150 -0
  254. package/src/styles/global.css +958 -27
  255. package/src/styles/zones.css +277 -0
  256. /package/src/{components → reference}/float-name.ts +0 -0
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The `content` CLI core (Phase G1).
3
3
  *
4
- * `scripts/content.ts` is a thin argv wrapper; every command's logic lives here
4
+ * `./cli-main` is the router (`runContentCli`), a course's `scripts/content.ts`
5
+ * a three-line shim over it; every command's logic lives here
5
6
  * as a pure-ish function that takes a compiled {@link Manifest} and returns a
6
7
  * plain data object. The router is **read-only** — only `content new` touches
7
8
  * the filesystem, and even then `scaffoldLesson` / `scaffoldTerm` only return
@@ -31,9 +32,9 @@
31
32
  */
32
33
 
33
34
  import { execFileSync } from 'node:child_process';
34
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
35
- import { join, relative } from 'node:path';
36
- import { pathToFileURL } from 'node:url';
35
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
36
+ import { basename, dirname, join, relative } from 'node:path';
37
+ import { fileURLToPath, pathToFileURL } from 'node:url';
37
38
 
38
39
  import {
39
40
  createMarkdownProcessor,
@@ -53,11 +54,27 @@ import remarkNotation, {
53
54
  } from '../reference/remark-notation.mjs';
54
55
  import remarkShortNote from '../reference/remark-short-note.mjs';
55
56
  import type { DiagnosticSeverity } from '../reference/types';
56
- import type { EquationLink } from '../reference/gate-math';
57
- import type { Manifest, ManifestLesson } from './manifest';
57
+ import {
58
+ explainCalls,
59
+ mathSpans,
60
+ redundantExplainWraps,
61
+ type EquationLink,
62
+ type ScopeEntry,
63
+ } from '../reference/gate-math';
64
+ import { resolveMathGlyphs } from '../reference/math-glyphs.mjs';
65
+ import { stripEquationLabels } from '../reference/equations.mjs';
66
+ import type { Manifest, ManifestLesson, ManifestLessonFloat } from './manifest';
58
67
  import { readRenderDiagnostics, type RenderDiagnostic } from './render-diagnostics';
59
- import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
60
- import { isCourseRuleId, ownerOf, ELEMENTS, type Element, type Enforcement } from '../rules/catalog';
68
+ import { isLessonSlug, lessonIdFromSlug, mergeTrackOrder } from './lesson-derivation';
69
+ import {
70
+ isCourseRuleId,
71
+ ownerOf,
72
+ retiredRule,
73
+ ELEMENTS,
74
+ type Element,
75
+ type Enforcement,
76
+ type RetiredRule,
77
+ } from '../rules/catalog';
61
78
  import {
62
79
  renderRulesInEffect,
63
80
  rulesInEffect,
@@ -65,6 +82,7 @@ import {
65
82
  type RulesFilter,
66
83
  } from '../rules/in-effect';
67
84
  import { RESERVED_KEY_PREFIXES, reservedKeyPrefix } from '../reference/references';
85
+ import { glyphMatcher } from '../reference/consistency';
68
86
 
69
87
  const REPOSITORY_ROOT = process.cwd();
70
88
  // Content and its course math are the top-level `content/` folder (Phase F).
@@ -82,7 +100,11 @@ export type ContentCliCode =
82
100
  | 'invalid-id'
83
101
  | 'exists'
84
102
  | 'not-a-lesson'
85
- | 'no-git';
103
+ | 'no-git'
104
+ | 'unknown-version'
105
+ | 'migration-gap'
106
+ | 'unknown-key'
107
+ | 'no-changelog';
86
108
 
87
109
  /** A user-facing failure with a stable code and no stack in `--json` output. */
88
110
  export class ContentCliError extends Error {
@@ -105,6 +127,8 @@ export interface CliDiagnostic {
105
127
  * which `tests/rules/catalog.test.ts` treats as a bug.
106
128
  */
107
129
  readonly rule?: string;
130
+ /** The owner's page and anchor under `docs/authoring/`, `notation.md#n4`. */
131
+ readonly doc?: string;
108
132
  readonly severity: DiagnosticSeverity;
109
133
  readonly message: string;
110
134
  readonly file?: string;
@@ -127,13 +151,33 @@ function diagnosticSeverity(value: unknown): DiagnosticSeverity {
127
151
  * `{ rule }` for a `<source>:<code>` (or bare) code the catalog owns — or for a
128
152
  * course rule, whose code is its own ID (`X1`).
129
153
  */
130
- function ownerFields(code: string): { readonly rule?: string } {
154
+ function ownerFields(code: string): { readonly rule?: string; readonly doc?: string } {
131
155
  const bare = code.slice(code.indexOf(':') + 1);
132
156
  const owner = ownerOf(bare);
133
- if (owner !== undefined) return { rule: owner.id };
157
+ if (owner !== undefined) return { rule: owner.id, doc: owner.doc };
134
158
  return isCourseRuleId(bare) ? { rule: bare } : {};
135
159
  }
136
160
 
161
+ /** ` [N4 · notation.md#n4]` for a diagnostic the catalog owns; empty otherwise. */
162
+ export function ownerTag(diagnostic: Pick<CliDiagnostic, 'rule' | 'doc'>): string {
163
+ if (!diagnostic.rule) return '';
164
+ return ` [${diagnostic.rule}${diagnostic.doc ? ` · ${diagnostic.doc}` : ''}]`;
165
+ }
166
+
167
+ /**
168
+ * One text line for a diagnostic, the shape every verb prints:
169
+ * `file:line:column: severity code [rule · doc]: message`. The location is
170
+ * left out when the diagnostic has no file, and reads `1:1` when the file is
171
+ * known but the position is not, so an editor opens the file. The rule and its page are on the
172
+ * line so a reader needs no second lookup to find what the code answers to.
173
+ */
174
+ export function formatCliDiagnostic(diagnostic: CliDiagnostic): string {
175
+ const location = diagnostic.file
176
+ ? `${diagnostic.file}:${diagnostic.line ?? 1}:${diagnostic.column ?? 1}: `
177
+ : '';
178
+ return `${location}${diagnostic.severity} ${diagnostic.code}${ownerTag(diagnostic)}: ${diagnostic.message}`;
179
+ }
180
+
137
181
  function withOwner(diagnostic: CliDiagnostic): CliDiagnostic {
138
182
  return { ...diagnostic, ...ownerFields(diagnostic.code) };
139
183
  }
@@ -165,7 +209,7 @@ function mentions(message: string, lessonId: string, file: string): boolean {
165
209
  */
166
210
  export function lessonDiagnostics(
167
211
  manifest: Manifest,
168
- lesson: ManifestLesson,
212
+ lesson: CheckTarget,
169
213
  rendered: readonly RenderDiagnostic[] = readRenderDiagnostics(),
170
214
  ): CliDiagnostic[] {
171
215
  const out: CliDiagnostic[] = [];
@@ -523,7 +567,7 @@ export function findLesson(manifest: Manifest, rawId: string): ManifestLesson {
523
567
  return lesson;
524
568
  }
525
569
 
526
- function lessonAbsolutePath(lesson: ManifestLesson): string {
570
+ function lessonAbsolutePath(lesson: Pick<ManifestLesson, 'file'>): string {
527
571
  return join(REPOSITORY_ROOT, lesson.file);
528
572
  }
529
573
 
@@ -628,6 +672,31 @@ function domainSignaturesForSection(section: string): DomainFileSignatures[] {
628
672
  export interface StatusReport {
629
673
  /** The course's published knowledge version (`about.version`). */
630
674
  readonly knowledgeVersion: string;
675
+ /**
676
+ * What an agent is told to read before working, in estimated tokens: the
677
+ * course's `AGENTS.md`, the repository's one level up when the course is a
678
+ * workspace package, and the rules-in-effect page. A budget to watch; the
679
+ * estimate is bytes over four.
680
+ */
681
+ readonly agentSurfaces: readonly { readonly name: string; readonly tokens: number }[];
682
+ /**
683
+ * Whether the course's own `AGENTS.md` points an agent at the engine's
684
+ * guide (`docs/authoring/agents.md`), and whether the procedures
685
+ * `content init-agent` installs are in `.claude/`.
686
+ */
687
+ readonly agentGuide: {
688
+ readonly agentsFile: string | null;
689
+ readonly linksGuide: boolean;
690
+ readonly skillsInstalled: boolean;
691
+ };
692
+ /**
693
+ * Rule C5: how many chapters and part pages carry a version of their own,
694
+ * and the slugs of those with a change `## [Unreleased]` has not released.
695
+ */
696
+ readonly pageVersions: {
697
+ readonly tracked: number;
698
+ readonly unreleased: readonly string[];
699
+ };
631
700
  readonly counts: {
632
701
  readonly lessons: number;
633
702
  readonly competencies: number;
@@ -649,6 +718,40 @@ export interface StatusReport {
649
718
  };
650
719
  }
651
720
 
721
+ /** The engine guide's file name, which a course's `AGENTS.md` names to point at it. */
722
+ const AGENT_GUIDE_MENTION = /docs\/authoring\/agents\.md/;
723
+
724
+ function agentGuideState(): StatusReport['agentGuide'] {
725
+ const agentsFile = ['AGENTS.md', 'CLAUDE.md'].find((name) => existsSync(join(REPOSITORY_ROOT, name))) ?? null;
726
+ const linksGuide =
727
+ agentsFile !== null && AGENT_GUIDE_MENTION.test(readFileSync(join(REPOSITORY_ROOT, agentsFile), 'utf8'));
728
+ const skillsInstalled = existsSync(join(REPOSITORY_ROOT, '.claude', 'skills', 'draft-lesson', 'SKILL.md'));
729
+ return { agentsFile, linksGuide, skillsInstalled };
730
+ }
731
+
732
+ function agentSurfaces(manifest: Manifest): { readonly name: string; readonly tokens: number }[] {
733
+ const surfaces: { name: string; tokens: number }[] = [];
734
+ for (const rel of ['AGENTS.md', join('..', 'AGENTS.md')]) {
735
+ const path = join(REPOSITORY_ROOT, rel);
736
+ if (!existsSync(path)) continue;
737
+ surfaces.push({ name: rel, tokens: estimateTokens(readFileSync(path, 'utf8')) });
738
+ }
739
+ surfaces.push({
740
+ name: 'rules in effect',
741
+ tokens: estimateTokens(renderRulesInEffect(manifest)),
742
+ });
743
+ // The migration an upgrade would read, when the pin and the install differ.
744
+ try {
745
+ const migration = contentMigrate();
746
+ if (migration.sections.length > 0) {
747
+ surfaces.push({ name: `migration ${migration.from} → ${migration.to}`, tokens: migration.tokens });
748
+ }
749
+ } catch {
750
+ // no pin, no install, or no guide: nothing to size
751
+ }
752
+ return surfaces;
753
+ }
754
+
652
755
  export function contentStatus(
653
756
  manifest: Manifest,
654
757
  rendered: readonly RenderDiagnostic[] = readRenderDiagnostics(),
@@ -710,8 +813,24 @@ export function contentStatus(
710
813
  manifest.lessons.flatMap((lesson) => lesson.assessments),
711
814
  ).size;
712
815
 
816
+ const trackedPages = [
817
+ ...manifest.lessons.map((lesson) => ({ slug: lesson.slug, since: lesson.since })),
818
+ ...manifest.parts.flatMap((part) =>
819
+ part.overview ? [{ slug: `${part.dir}/index`, since: part.overview.since }] : [],
820
+ ),
821
+ ];
822
+
713
823
  return {
714
824
  knowledgeVersion: manifest.about.knowledge.version,
825
+ agentSurfaces: agentSurfaces(manifest),
826
+ agentGuide: agentGuideState(),
827
+ pageVersions: {
828
+ tracked: manifest.pageVersions.enabled ? trackedPages.length : 0,
829
+ unreleased: trackedPages
830
+ .filter((page) => page.since === null)
831
+ .map((page) => page.slug)
832
+ .sort(),
833
+ },
715
834
  counts: {
716
835
  lessons: manifest.lessons.length,
717
836
  competencies: manifest.competencies.length,
@@ -745,6 +864,125 @@ export interface DiagnosticsReport {
745
864
  readonly diagnostics: readonly CliDiagnostic[];
746
865
  }
747
866
 
867
+ /**
868
+ * The diagnostics list folded to what an agent decides from: how many, of
869
+ * what, and where. The default text output, since a course can have hundreds
870
+ * of lines of two codes and the list is what `--all` is for.
871
+ */
872
+ export interface DiagnosticsSummary {
873
+ readonly minimumSeverity: DiagnosticThreshold;
874
+ readonly count: number;
875
+ readonly bySeverity: { readonly errors: number; readonly warnings: number; readonly infos: number };
876
+ /** One row per code and severity, most frequent first. */
877
+ readonly byCode: readonly {
878
+ readonly code: string;
879
+ readonly rule?: string;
880
+ readonly doc?: string;
881
+ readonly severity: DiagnosticSeverity;
882
+ readonly count: number;
883
+ }[];
884
+ /** One row per file, most diagnostics first; `(no file)` for the rest. */
885
+ readonly byFile: readonly {
886
+ readonly file: string;
887
+ readonly count: number;
888
+ readonly errors: number;
889
+ readonly warnings: number;
890
+ readonly infos: number;
891
+ }[];
892
+ }
893
+
894
+ function severityCounts(diagnostics: readonly CliDiagnostic[]) {
895
+ const counts = { errors: 0, warnings: 0, infos: 0 };
896
+ for (const diagnostic of diagnostics) {
897
+ if (diagnostic.severity === 'error') counts.errors += 1;
898
+ else if (diagnostic.severity === 'warning') counts.warnings += 1;
899
+ else counts.infos += 1;
900
+ }
901
+ return counts;
902
+ }
903
+
904
+ export function summarizeDiagnostics(report: DiagnosticsReport): DiagnosticsSummary {
905
+ const byCode = new Map<string, DiagnosticsSummary['byCode'][number]>();
906
+ const byFile = new Map<string, CliDiagnostic[]>();
907
+ for (const diagnostic of report.diagnostics) {
908
+ const codeKey = `${diagnostic.code}\u0000${diagnostic.severity}`;
909
+ const row = byCode.get(codeKey);
910
+ if (row) byCode.set(codeKey, { ...row, count: row.count + 1 });
911
+ else {
912
+ byCode.set(codeKey, {
913
+ code: diagnostic.code,
914
+ ...(diagnostic.rule === undefined ? {} : { rule: diagnostic.rule }),
915
+ ...(diagnostic.doc === undefined ? {} : { doc: diagnostic.doc }),
916
+ severity: diagnostic.severity,
917
+ count: 1,
918
+ });
919
+ }
920
+ const file = diagnostic.file ?? '(no file)';
921
+ byFile.set(file, [...(byFile.get(file) ?? []), diagnostic]);
922
+ }
923
+ const byCount = <T extends { count: number }>(key: (row: T) => string) => (a: T, b: T) =>
924
+ b.count - a.count || key(a).localeCompare(key(b));
925
+ return {
926
+ minimumSeverity: report.minimumSeverity,
927
+ count: report.count,
928
+ bySeverity: severityCounts(report.diagnostics),
929
+ byCode: [...byCode.values()].sort(byCount((row) => row.code)),
930
+ byFile: [...byFile]
931
+ .map(([file, list]) => ({ file, count: list.length, ...severityCounts(list) }))
932
+ .sort(byCount((row) => row.file)),
933
+ };
934
+ }
935
+
936
+ /** The diagnostics of one code (with or without its `source:` prefix) or of some files. */
937
+ export function filterDiagnostics(
938
+ report: DiagnosticsReport,
939
+ filter: { readonly code?: string; readonly files?: readonly string[] },
940
+ ): DiagnosticsReport {
941
+ const files = filter.files === undefined ? undefined : new Set(filter.files);
942
+ const diagnostics = report.diagnostics.filter((diagnostic) => {
943
+ if (
944
+ filter.code !== undefined &&
945
+ diagnostic.code !== filter.code &&
946
+ diagnostic.code.slice(diagnostic.code.indexOf(':') + 1) !== filter.code
947
+ ) {
948
+ return false;
949
+ }
950
+ return files === undefined || (diagnostic.file !== undefined && files.has(diagnostic.file));
951
+ });
952
+ return { minimumSeverity: report.minimumSeverity, count: diagnostics.length, diagnostics };
953
+ }
954
+
955
+ /** The summary as text: a severity line, then counts by code and by file, the file list capped. */
956
+ export function formatDiagnosticsSummary(
957
+ summary: DiagnosticsSummary,
958
+ options: { readonly fileCap?: number } = {},
959
+ ): string {
960
+ const fileCap = options.fileCap ?? 25;
961
+ const s = summary.bySeverity;
962
+ const lines = [
963
+ `${summary.count} diagnostic(s) at ${summary.minimumSeverity} or higher: ${s.errors} error(s), ${s.warnings} warning(s), ${s.infos} info`,
964
+ ];
965
+ if (summary.count === 0) return `${lines[0]}\n`;
966
+ lines.push('by code');
967
+ for (const row of summary.byCode) {
968
+ lines.push(` ${String(row.count).padStart(5)} ${row.severity.padEnd(7)} ${row.code}${ownerTag(row)}`);
969
+ }
970
+ lines.push('by file');
971
+ for (const row of summary.byFile.slice(0, fileCap)) {
972
+ const parts = [
973
+ row.errors > 0 ? `${row.errors} error(s)` : '',
974
+ row.warnings > 0 ? `${row.warnings} warning(s)` : '',
975
+ row.infos > 0 ? `${row.infos} info` : '',
976
+ ].filter((part) => part !== '');
977
+ lines.push(` ${String(row.count).padStart(5)} ${row.file} (${parts.join(', ')})`);
978
+ }
979
+ if (summary.byFile.length > fileCap) {
980
+ lines.push(` … and ${summary.byFile.length - fileCap} more file(s)`);
981
+ }
982
+ lines.push('List them: --all for every line; --code <code>, --lesson <id> or --changed for a part.');
983
+ return `${lines.join('\n')}\n`;
984
+ }
985
+
748
986
  export interface WarningsReport {
749
987
  readonly count: number;
750
988
  readonly warnings: readonly CliDiagnostic[];
@@ -899,8 +1137,20 @@ export interface RulesReport {
899
1137
  export function parseRulesFilter(input: {
900
1138
  readonly element?: string;
901
1139
  readonly enforcement?: string;
1140
+ /** `chapter` (the default page) or `deck` (`<lesson>.deck.mdx`). */
1141
+ readonly edition?: string;
902
1142
  }): RulesFilter {
903
- const filter: { elements?: Element[]; enforcement?: Enforcement | 'locked' } = {};
1143
+ const filter: {
1144
+ elements?: Element[];
1145
+ enforcement?: Enforcement | 'locked';
1146
+ edition?: 'chapter' | 'deck';
1147
+ } = {};
1148
+ if (input.edition !== undefined) {
1149
+ if (input.edition !== 'chapter' && input.edition !== 'deck') {
1150
+ throw new ContentCliError('usage', `--edition ${input.edition}: one of chapter, deck.`);
1151
+ }
1152
+ filter.edition = input.edition;
1153
+ }
904
1154
  if (input.element !== undefined) {
905
1155
  const elements = input.element.split(',').map((e) => e.trim()).filter(Boolean);
906
1156
  for (const element of elements) {
@@ -949,6 +1199,8 @@ export interface ContextReport {
949
1199
  readonly teaches: readonly string[];
950
1200
  readonly requires: readonly string[];
951
1201
  readonly assumptions: readonly string[];
1202
+ /** The version the page's content last changed in; `null` for a change not yet released (rule C5). */
1203
+ readonly since: string | null;
952
1204
  readonly notationLocal: unknown;
953
1205
  readonly body: string;
954
1206
  };
@@ -962,10 +1214,10 @@ export interface ContextReport {
962
1214
  units?: string;
963
1215
  formula?: string;
964
1216
  }[];
965
- readonly equations: readonly { key: string; number: string }[];
966
- readonly tables: readonly { key: string; number: string }[];
967
- readonly figures: readonly { key: string; number: string }[];
968
- readonly diagrams: readonly { key: string; number: string }[];
1217
+ readonly equations: readonly ManifestLessonFloat[];
1218
+ readonly tables: readonly ManifestLessonFloat[];
1219
+ readonly figures: readonly ManifestLessonFloat[];
1220
+ readonly diagrams: readonly ManifestLessonFloat[];
969
1221
  /** Worked-example sets: `short` is the title `[[ex-key]]` names. */
970
1222
  readonly examples: readonly { key: string; number: string; short?: string }[];
971
1223
  readonly knowledgeChecks: readonly { key: string; number: string; title?: string }[];
@@ -988,179 +1240,1602 @@ export interface ContextReport {
988
1240
  readonly tokens: TokenCounts;
989
1241
  }
990
1242
 
991
- export function contentContext(
992
- manifest: Manifest,
993
- rawId: string,
994
- ): ContextReport {
995
- const lesson = findLesson(manifest, rawId);
996
- const { frontmatter, body } = lessonRaw(lesson);
997
-
998
- const outcomeById = new Map(
999
- manifest.competencies.map((competency) => [
1000
- String(competency.id),
1001
- String(competency.outcome ?? ''),
1002
- ]),
1003
- );
1004
- const outcomesFor = (ids: readonly string[]) =>
1005
- ids
1006
- .filter((id) => outcomeById.has(id))
1007
- .map((id) => ({ id, outcome: outcomeById.get(id) ?? '' }));
1243
+ // --- content migrate -------------------------------------------------
1008
1244
 
1009
- const sourceById = new Map(
1010
- manifest.sources.map((source) => [String(source.id), source]),
1011
- );
1012
- const citedLocatorsById = new Map<string, string[]>();
1013
- for (const citation of lesson.citations) {
1014
- const list = citedLocatorsById.get(citation.id) ?? [];
1015
- if (citation.locator) list.push(citation.locator);
1016
- citedLocatorsById.set(citation.id, list);
1017
- }
1245
+ /*
1246
+ * The migration guide, cut to the sections an upgrade needs: from the
1247
+ * version a course pins to the version it has installed, oldest first. The
1248
+ * guide is one newest-first file of every release; an upgrade reads one or
1249
+ * two sections of it, and the heading grammar `## <from> → <to>` (a `0.4.x`
1250
+ * range on the left, `Unreleased` allowed on the right) says which.
1251
+ */
1252
+ export interface MigrationSection {
1253
+ readonly from: string;
1254
+ readonly to: string;
1255
+ readonly heading: string;
1256
+ readonly markdown: string;
1257
+ readonly tokens: number;
1258
+ }
1018
1259
 
1019
- const contextWithoutTokens = {
1020
- lesson: {
1021
- id: lesson.id,
1022
- slug: lesson.slug,
1023
- file: lesson.file,
1024
- title: lesson.title,
1025
- description: lesson.description,
1026
- teaches: lesson.teaches,
1027
- requires: lesson.requires,
1028
- assumptions: lesson.assumptions,
1029
- notationLocal:
1030
- (frontmatter.notation as { local?: unknown } | undefined)?.local ?? [],
1031
- body,
1032
- },
1033
- taughtOutcomes: outcomesFor(lesson.teaches),
1034
- prerequisiteOutcomes: outcomesFor(lesson.requires),
1035
- notation: lesson.notation.definitions.map((definition) => ({
1036
- key: definition.key,
1037
- latex: definition.notation,
1038
- meaning: definition.summary,
1039
- scope: definition.scope,
1040
- ...(definition.units === undefined ? {} : { units: definition.units }),
1041
- ...(definition.formula === undefined
1042
- ? {}
1043
- : { formula: definition.formula }),
1044
- })),
1045
- equations: lesson.equations.map((equation) => ({ ...equation })),
1046
- tables: lesson.tables.map((table) => ({ ...table })),
1047
- figures: lesson.figures.map((figure) => ({ ...figure })),
1048
- diagrams: lesson.diagrams.map((diagram) => ({ ...diagram })),
1049
- examples: lesson.examples.map((example) => ({ ...example })),
1050
- knowledgeChecks: lesson.knowledgeChecks.map((check) => ({ ...check })),
1051
- sections: lesson.headings.map((heading) => ({ ...heading })),
1052
- sources: lesson.sources.map((id) => {
1053
- const record = sourceById.get(id) as Record<string, unknown> | undefined;
1054
- return {
1055
- id,
1056
- title: String(record?.title ?? id),
1057
- type: String(record?.type ?? 'unknown'),
1058
- ...(typeof record?.locator === 'string'
1059
- ? { recordLocator: record.locator }
1060
- : {}),
1061
- citedLocators: citedLocatorsById.get(id) ?? [],
1062
- };
1063
- }),
1064
- domainSignatures: domainSignaturesForSection(
1065
- lesson.slug.split('/')[0] ?? '',
1066
- ),
1067
- diagnostics: lessonDiagnostics(manifest, lesson),
1068
- };
1260
+ export interface MigrateReport {
1261
+ readonly from: string;
1262
+ readonly to: string;
1263
+ readonly file: string;
1264
+ readonly sections: readonly MigrationSection[];
1265
+ readonly tokens: number;
1266
+ }
1069
1267
 
1070
- return {
1071
- ...contextWithoutTokens,
1072
- tokens: tokenCounts(contextWithoutTokens),
1073
- };
1268
+ const MIGRATION_HEADING = /^## ([^\n→]+?) → ([^\n]+?)[\t ]*$/;
1269
+
1270
+ /** Every `## <from> → <to>` section of the guide, in file order (newest first). */
1271
+ export function parseMigrationSections(markdown: string): MigrationSection[] {
1272
+ const lines = markdown.split('\n');
1273
+ const starts = lines
1274
+ .map((line, index) => ({ index, match: MIGRATION_HEADING.exec(line) }))
1275
+ .filter((entry): entry is { index: number; match: RegExpExecArray } => entry.match !== null);
1276
+ return starts.map(({ index, match }, position) => {
1277
+ // A section runs to the next `## ` heading of any kind, so the trailing
1278
+ // changelog-style block ends the last one.
1279
+ let end = lines.findIndex((line, i) => i > index && line.startsWith('## '));
1280
+ if (end < 0) end = lines.length;
1281
+ const body = lines.slice(index, end);
1282
+ while (body.length > 0 && /^(---)?\s*$/.test(body[body.length - 1]!)) body.pop();
1283
+ const text = body.join('\n');
1284
+ return {
1285
+ from: match[1]!.trim(),
1286
+ to: match[2]!.trim(),
1287
+ heading: lines[index]!,
1288
+ markdown: text,
1289
+ tokens: estimateTokens(text),
1290
+ ...(position === starts.length ? {} : {}),
1291
+ };
1292
+ });
1074
1293
  }
1075
1294
 
1076
- // --- content check ---------------------------------------------------
1295
+ function versionParts(version: string): number[] | undefined {
1296
+ if (version === 'Unreleased') return undefined;
1297
+ const parts = version.split('.').map((part) => (part === 'x' ? -1 : Number(part)));
1298
+ return parts.every((part) => Number.isInteger(part)) ? parts : undefined;
1299
+ }
1077
1300
 
1078
- export interface CheckLessonResult {
1079
- readonly lesson: string;
1080
- readonly file: string;
1081
- readonly ok: boolean;
1082
- readonly rendered: boolean;
1083
- readonly diagnostics: readonly CliDiagnostic[];
1301
+ /** `0.4.x` matches any `0.4.*`; otherwise the versions are equal. */
1302
+ function versionMatches(pattern: string, version: string): boolean {
1303
+ if (pattern === version) return true;
1304
+ const p = versionParts(pattern);
1305
+ const v = versionParts(version);
1306
+ if (!p || !v) return false;
1307
+ return p.every((part, index) => part === -1 || part === v[index]);
1084
1308
  }
1085
1309
 
1086
- export interface CheckReport {
1087
- readonly ok: boolean;
1088
- readonly lessons: readonly CheckLessonResult[];
1310
+ function compareVersions(a: string, b: string): number {
1311
+ if (a === b) return 0;
1312
+ if (a === 'Unreleased') return 1;
1313
+ if (b === 'Unreleased') return -1;
1314
+ const pa = versionParts(a) ?? [];
1315
+ const pb = versionParts(b) ?? [];
1316
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
1317
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
1318
+ if (d !== 0) return d;
1319
+ }
1320
+ return 0;
1089
1321
  }
1090
1322
 
1091
- async function checkOneLesson(
1092
- manifest: Manifest,
1093
- lesson: ManifestLesson,
1094
- ): Promise<CheckLessonResult> {
1095
- const manifestDiagnostics = lessonDiagnostics(manifest, lesson);
1096
- const { frontmatter, body } = lessonSource(
1097
- readFileSync(lessonAbsolutePath(lesson), 'utf8'),
1098
- );
1099
- const render = await renderLesson(manifest, {
1100
- body,
1101
- frontmatter,
1102
- path: lessonAbsolutePath(lesson),
1103
- });
1323
+ /**
1324
+ * The sections that take a course from `from` to `to`, oldest first: each
1325
+ * section's left side matches where the previous one ended. A hole in the
1326
+ * chain is an error naming the version no section continues from, since a
1327
+ * skipped section is a skipped migration.
1328
+ */
1329
+ export function selectMigrationPath(
1330
+ sections: readonly MigrationSection[],
1331
+ from: string,
1332
+ to: string,
1333
+ ): MigrationSection[] {
1334
+ if (compareVersions(from, to) > 0) {
1335
+ throw new ContentCliError(
1336
+ 'unknown-version',
1337
+ `--from ${from} is newer than --to ${to}; the guide only goes forward.`,
1338
+ );
1339
+ }
1340
+ const path: MigrationSection[] = [];
1341
+ let cursor = from;
1342
+ while (compareVersions(cursor, to) < 0) {
1343
+ const next = sections
1344
+ .filter((section) => versionMatches(section.from, cursor) && compareVersions(section.to, to) <= 0)
1345
+ .sort((a, b) => compareVersions(a.to, b.to))[0];
1346
+ if (!next) {
1347
+ throw new ContentCliError(
1348
+ 'migration-gap',
1349
+ `No section continues from ${cursor} towards ${to}. Sections known: ${sections
1350
+ .map((section) => `${section.from} → ${section.to}`)
1351
+ .join(', ')}.`,
1352
+ );
1353
+ }
1354
+ path.push(next);
1355
+ cursor = next.to;
1356
+ }
1357
+ return path;
1358
+ }
1104
1359
 
1105
- const diagnostics = [...manifestDiagnostics, ...render.diagnostics].sort(
1106
- (a, b) =>
1107
- a.code.localeCompare(b.code) || a.message.localeCompare(b.message),
1108
- );
1109
- return {
1110
- lesson: lesson.id,
1111
- file: lesson.file,
1112
- ok: !diagnostics.some((diagnostic) => diagnostic.severity === 'error'),
1113
- rendered: render.rendered,
1114
- diagnostics,
1115
- };
1360
+ /** The engine version the course has installed: the package beside it, or this one. */
1361
+ export function installedEngineVersion(): string | undefined {
1362
+ for (const path of [
1363
+ join(REPOSITORY_ROOT, 'node_modules', 'explico', 'package.json'),
1364
+ join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'),
1365
+ join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
1366
+ ]) {
1367
+ if (!existsSync(path)) continue;
1368
+ try {
1369
+ const pkg = JSON.parse(readFileSync(path, 'utf8')) as { name?: string; version?: string };
1370
+ if (pkg.name === 'explico' && typeof pkg.version === 'string') return pkg.version;
1371
+ } catch {
1372
+ // not this one
1373
+ }
1374
+ }
1375
+ return undefined;
1116
1376
  }
1117
1377
 
1118
- /** `.mdx` / `.checks.yml` paths that differ from `HEAD`, tracked or not. */
1119
- export function changedLessonIds(): string[] {
1120
- let output: string;
1378
+ /**
1379
+ * The engine version `package.json` pins at HEAD — what the course last
1380
+ * migrated to, before the working tree bumped it. `workspace:*` or a range
1381
+ * is no version, so the caller asks for `--from`.
1382
+ */
1383
+ export function pinnedEngineVersion(): string | undefined {
1384
+ let text: string;
1121
1385
  try {
1122
- const tracked = execFileSync(
1123
- 'git',
1124
- ['diff', '--name-only', 'HEAD', '--', 'content/docs'],
1125
- { cwd: REPOSITORY_ROOT, encoding: 'utf8' },
1126
- );
1127
- const untracked = execFileSync(
1128
- 'git',
1129
- ['ls-files', '--others', '--exclude-standard', '--', 'content/docs'],
1130
- { cwd: REPOSITORY_ROOT, encoding: 'utf8' },
1131
- );
1132
- output = `${tracked}\n${untracked}`;
1386
+ text = execFileSync('git', ['show', 'HEAD:package.json'], {
1387
+ cwd: REPOSITORY_ROOT,
1388
+ encoding: 'utf8',
1389
+ stdio: ['ignore', 'pipe', 'ignore'],
1390
+ });
1391
+ } catch {
1392
+ return undefined;
1393
+ }
1394
+ try {
1395
+ const pkg = JSON.parse(text) as Record<string, Record<string, string> | undefined>;
1396
+ const pinned = pkg.dependencies?.explico ?? pkg.devDependencies?.explico;
1397
+ if (typeof pinned !== 'string') return undefined;
1398
+ const exact = pinned.replace(/^[\^~=v]/, '');
1399
+ return versionParts(exact) ? exact : undefined;
1133
1400
  } catch {
1401
+ return undefined;
1402
+ }
1403
+ }
1404
+
1405
+ export function contentMigrate(options: { readonly from?: string; readonly to?: string } = {}): MigrateReport {
1406
+ const dir = authoringDocsDir();
1407
+ const file = dir === undefined ? undefined : join(dir, 'migrating.md');
1408
+ if (file === undefined || !existsSync(file)) {
1409
+ throw new ContentCliError('unknown-version', 'No migrating.md beside the engine (node_modules/explico/docs/authoring).');
1410
+ }
1411
+ const to = options.to ?? installedEngineVersion();
1412
+ if (to === undefined) {
1413
+ throw new ContentCliError('unknown-version', 'Could not read the installed engine version; pass --to <version>.');
1414
+ }
1415
+ const from = options.from ?? pinnedEngineVersion();
1416
+ if (from === undefined) {
1134
1417
  throw new ContentCliError(
1135
- 'no-git',
1136
- 'Could not read git status for --changed. Run inside a git working tree.',
1418
+ 'unknown-version',
1419
+ 'package.json at HEAD pins no exact explico version (a workspace link or a range); pass --from <version>.',
1137
1420
  );
1138
1421
  }
1422
+ const sections = selectMigrationPath(parseMigrationSections(readFileSync(file, 'utf8')), from, to);
1423
+ return {
1424
+ from,
1425
+ to,
1426
+ file: join('docs', 'authoring', 'migrating.md'),
1427
+ sections,
1428
+ tokens: sections.reduce((n, section) => n + section.tokens, 0),
1429
+ };
1430
+ }
1139
1431
 
1140
- const ids = new Set<string>();
1141
- for (const line of output.split('\n')) {
1142
- const path = line.trim();
1143
- if (!path) continue;
1144
- const slug = path
1145
- .replace(/^content\/docs\//, '')
1146
- .replace(/\.checks\.yml$/, '.mdx')
1147
- .replace(/\.mdx?$/, '');
1148
- if (!isLessonSlug(slug)) continue;
1149
- ids.add(slug.replace(/\//g, '.'));
1432
+ /** The sections, oldest first, under one line that says how much is coming. */
1433
+ export function formatMigrateReport(report: MigrateReport): string {
1434
+ if (report.sections.length === 0) {
1435
+ return `Already at ${report.to}: nothing to migrate from ${report.from}.\n`;
1150
1436
  }
1151
- return [...ids].sort();
1437
+ const head =
1438
+ `Migrating ${report.from} → ${report.to}: ${report.sections.length} section(s) of ${report.file}, ` +
1439
+ `≈${report.tokens} tokens, oldest first. Apply every one; each ends with the checks to run.\n`;
1440
+ return `${head}\n${report.sections.map((section) => section.markdown).join('\n\n---\n\n')}\n`;
1152
1441
  }
1153
1442
 
1154
- export async function contentCheck(
1155
- manifest: Manifest,
1156
- options: {
1157
- lesson?: string;
1158
- /** Several lessons, each checked; `lesson` is the one-lesson form. */
1159
- lessons?: readonly string[];
1160
- changed?: boolean;
1161
- },
1162
- ): Promise<CheckReport> {
1163
- let targets: ManifestLesson[];
1443
+ // --- content bindings ------------------------------------------------
1444
+
1445
+ /*
1446
+ * What a lesson's math binds, span by span, before a build says so: each
1447
+ * glyph and the card it resolves to, how (`canonical`, `family`, `derived`,
1448
+ * `base`, `operator`, or an explicit `\explain` wrap), the wraps the bare
1449
+ * symbol would have resolved without, and every unresolved glyph with where
1450
+ * `content symbol` finds it defined. The same resolver and scope the
1451
+ * completeness gate uses, so what it prints is what the build will do.
1452
+ */
1453
+ export interface SpanBinding {
1454
+ readonly token: string;
1455
+ readonly key: string;
1456
+ /** The resolver's match kind; `explicit` for an `\explain` wrap. */
1457
+ readonly how: string;
1458
+ }
1459
+
1460
+ export interface ExplicitWrap {
1461
+ readonly key: string;
1462
+ readonly inner: string;
1463
+ /** The bare `inner` resolves to exactly this card: the wrap costs tokens and binds nothing new. */
1464
+ readonly redundant: boolean;
1465
+ }
1466
+
1467
+ export interface UnresolvedGlyph {
1468
+ readonly token: string;
1469
+ readonly suspended?: string;
1470
+ /** Rule N23: indexed entries whose base this bare letter is; none lends it a card. */
1471
+ readonly lenders?: readonly string[];
1472
+ /** Where `content symbol` finds the token defined: exact hits, then near misses. */
1473
+ readonly defined: readonly { readonly tier: SymbolTier; readonly key: string; readonly where: string }[];
1474
+ readonly near: readonly { readonly latex: string; readonly key: string }[];
1475
+ }
1476
+
1477
+ export interface SpanBindings {
1478
+ readonly bindings: readonly SpanBinding[];
1479
+ readonly wraps: readonly ExplicitWrap[];
1480
+ readonly unresolved: readonly Omit<UnresolvedGlyph, 'defined' | 'near'>[];
1481
+ /** The resolver rejected the span (a KaTeX parse error or a rule it enforces, N13 say); its message. */
1482
+ readonly error?: string;
1483
+ }
1484
+
1485
+ export interface BindingsSpan extends Omit<SpanBindings, 'unresolved'> {
1486
+ readonly line: number;
1487
+ readonly column: number;
1488
+ readonly display: boolean;
1489
+ readonly latex: string;
1490
+ readonly unresolved: readonly UnresolvedGlyph[];
1491
+ }
1492
+
1493
+ export interface BindingsReport {
1494
+ readonly lesson: string;
1495
+ readonly file: string;
1496
+ readonly scope: readonly { readonly key: string; readonly notation: string; readonly scope: 'shared' | 'local' }[];
1497
+ readonly spans: readonly BindingsSpan[];
1498
+ readonly counts: {
1499
+ readonly spans: number;
1500
+ readonly bindings: number;
1501
+ readonly unresolved: number;
1502
+ readonly wraps: number;
1503
+ readonly redundantWraps: number;
1504
+ readonly errors: number;
1505
+ };
1506
+ }
1507
+
1508
+ /**
1509
+ * One span against a scope. Exported for tests and for a caller with a span
1510
+ * of its own (`content equation`).
1511
+ */
1512
+ export function spanBindings(
1513
+ latex: string,
1514
+ scope: readonly ScopeEntry[],
1515
+ options: { readonly suspended?: readonly string[]; readonly derive?: boolean } = {},
1516
+ ): SpanBindings {
1517
+ // D7: `\label{eq:…}` and `\caption{…}` are identity markers, not math KaTeX
1518
+ // parses; the gate strips them the same way.
1519
+ const source = stripEquationLabels(latex);
1520
+ const resolve = (expression: string) =>
1521
+ resolveMathGlyphs(expression, [...scope], {
1522
+ suspended: options.suspended ?? [],
1523
+ derive: options.derive ?? true,
1524
+ }) as {
1525
+ bindings: readonly { key: string; level: string; match: string; token: string }[];
1526
+ unresolved: readonly { token: string; suspended?: string; lenders?: readonly string[] }[];
1527
+ /** Rule N22: a decoration with neither a whole-form card nor an operator entry is unexplained. */
1528
+ decorations: readonly { wholeForm: string | null; operator: string | null }[];
1529
+ };
1530
+ let result: ReturnType<typeof resolve>;
1531
+ try {
1532
+ result = resolve(source);
1533
+ } catch (error) {
1534
+ return { bindings: [], wraps: [], unresolved: [], error: error instanceof Error ? error.message : String(error) };
1535
+ }
1536
+ // The gate's own rule for a wrap that binds nothing new (`explain-redundant`).
1537
+ const redundant = new Set(
1538
+ redundantExplainWraps(source, scope, { suspended: options.suspended ?? [], derive: options.derive ?? true }).map(
1539
+ (wrap) => wrap.index,
1540
+ ),
1541
+ );
1542
+ const wraps: ExplicitWrap[] = explainCalls(source).map((call) => ({
1543
+ key: call.key,
1544
+ inner: call.inner,
1545
+ redundant: redundant.has(call.index),
1546
+ }));
1547
+ return {
1548
+ bindings: result.bindings.map((binding) => ({
1549
+ token: binding.token,
1550
+ key: binding.key,
1551
+ how: binding.level === 'explicit' ? 'explicit' : binding.match,
1552
+ })),
1553
+ wraps,
1554
+ unresolved: result.unresolved.map((item) => ({
1555
+ token: item.token,
1556
+ ...(item.suspended === undefined ? {} : { suspended: item.suspended }),
1557
+ ...(item.lenders === undefined || item.lenders.length === 0 ? {} : { lenders: [...item.lenders] }),
1558
+ })),
1559
+ };
1560
+ }
1561
+
1562
+ /**
1563
+ * The glyph table a lesson's math resolves against — its bundle: the
1564
+ * `notation.local` entries and the shared cards it introduces — and the
1565
+ * decorations it suspends (rule N22). The same table the completeness gate
1566
+ * builds, read from the manifest instead of rebuilt.
1567
+ */
1568
+ function lessonScope(
1569
+ manifest: Manifest,
1570
+ lesson: ManifestLesson,
1571
+ frontmatter: Record<string, unknown>,
1572
+ ): { readonly records: readonly DefinitionRecord[]; readonly scope: ScopeEntry[]; readonly suspended: string[] } {
1573
+ const ids = new Set(lesson.notation.definitionIds);
1574
+ const records = manifest.notation.definitions.filter((definition) => ids.has(definition.id));
1575
+ const scope: ScopeEntry[] = records.map((definition) => ({
1576
+ key: definition.key,
1577
+ notation: definition.notation,
1578
+ ...(definition.fixedIndex ? { fixedIndex: true as const } : {}),
1579
+ }));
1580
+ const suspend = (frontmatter.notation as { suspend?: { operator?: unknown }[] } | undefined)?.suspend ?? [];
1581
+ const suspended = suspend
1582
+ .map((entry) => entry.operator)
1583
+ .filter((operator): operator is string => typeof operator === 'string');
1584
+ return { records, scope, suspended };
1585
+ }
1586
+
1587
+ /** Where `content symbol` finds a token defined, for an unresolved glyph; one lookup per token. */
1588
+ function symbolLookup(manifest: Manifest) {
1589
+ const lookups = new Map<string, Pick<UnresolvedGlyph, 'defined' | 'near'>>();
1590
+ return (token: string) => {
1591
+ const cached = lookups.get(token);
1592
+ if (cached) return cached;
1593
+ const report = contentSymbol(manifest, token);
1594
+ const found = {
1595
+ defined: [...report.shared, ...report.local, ...report.glosses].map((hit) => ({
1596
+ tier: hit.tier,
1597
+ key: hit.key,
1598
+ where: hit.where,
1599
+ })),
1600
+ near: report.contains.slice(0, 3).map((hit) => ({ latex: hit.latex, key: hit.key })),
1601
+ };
1602
+ lookups.set(token, found);
1603
+ return found;
1604
+ };
1605
+ }
1606
+
1607
+ export function contentBindings(manifest: Manifest, rawId: string): BindingsReport {
1608
+ const lesson = findLesson(manifest, rawId);
1609
+ const { frontmatter, body } = lessonRaw(lesson);
1610
+ const text = readFileSync(lessonAbsolutePath(lesson), 'utf8');
1611
+ const bodyStart = Math.max(0, text.indexOf(body));
1612
+ const bodyLine = text.slice(0, bodyStart).split('\n').length;
1613
+
1614
+ const { records: scopeRecords, scope, suspended } = lessonScope(manifest, lesson, frontmatter);
1615
+ const lookup = symbolLookup(manifest);
1616
+
1617
+ const spans: BindingsSpan[] = mathSpans(body).map((span) => {
1618
+ const resolved = spanBindings(span.latex, scope, { suspended, derive: true });
1619
+ return {
1620
+ line: bodyLine + span.line - 1,
1621
+ column: span.column,
1622
+ display: body.slice(Math.max(0, span.offset - 2), span.offset) === '$$',
1623
+ latex: span.latex,
1624
+ bindings: resolved.bindings,
1625
+ wraps: resolved.wraps,
1626
+ unresolved: resolved.unresolved.map((item) => ({ ...item, ...lookup(item.token) })),
1627
+ ...(resolved.error === undefined ? {} : { error: resolved.error }),
1628
+ };
1629
+ });
1630
+ spans.sort((a, b) => a.line - b.line || a.column - b.column);
1631
+
1632
+ return {
1633
+ lesson: lesson.id,
1634
+ file: lesson.file,
1635
+ scope: scopeRecords
1636
+ .map((definition) => ({ key: definition.key, notation: definition.notation, scope: definition.kind }))
1637
+ .sort((a, b) => a.key.localeCompare(b.key)),
1638
+ spans,
1639
+ counts: {
1640
+ spans: spans.length,
1641
+ bindings: spans.reduce((n, span) => n + span.bindings.length, 0),
1642
+ unresolved: spans.reduce((n, span) => n + span.unresolved.length, 0),
1643
+ wraps: spans.reduce((n, span) => n + span.wraps.length, 0),
1644
+ redundantWraps: spans.reduce((n, span) => n + span.wraps.filter((wrap) => wrap.redundant).length, 0),
1645
+ errors: spans.filter((span) => span.error !== undefined).length,
1646
+ },
1647
+ };
1648
+ }
1649
+
1650
+ function latexPreview(latex: string, width = 44): string {
1651
+ const flat = latex.replace(/\s+/g, ' ').trim();
1652
+ return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat;
1653
+ }
1654
+
1655
+ /** The text form: a counts line, the scope, then one line per span. */
1656
+ export function formatBindingsReport(report: BindingsReport): string {
1657
+ const c = report.counts;
1658
+ const lines = [
1659
+ `${report.lesson} — ${report.file}`,
1660
+ `${c.spans} math span(s) · ${c.bindings} binding(s) · ${c.unresolved} unresolved · ${c.wraps} explicit wrap(s)` +
1661
+ (c.wraps > 0 ? `, ${c.redundantWraps} redundant (the bare symbol resolves to the same card)` : '') +
1662
+ (c.errors > 0 ? ` · ${c.errors} span(s) the resolver rejects` : ''),
1663
+ `scope (${report.scope.length}): ${report.scope
1664
+ .map((entry) => `${entry.key} ${entry.notation}${entry.scope === 'local' ? ' (local)' : ''}`)
1665
+ .join(', ') || '(none)'}`,
1666
+ ];
1667
+ for (const span of report.spans) {
1668
+ const where = `L${span.line}:${span.column}`.padEnd(9);
1669
+ const preview = (span.display ? '$$' : '$') + latexPreview(span.latex) + (span.display ? '$$' : '$');
1670
+ const parts: string[] = [];
1671
+ if (span.error !== undefined) parts.push(`REJECTED ${span.error}`);
1672
+ for (const binding of span.bindings) {
1673
+ const explicit = binding.how === 'explicit';
1674
+ const wrap = explicit ? span.wraps.find((w) => w.key === binding.key) : undefined;
1675
+ const note = explicit
1676
+ ? ` [wrap${wrap?.redundant ? ', redundant' : ''}]`
1677
+ : binding.how === 'canonical'
1678
+ ? ''
1679
+ : ` [${binding.how}]`;
1680
+ parts.push(`${binding.token}${explicit ? '⇒' : '→'}${binding.key}${note}`);
1681
+ }
1682
+ for (const item of span.unresolved) {
1683
+ const defined =
1684
+ item.defined.length > 0
1685
+ ? ` — defined: ${item.defined.map((d) => `${d.key} (${d.tier}, ${d.where})`).join('; ')}`
1686
+ : item.near.length > 0
1687
+ ? ` — not defined; near: ${item.near.map((n) => `${n.latex} ${n.key}`).join(', ')}`
1688
+ : ' — not defined anywhere';
1689
+ const why = item.suspended ? ` (suspended ${item.suspended})` : item.lenders ? ` (indexed entries ${item.lenders.join(', ')} lend no card to the bare letter)` : '';
1690
+ parts.push(`${item.token} UNRESOLVED${why}${defined}`);
1691
+ }
1692
+ lines.push(`${where} ${preview.padEnd(48)} ${parts.join(' · ') || '(nothing to bind)'}`);
1693
+ }
1694
+ if (report.spans.length === 0) lines.push('(no math)');
1695
+ return `${lines.join('\n')}\n`;
1696
+ }
1697
+
1698
+ // --- content equation ------------------------------------------------
1699
+
1700
+ /**
1701
+ * One LaTeX string, resolved the way the gate will: against a lesson's
1702
+ * scope, or against every shared card when no lesson is named (the table a
1703
+ * notation entry's body and formula use). The inner loop while drafting:
1704
+ * a few lines out, non-zero exit when the build would refuse the span.
1705
+ */
1706
+ export interface EquationReport extends Omit<SpanBindings, 'unresolved'> {
1707
+ readonly latex: string;
1708
+ readonly against: { readonly kind: 'lesson'; readonly lesson: string; readonly cards: number } | { readonly kind: 'shared'; readonly cards: number };
1709
+ readonly unresolved: readonly UnresolvedGlyph[];
1710
+ readonly ok: boolean;
1711
+ }
1712
+
1713
+ export function contentEquation(
1714
+ manifest: Manifest,
1715
+ latex: string,
1716
+ options: { readonly lesson?: string } = {},
1717
+ ): EquationReport {
1718
+ const lookup = symbolLookup(manifest);
1719
+ let resolved: SpanBindings;
1720
+ let against: EquationReport['against'];
1721
+ if (options.lesson !== undefined) {
1722
+ const lesson = findLesson(manifest, options.lesson);
1723
+ const { frontmatter } = lessonRaw(lesson);
1724
+ const { scope, suspended } = lessonScope(manifest, lesson, frontmatter);
1725
+ resolved = spanBindings(latex, scope, { suspended, derive: true });
1726
+ against = { kind: 'lesson', lesson: lesson.id, cards: scope.length };
1727
+ } else {
1728
+ const scope: ScopeEntry[] = manifest.notation.definitions
1729
+ .filter((definition) => definition.kind === 'shared')
1730
+ .map((definition) => ({
1731
+ key: definition.key,
1732
+ notation: definition.notation,
1733
+ ...(definition.fixedIndex ? { fixedIndex: true as const } : {}),
1734
+ }));
1735
+ resolved = spanBindings(latex, scope, { suspended: [], derive: false });
1736
+ against = { kind: 'shared', cards: scope.length };
1737
+ }
1738
+ const unresolved = resolved.unresolved.map((item) => ({ ...item, ...lookup(item.token) }));
1739
+ return {
1740
+ latex,
1741
+ against,
1742
+ bindings: resolved.bindings,
1743
+ wraps: resolved.wraps,
1744
+ unresolved,
1745
+ ...(resolved.error === undefined ? {} : { error: resolved.error }),
1746
+ ok: resolved.error === undefined && unresolved.length === 0,
1747
+ };
1748
+ }
1749
+
1750
+ export function formatEquationReport(report: EquationReport): string {
1751
+ const against =
1752
+ report.against.kind === 'lesson'
1753
+ ? `${report.against.lesson}'s scope (${report.against.cards} cards)`
1754
+ : `every shared card (${report.against.cards})`;
1755
+ const lines = [`$${latexPreview(report.latex, 80)}$ against ${against}`];
1756
+ if (report.error !== undefined) lines.push(` REJECTED ${report.error}`);
1757
+ if (report.bindings.length > 0) {
1758
+ lines.push(
1759
+ ` ${report.bindings
1760
+ .map((binding) => {
1761
+ const explicit = binding.how === 'explicit';
1762
+ const wrap = explicit ? report.wraps.find((w) => w.key === binding.key) : undefined;
1763
+ const note = explicit ? ` [wrap${wrap?.redundant ? ', redundant' : ''}]` : binding.how === 'canonical' ? '' : ` [${binding.how}]`;
1764
+ return `${binding.token}${explicit ? '⇒' : '→'}${binding.key}${note}`;
1765
+ })
1766
+ .join(' · ')}`,
1767
+ );
1768
+ }
1769
+ for (const item of report.unresolved) {
1770
+ const defined =
1771
+ item.defined.length > 0
1772
+ ? ` — defined: ${item.defined.map((d) => `${d.key} (${d.tier}, ${d.where})`).join('; ')}`
1773
+ : item.near.length > 0
1774
+ ? ` — not defined; near: ${item.near.map((n) => `${n.latex} ${n.key}`).join(', ')}`
1775
+ : ' — not defined anywhere';
1776
+ const why = item.suspended ? ` (suspended ${item.suspended})` : item.lenders ? ` (indexed entries ${item.lenders.join(', ')} lend no card to the bare letter)` : '';
1777
+ lines.push(` ${item.token} UNRESOLVED${why}${defined}`);
1778
+ }
1779
+ lines.push(
1780
+ report.ok
1781
+ ? 'ok: every glyph binds.'
1782
+ : report.error !== undefined
1783
+ ? 'The build would reject this span.'
1784
+ : `${report.unresolved.length} unresolved glyph(s): the build would fail on this span.`,
1785
+ );
1786
+ return `${lines.join('\n')}\n`;
1787
+ }
1788
+
1789
+ // --- content move / rename / fix: edits as a plan ----------------------
1790
+
1791
+ /*
1792
+ * The refactors a model gets wrong most and reads most for, as deterministic
1793
+ * edits: every one is planned first (`FileEdit`s with the whole text before
1794
+ * and after), shown as a unified diff, and written only on `--write`.
1795
+ */
1796
+ export interface FileEdit {
1797
+ /** Course-relative path. */
1798
+ readonly file: string;
1799
+ /** `null` when the file is created. */
1800
+ readonly before: string | null;
1801
+ /** `null` when the file is deleted. */
1802
+ readonly after: string | null;
1803
+ }
1804
+
1805
+ export interface EditPlan {
1806
+ readonly summary: readonly string[];
1807
+ readonly edits: readonly FileEdit[];
1808
+ /** What to run after writing. */
1809
+ readonly next: readonly string[];
1810
+ }
1811
+
1812
+ const KEY_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
1813
+
1814
+ function assertNotationKey(key: string): void {
1815
+ if (!KEY_PATTERN.test(key)) {
1816
+ throw new ContentCliError('invalid-id', `A notation key is lower-case, dot/hyphen separated, got ${JSON.stringify(key)}.`);
1817
+ }
1818
+ const reserved = reservedKeyPrefix(key);
1819
+ if (reserved !== undefined) {
1820
+ throw new ContentCliError(
1821
+ 'invalid-id',
1822
+ `A notation key may not start with ${RESERVED_KEY_PREFIXES.join(', ')}: [[${key}]] would be read as a reference to that kind of float.`,
1823
+ );
1824
+ }
1825
+ }
1826
+
1827
+ function escapeRegExp(text: string): string {
1828
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1829
+ }
1830
+
1831
+ /** `[[old]]` and `\explain{old}` become `new`; nothing else is touched. Returns the count. */
1832
+ export function rewriteKeyReferences(text: string, oldKey: string, newKey: string): { text: string; count: number } {
1833
+ const k = escapeRegExp(oldKey);
1834
+ let count = 0;
1835
+ const out = text
1836
+ .replace(new RegExp(`\\[\\[\\s*${k}\\s*\\]\\]`, 'g'), () => {
1837
+ count += 1;
1838
+ return `[[${newKey}]]`;
1839
+ })
1840
+ .replace(new RegExp(`\\\\explain(\\s*)\\{${k}\\}`, 'g'), (_m, space: string) => {
1841
+ count += 1;
1842
+ return `\\explain${space}{${newKey}}`;
1843
+ });
1844
+ return { text: out, count };
1845
+ }
1846
+
1847
+ /** A `seeAlso:` item or a lint-ignore detail naming the key, in a notation file or lint-ignore.yml. */
1848
+ function rewriteKeyMentions(text: string, oldKey: string, newKey: string): { text: string; count: number } {
1849
+ const k = escapeRegExp(oldKey);
1850
+ let count = 0;
1851
+ const out = text
1852
+ .replace(new RegExp(`^(\\s*-\\s*)${k}[\\t ]*$`, 'gm'), (_m, lead: string) => {
1853
+ count += 1;
1854
+ return `${lead}${newKey}`;
1855
+ })
1856
+ .replace(new RegExp(`^(\\s*-\\s*[a-z-]+:)${k}(\\s+—)`, 'gm'), (_m, lead: string, tail: string) => {
1857
+ count += 1;
1858
+ return `${lead}${newKey}${tail}`;
1859
+ });
1860
+ return { text: out, count };
1861
+ }
1862
+
1863
+ /**
1864
+ * Remove one `notation.local` item from a lesson file's text, by key, keeping
1865
+ * the rest of the frontmatter byte for byte. An item runs from its `- key:`
1866
+ * line to the next line indented no deeper than the dash; an emptied list
1867
+ * becomes `local: []`.
1868
+ */
1869
+ export function removeLocalItem(text: string, key: string): { text: string; removed: boolean } {
1870
+ const lines = text.split('\n');
1871
+ const start = lines.findIndex((line) => new RegExp(`^(\\s*)-\\s*key:\\s*${escapeRegExp(key)}\\s*$`).test(line));
1872
+ if (start < 0) return { text, removed: false };
1873
+ const indent = /^(\s*)-/.exec(lines[start]!)![1]!.length;
1874
+ let end = start + 1;
1875
+ while (end < lines.length) {
1876
+ const line = lines[end]!;
1877
+ if (line.trim() !== '' && (line.length - line.trimStart().length) <= indent) break;
1878
+ if (line === '---') break;
1879
+ end += 1;
1880
+ }
1881
+ // Trailing blank lines inside the item belong to whatever follows.
1882
+ while (end > start + 1 && lines[end - 1]!.trim() === '') end -= 1;
1883
+ lines.splice(start, end - start);
1884
+ // The list the item sat in: the nearest `local:` above with a smaller indent.
1885
+ let listLine = start - 1;
1886
+ while (listLine >= 0 && !/^\s*local:\s*$/.test(lines[listLine]!)) listLine -= 1;
1887
+ if (listLine >= 0) {
1888
+ const listIndent = lines[listLine]!.length - lines[listLine]!.trimStart().length;
1889
+ let probe = listLine + 1;
1890
+ while (probe < lines.length && lines[probe]!.trim() === '') probe += 1;
1891
+ const nextIndent = probe < lines.length ? lines[probe]!.length - lines[probe]!.trimStart().length : 0;
1892
+ if (probe >= lines.length || lines[probe] === '---' || nextIndent <= listIndent) {
1893
+ lines[listLine] = `${' '.repeat(listIndent)}local: []`;
1894
+ }
1895
+ }
1896
+ return { text: lines.join('\n'), removed: true };
1897
+ }
1898
+
1899
+ function yamlScalar(value: unknown): string {
1900
+ if (typeof value === 'string') {
1901
+ return /^[A-Za-z0-9][A-Za-z0-9 ,.()/%^_-]*$/.test(value) && !/^(true|false|null|yes|no|on|off)$/i.test(value) && !/^\d/.test(value)
1902
+ ? value
1903
+ : JSON.stringify(value);
1904
+ }
1905
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
1906
+ return JSON.stringify(value);
1907
+ }
1908
+
1909
+ function yamlBlock(value: unknown, indent: number): string[] {
1910
+ const pad = ' '.repeat(indent);
1911
+ if (Array.isArray(value)) {
1912
+ if (value.length === 0) return [`${pad}[]`];
1913
+ return value.flatMap((item) => {
1914
+ if (item !== null && typeof item === 'object') {
1915
+ const inner = yamlBlock(item, indent + 2);
1916
+ return [`${pad}- ${inner[0]!.trimStart()}`, ...inner.slice(1)];
1917
+ }
1918
+ return [`${pad}- ${yamlScalar(item)}`];
1919
+ });
1920
+ }
1921
+ if (value !== null && typeof value === 'object') {
1922
+ return Object.entries(value as Record<string, unknown>).flatMap(([k, v]) => {
1923
+ if (v === undefined) return [];
1924
+ if (Array.isArray(v) && v.length === 0) return [`${pad}${k}: []`];
1925
+ if (v !== null && typeof v === 'object') return [`${pad}${k}:`, ...yamlBlock(v, indent + 2)];
1926
+ return [`${pad}${k}: ${yamlScalar(v)}`];
1927
+ });
1928
+ }
1929
+ return [`${pad}${yamlScalar(value)}`];
1930
+ }
1931
+
1932
+ /**
1933
+ * A shared card's file from a page-local entry's fields, nothing invented: no
1934
+ * body (the glossary shows the meaning; what a reader needs beyond it is the
1935
+ * author's to write, and a placeholder would publish), and the label only
1936
+ * when the entry has one. `domain` is the caller's pick, else the key's
1937
+ * prefix, else `general`.
1938
+ */
1939
+ export function sharedCardFromLocal(
1940
+ local: Record<string, unknown>,
1941
+ options: { readonly key: string; readonly fromLesson: string; readonly domain?: string },
1942
+ ): string {
1943
+ const key = options.key;
1944
+ const domain = options.domain ?? (key.includes('.') ? key.slice(0, key.indexOf('.')) : 'general');
1945
+ const front: Record<string, unknown> = {
1946
+ key,
1947
+ latex: local.latex,
1948
+ ...(local.label === undefined ? {} : { label: local.label }),
1949
+ meaning: local.meaning,
1950
+ ...(local.formula === undefined ? {} : { formula: local.formula }),
1951
+ domain,
1952
+ ...(local.units === undefined ? {} : { units: local.units }),
1953
+ ...(local.dimensionless === undefined ? {} : { dimensionless: local.dimensionless }),
1954
+ ...(local.range === undefined ? {} : { range: local.range }),
1955
+ ...(local.fixedIndex === undefined ? {} : { fixedIndex: local.fixedIndex }),
1956
+ ...(local.reads === undefined ? {} : { reads: local.reads }),
1957
+ sources: local.sources ?? [],
1958
+ seeAlso: local.seeAlso ?? [],
1959
+ ...(local.glosses === undefined ? {} : { glosses: local.glosses }),
1960
+ alignment: local.alignment ?? { kind: 'general', rationale: `Promoted from ${options.fromLesson}'s page-local entry.` },
1961
+ aiAssisted: true,
1962
+ };
1963
+ return `---\n${yamlBlock(front, 0).join('\n')}\n---\n`;
1964
+ }
1965
+
1966
+ /**
1967
+ * The domain a card promoted under `key` belongs to: the key's prefix when the
1968
+ * course already has that domain, else the one most of the lesson's shared
1969
+ * cards have, else the course's most common, else the prefix or `general`.
1970
+ */
1971
+ function promotedDomain(manifest: Manifest, lesson: ManifestLesson, key: string): string {
1972
+ type Shared = Extract<Manifest['notation']['definitions'][number], { kind: 'shared' }>;
1973
+ const shared = manifest.notation.definitions.filter((definition): definition is Shared => definition.kind === 'shared');
1974
+ const prefix = key.includes('.') ? key.slice(0, key.indexOf('.')) : undefined;
1975
+ if (prefix !== undefined && shared.some((definition) => definition.domain === prefix)) return prefix;
1976
+ const mostCommon = (definitions: typeof shared): string | undefined => {
1977
+ const counts = new Map<string, number>();
1978
+ for (const definition of definitions) {
1979
+ if (definition.domain) counts.set(definition.domain, (counts.get(definition.domain) ?? 0) + 1);
1980
+ }
1981
+ return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
1982
+ };
1983
+ const bound = new Set(lesson.notation.definitionIds);
1984
+ return mostCommon(shared.filter((definition) => bound.has(definition.id))) ?? mostCommon(shared) ?? prefix ?? 'general';
1985
+ }
1986
+
1987
+ function courseTextFiles(): string[] {
1988
+ const out: string[] = [];
1989
+ const walk = (dir: string) => {
1990
+ for (const entry of readdirSync(join(REPOSITORY_ROOT, dir), { withFileTypes: true })) {
1991
+ const rel = `${dir}/${entry.name}`;
1992
+ if (entry.isDirectory()) walk(rel);
1993
+ else if (/\.(mdx?|ya?ml)$/.test(entry.name)) out.push(rel);
1994
+ }
1995
+ };
1996
+ if (existsSync(join(REPOSITORY_ROOT, 'content'))) walk('content');
1997
+ if (existsSync(join(REPOSITORY_ROOT, 'lint-ignore.yml'))) out.push('lint-ignore.yml');
1998
+ return out.sort();
1999
+ }
2000
+
2001
+ function referenceEdits(oldKey: string, newKey: string, skip: ReadonlySet<string>): { edits: FileEdit[]; count: number } {
2002
+ const edits: FileEdit[] = [];
2003
+ let count = 0;
2004
+ for (const file of courseTextFiles()) {
2005
+ if (skip.has(file)) continue;
2006
+ const before = readFileSync(join(REPOSITORY_ROOT, file), 'utf8');
2007
+ const refs = rewriteKeyReferences(before, oldKey, newKey);
2008
+ const mentions = /^content\/notation\/|^lint-ignore\.yml$/.test(file)
2009
+ ? rewriteKeyMentions(refs.text, oldKey, newKey)
2010
+ : { text: refs.text, count: 0 };
2011
+ const n = refs.count + mentions.count;
2012
+ if (n > 0) {
2013
+ edits.push({ file, before, after: mentions.text });
2014
+ count += n;
2015
+ }
2016
+ }
2017
+ return { edits, count };
2018
+ }
2019
+
2020
+ /**
2021
+ * `content move <lesson>:<key> [--to <key>]`: a page-local entry becomes a
2022
+ * shared card. Every lesson that declares the key loses its copy (the card
2023
+ * serves them all; copies that differ are reported, the named lesson's text
2024
+ * wins), and with `--to` every `[[key]]` and `\explain{key}` follows.
2025
+ */
2026
+ export function planMove(manifest: Manifest, lessonId: string, key: string, options: { readonly to?: string } = {}): EditPlan {
2027
+ const lesson = findLesson(manifest, lessonId);
2028
+ const newKey = options.to ?? key;
2029
+ assertNotationKey(newKey);
2030
+ const target = `content/notation/${newKey}.md`;
2031
+ if (existsSync(join(REPOSITORY_ROOT, target))) {
2032
+ throw new ContentCliError('exists', `${target} already exists; reference it with [[${newKey}]] instead, or move with --to another key.`);
2033
+ }
2034
+ const local = (lesson: ManifestLesson): Record<string, unknown> | undefined => {
2035
+ const { frontmatter } = lessonRaw(lesson);
2036
+ const list = (frontmatter.notation as { local?: Record<string, unknown>[] } | undefined)?.local ?? [];
2037
+ return list.find((entry) => entry.key === key);
2038
+ };
2039
+ const source = local(lesson);
2040
+ if (!source) {
2041
+ throw new ContentCliError('unknown-key', `${lesson.id} declares no notation.local entry ${JSON.stringify(key)}.`);
2042
+ }
2043
+ const summary: string[] = [];
2044
+ const edits: FileEdit[] = [];
2045
+ const domain = promotedDomain(manifest, lesson, newKey);
2046
+ const glyph = typeof source.latex === 'string' ? source.latex.trim() : '';
2047
+ const owner = manifest.notation.definitions.find(
2048
+ (definition) => definition.kind === 'shared' && definition.key !== newKey && definition.notation.trim() === glyph,
2049
+ );
2050
+ if (owner !== undefined) {
2051
+ summary.push(
2052
+ `CAUTION: ${glyph} is already shared card ${owner.key}'s — a second shared card on one glyph is a glyph-unique-in-corpus finding; ` +
2053
+ 'a reused letter stays local (rule N4). Write this only if the two cards are meant to share the glyph, and acknowledge the pair.',
2054
+ );
2055
+ }
2056
+ edits.push({ file: target, before: null, after: sharedCardFromLocal(source, { key: newKey, fromLesson: lesson.id, domain }) });
2057
+ summary.push(
2058
+ `${target}: new shared card from ${lesson.id}'s entry${newKey === key ? '' : ` (key ${key} → ${newKey})`}, domain ${domain}` +
2059
+ `; no body yet — add what a glossary reader needs beyond the meaning` +
2060
+ (source.label === undefined ? '; no label in the entry — the card shows one made from the key until you add `label:`' : ''),
2061
+ );
2062
+ const touched = new Set<string>();
2063
+ for (const other of manifest.lessons) {
2064
+ const copy = local(other);
2065
+ if (!copy) continue;
2066
+ const before = readFileSync(lessonAbsolutePath(other), 'utf8');
2067
+ const removed = removeLocalItem(before, key);
2068
+ if (!removed.removed) continue;
2069
+ let after = removed.text;
2070
+ if (newKey !== key) after = rewriteKeyReferences(after, key, newKey).text;
2071
+ edits.push({ file: other.file, before, after });
2072
+ touched.add(other.file);
2073
+ const same = ['latex', 'label', 'meaning', 'units'].every((field) => JSON.stringify(copy[field] ?? null) === JSON.stringify(source[field] ?? null));
2074
+ summary.push(
2075
+ `${other.file}: local entry ${key} removed` +
2076
+ (other.id === lesson.id ? '' : same ? ' (a copy that agreed)' : ' (a copy that DIFFERED — the named lesson\'s text is the card\'s; check it)'),
2077
+ );
2078
+ }
2079
+ if (newKey !== key) {
2080
+ const refs = referenceEdits(key, newKey, touched);
2081
+ edits.push(...refs.edits);
2082
+ if (refs.count > 0) summary.push(`${refs.count} reference(s) to ${key} in ${refs.edits.length} other file(s) now name ${newKey}`);
2083
+ }
2084
+ return { summary, edits, next: ['pnpm validate:content', `pnpm content symbol ${newKey}`] };
2085
+ }
2086
+
2087
+ /** `content rename <old> <new>`: a shared card's key, everywhere it is named. */
2088
+ export function planRename(manifest: Manifest, oldKey: string, newKey: string): EditPlan {
2089
+ assertNotationKey(newKey);
2090
+ const oldFile = `content/notation/${oldKey}.md`;
2091
+ const newFile = `content/notation/${newKey}.md`;
2092
+ if (!existsSync(join(REPOSITORY_ROOT, oldFile))) {
2093
+ const local = manifest.lessons.find((lesson) => {
2094
+ const { frontmatter } = lessonRaw(lesson);
2095
+ return ((frontmatter.notation as { local?: { key?: string }[] } | undefined)?.local ?? []).some((e) => e.key === oldKey);
2096
+ });
2097
+ throw new ContentCliError(
2098
+ 'unknown-key',
2099
+ local
2100
+ ? `${oldKey} is a notation.local entry of ${local.id}, not a shared card; promote it with content move ${local.id}:${oldKey} --to ${newKey}.`
2101
+ : `No shared card ${oldKey} (${oldFile}).`,
2102
+ );
2103
+ }
2104
+ if (existsSync(join(REPOSITORY_ROOT, newFile))) throw new ContentCliError('exists', `${newFile} already exists.`);
2105
+ const before = readFileSync(join(REPOSITORY_ROOT, oldFile), 'utf8');
2106
+ const keyLine = /^key:\s*.*$/m.exec(before);
2107
+ if (!keyLine) throw new ContentCliError('unknown-key', `${oldFile} has no key: line.`);
2108
+ const moved = before.slice(0, keyLine.index) + `key: ${newKey}` + before.slice(keyLine.index + keyLine[0].length);
2109
+ const edits: FileEdit[] = [
2110
+ { file: oldFile, before, after: null },
2111
+ { file: newFile, before: null, after: rewriteKeyReferences(moved, oldKey, newKey).text },
2112
+ ];
2113
+ const refs = referenceEdits(oldKey, newKey, new Set([oldFile]));
2114
+ edits.push(...refs.edits);
2115
+ return {
2116
+ summary: [
2117
+ `${oldFile} → ${newFile} (key: ${newKey})`,
2118
+ `${refs.count} reference(s) in ${refs.edits.length} file(s) now name ${newKey}`,
2119
+ ],
2120
+ edits,
2121
+ next: ['pnpm validate:content', `pnpm content symbol ${newKey}`],
2122
+ };
2123
+ }
2124
+
2125
+ /**
2126
+ * `content fix <lesson>`: the diagnostics with one mechanical repair, applied.
2127
+ * Today: every `\explain{key}{inner}` the gate reports as `explain-redundant`
2128
+ * becomes `inner`.
2129
+ */
2130
+ export function planFix(manifest: Manifest, lessonId: string): EditPlan {
2131
+ const lesson = findLesson(manifest, lessonId);
2132
+ const { frontmatter, body } = lessonRaw(lesson);
2133
+ const text = readFileSync(lessonAbsolutePath(lesson), 'utf8');
2134
+ const bodyStart = Math.max(0, text.indexOf(body));
2135
+ const { scope, suspended } = lessonScope(manifest, lesson, frontmatter);
2136
+ const replacements: { start: number; end: number; inner: string; key: string }[] = [];
2137
+ for (const span of mathSpans(body)) {
2138
+ const source = stripEquationLabels(span.latex);
2139
+ // The stripped span keeps every offset of the original only when nothing
2140
+ // was stripped before a wrap; locate each wrap in the original instead.
2141
+ for (const wrap of redundantExplainWraps(source, scope, { suspended, derive: true })) {
2142
+ const needle = `\\explain{${wrap.key}}{${wrap.inner}}`;
2143
+ let at = span.latex.indexOf(needle);
2144
+ while (at >= 0) {
2145
+ replacements.push({ start: bodyStart + span.offset + at, end: bodyStart + span.offset + at + needle.length, inner: wrap.inner, key: wrap.key });
2146
+ at = span.latex.indexOf(needle, at + needle.length);
2147
+ }
2148
+ }
2149
+ }
2150
+ const unique = [...new Map(replacements.map((r) => [r.start, r])).values()].sort((a, b) => b.start - a.start);
2151
+ let after = text;
2152
+ for (const r of unique) after = after.slice(0, r.start) + r.inner + after.slice(r.end);
2153
+ const byKey = new Map<string, number>();
2154
+ for (const r of unique) byKey.set(r.key, (byKey.get(r.key) ?? 0) + 1);
2155
+ return {
2156
+ summary:
2157
+ unique.length === 0
2158
+ ? [`${lesson.file}: nothing to fix`]
2159
+ : [
2160
+ `${lesson.file}: ${unique.length} redundant \\explain wrap(s) unwrapped — ${[...byKey]
2161
+ .sort(([a], [b]) => a.localeCompare(b))
2162
+ .map(([key, n]) => `${key} ×${n}`)
2163
+ .join(', ')}`,
2164
+ ],
2165
+ edits: unique.length === 0 ? [] : [{ file: lesson.file, before: text, after }],
2166
+ next: [`pnpm content check ${lesson.id}`],
2167
+ };
2168
+ }
2169
+
2170
+ /** `content fix` over several lessons (or every one, `--all`) as one plan: the lessons with nothing to fix are counted, not listed. */
2171
+ export function planFixMany(manifest: Manifest, lessonIds: readonly string[]): EditPlan {
2172
+ if (lessonIds.length === 1) return planFix(manifest, lessonIds[0]!);
2173
+ const plans = lessonIds.map((id) => planFix(manifest, id));
2174
+ const fixing = plans.filter((plan) => plan.edits.length > 0);
2175
+ const clean = plans.length - fixing.length;
2176
+ return {
2177
+ summary: [
2178
+ ...fixing.flatMap((plan) => plan.summary),
2179
+ ...(clean > 0 ? [`${clean} lesson(s) with nothing to fix`] : []),
2180
+ ],
2181
+ edits: fixing.flatMap((plan) => plan.edits),
2182
+ next: ['pnpm content check --changed'],
2183
+ };
2184
+ }
2185
+
2186
+ /** The plan as a unified diff (git's), file by file, under its summary lines. */
2187
+ export function renderEditPlan(plan: EditPlan): string {
2188
+ const lines = plan.summary.map((line) => ` ${line}`);
2189
+ const scratch = join(REPOSITORY_ROOT, '.agent', 'content-edits');
2190
+ mkdirSync(scratch, { recursive: true });
2191
+ plan.edits.forEach((edit, index) => {
2192
+ const a = join(scratch, `${index}.before`);
2193
+ const b = join(scratch, `${index}.after`);
2194
+ writeFileSync(a, edit.before ?? '');
2195
+ writeFileSync(b, edit.after ?? '');
2196
+ let diff = '';
2197
+ try {
2198
+ diff = execFileSync('git', ['diff', '--no-index', '--no-color', '--unified=2', '--', a, b], { encoding: 'utf8' });
2199
+ } catch (error) {
2200
+ diff = typeof (error as { stdout?: unknown }).stdout === 'string' ? ((error as { stdout: string }).stdout) : '';
2201
+ }
2202
+ const label = edit.before === null ? `${edit.file} (new file)` : edit.after === null ? `${edit.file} (deleted)` : edit.file;
2203
+ const hunks = diff.split('\n').filter((line) => !/^(diff --git|index |--- |\+\+\+ )/.test(line)).join('\n').trim();
2204
+ lines.push('', `--- ${label}`, hunks || '(no textual change)');
2205
+ unlinkSync(a);
2206
+ unlinkSync(b);
2207
+ });
2208
+ return `${lines.join('\n')}\n`;
2209
+ }
2210
+
2211
+ /** Write a plan's edits. Returns the files touched. */
2212
+ export function applyEditPlan(plan: EditPlan): string[] {
2213
+ const touched: string[] = [];
2214
+ for (const edit of plan.edits) {
2215
+ const path = join(REPOSITORY_ROOT, edit.file);
2216
+ if (edit.after === null) {
2217
+ if (existsSync(path)) unlinkSync(path);
2218
+ } else {
2219
+ mkdirSync(dirname(path), { recursive: true });
2220
+ writeFileSync(path, edit.after);
2221
+ }
2222
+ touched.push(edit.file);
2223
+ }
2224
+ return touched;
2225
+ }
2226
+
2227
+ // --- content init-agent ----------------------------------------------
2228
+
2229
+ /**
2230
+ * The engine's course-facing agent procedures (`docs/course-agent/`): skills,
2231
+ * the `rules-reviewer` agent and the `AGENTS.md` baseline, beside the
2232
+ * installed package or above this module.
2233
+ */
2234
+ export function courseAgentDir(): string | undefined {
2235
+ const candidates = [join(REPOSITORY_ROOT, 'node_modules', 'explico', 'docs', 'course-agent')];
2236
+ let dir = dirname(fileURLToPath(import.meta.url));
2237
+ for (let level = 0; level < 5; level += 1) {
2238
+ candidates.push(join(dir, 'docs', 'course-agent'));
2239
+ dir = dirname(dir);
2240
+ }
2241
+ return candidates.find((candidate) => existsSync(join(candidate, 'AGENTS.md')));
2242
+ }
2243
+
2244
+ function filesUnder(root: string, rel = ''): string[] {
2245
+ const out: string[] = [];
2246
+ for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
2247
+ const path = rel === '' ? entry.name : `${rel}/${entry.name}`;
2248
+ if (entry.isDirectory()) out.push(...filesUnder(root, path));
2249
+ else out.push(path);
2250
+ }
2251
+ return out.sort();
2252
+ }
2253
+
2254
+ /**
2255
+ * `content init-agent [--write]`: install or refresh the engine's agent
2256
+ * procedures in this course — every skill under `.claude/skills/`, the
2257
+ * reviewer under `.claude/agents/` — and, only when the course has no
2258
+ * `AGENTS.md`, the baseline one. An existing `AGENTS.md` is never edited;
2259
+ * when it does not point at the engine's guide, the plan says what to add.
2260
+ */
2261
+ export function planInitAgent(): EditPlan {
2262
+ const source = courseAgentDir();
2263
+ if (source === undefined) {
2264
+ throw new ContentCliError('unknown-key', 'No docs/course-agent/ beside the engine (node_modules/explico/docs/course-agent).');
2265
+ }
2266
+ const edits: FileEdit[] = [];
2267
+ const summary: string[] = [];
2268
+ const install = (from: string, to: string) => {
2269
+ const after = readFileSync(join(source, from), 'utf8');
2270
+ const target = join(REPOSITORY_ROOT, to);
2271
+ const before = existsSync(target) ? readFileSync(target, 'utf8') : null;
2272
+ if (before === after) return;
2273
+ edits.push({ file: to, before, after });
2274
+ summary.push(`${to}: ${before === null ? 'new' : "differs from the engine's copy"}`);
2275
+ };
2276
+ for (const file of filesUnder(join(source, 'skills'))) install(`skills/${file}`, `.claude/skills/${file}`);
2277
+ for (const file of filesUnder(join(source, 'agents'))) install(`agents/${file}`, `.claude/agents/${file}`);
2278
+ const agents = agentGuideState();
2279
+ if (agents.agentsFile === null) {
2280
+ install('AGENTS.md', 'AGENTS.md');
2281
+ } else if (!agents.linksGuide) {
2282
+ summary.push(
2283
+ `${agents.agentsFile}: left as it is; it does not point at the engine's guide — add a first line such as ` +
2284
+ '"Read node_modules/explico/docs/authoring/agents.md first; this file holds only what is particular to this course." ' +
2285
+ `(the baseline is ${relative(REPOSITORY_ROOT, join(source, 'AGENTS.md'))})`,
2286
+ );
2287
+ }
2288
+ if (edits.length === 0 && summary.length === 0) summary.push('the agent procedures are installed and current');
2289
+ return { summary, edits, next: ['pnpm content status'] };
2290
+ }
2291
+
2292
+ // --- content rule ----------------------------------------------------
2293
+
2294
+ /**
2295
+ * One rule, whole: the catalog entry as this course has it (enforcement,
2296
+ * codes, settings in effect, whether it is on) and the rule page's own
2297
+ * section, example included. About half a thousand tokens where opening the
2298
+ * page costs twenty thousand.
2299
+ */
2300
+ export interface RuleReport {
2301
+ readonly id: string;
2302
+ readonly status: 'in-effect' | 'retired' | 'unknown';
2303
+ readonly rule?: RuleInEffect;
2304
+ readonly retired?: RetiredRule;
2305
+ /** The rule page's section for this rule, when the page is at hand. */
2306
+ readonly section?: {
2307
+ readonly file: string;
2308
+ readonly markdown: string;
2309
+ readonly tokens: number;
2310
+ };
2311
+ /** For `unknown`: the IDs this course has, so the caller can pick one. */
2312
+ readonly known: readonly string[];
2313
+ }
2314
+
2315
+ /**
2316
+ * The engine's `docs/authoring/`: beside the installed package in a course
2317
+ * (`node_modules/explico/docs/authoring`, the path the rules-in-effect page
2318
+ * links), or above this module in the repository and in the package itself.
2319
+ */
2320
+ export function authoringDocsDir(): string | undefined {
2321
+ const candidates = [join(REPOSITORY_ROOT, 'node_modules', 'explico', 'docs', 'authoring')];
2322
+ let dir = dirname(fileURLToPath(import.meta.url));
2323
+ for (let level = 0; level < 5; level += 1) {
2324
+ candidates.push(join(dir, 'docs', 'authoring'));
2325
+ dir = dirname(dir);
2326
+ }
2327
+ return candidates.find((candidate) => existsSync(join(candidate, 'README.md')));
2328
+ }
2329
+
2330
+ function headingSlug(text: string): string {
2331
+ return text
2332
+ .toLowerCase()
2333
+ .replace(/`/g, '')
2334
+ .replace(/[^a-z0-9]+/g, '-')
2335
+ .replace(/^-|-$/g, '');
2336
+ }
2337
+
2338
+ /**
2339
+ * The section of a rule page that `anchor` names: from the heading whose
2340
+ * slug is the anchor (`## N4` → `n4`, `## Reference systems` →
2341
+ * `reference-systems`) to the next heading of the same or a higher level,
2342
+ * trailing rules and blank lines dropped.
2343
+ */
2344
+ export function ruleSection(page: string, anchor: string): string | undefined {
2345
+ const lines = page.split('\n');
2346
+ const want = anchor.toLowerCase();
2347
+ const start = lines.findIndex((line) => {
2348
+ const match = /^(#{1,6})\s+(.*\S)\s*$/.exec(line);
2349
+ return match !== null && headingSlug(match[2]!) === want;
2350
+ });
2351
+ if (start < 0) return undefined;
2352
+ const level = /^#+/.exec(lines[start]!)![0].length;
2353
+ let end = lines.findIndex((line, index) => {
2354
+ if (index <= start) return false;
2355
+ const match = /^(#{1,6})\s/.exec(line);
2356
+ return match !== null && match[1]!.length <= level;
2357
+ });
2358
+ if (end < 0) end = lines.length;
2359
+ const body = lines.slice(start, end);
2360
+ while (body.length > 0 && /^(---)?\s*$/.test(body[body.length - 1]!)) body.pop();
2361
+ return body.join('\n');
2362
+ }
2363
+
2364
+ function ruleSectionFor(rule: RuleInEffect): RuleReport['section'] | undefined {
2365
+ if (rule.source === 'course') return undefined;
2366
+ const dir = authoringDocsDir();
2367
+ if (dir === undefined) return undefined;
2368
+ // `doc` is the link the rules-in-effect page prints (`../node_modules/…/notation.md#n4`);
2369
+ // the pages are flat under docs/authoring, so the basename names the file.
2370
+ const [link, anchor] = rule.doc.split('#');
2371
+ if (!link || !anchor) return undefined;
2372
+ const page = basename(link);
2373
+ const file = join(dir, page);
2374
+ if (!existsSync(file)) return undefined;
2375
+ const markdown = ruleSection(readFileSync(file, 'utf8'), anchor);
2376
+ if (markdown === undefined) return undefined;
2377
+ return { file: join('docs', 'authoring', page), markdown, tokens: estimateTokens(markdown) };
2378
+ }
2379
+
2380
+ export function contentRule(manifest: Manifest, rawId: string): RuleReport {
2381
+ const id = rawId.trim();
2382
+ const inEffect = rulesInEffect(manifest);
2383
+ const rule = inEffect.find((entry) => entry.id.toLowerCase() === id.toLowerCase());
2384
+ if (rule) {
2385
+ const section = ruleSectionFor(rule);
2386
+ return { id: rule.id, status: 'in-effect', rule, ...(section ? { section } : {}), known: [] };
2387
+ }
2388
+ const retired = retiredRule(id.toUpperCase());
2389
+ if (retired) return { id: retired.id, status: 'retired', retired, known: [] };
2390
+ return { id, status: 'unknown', known: inEffect.map((entry) => entry.id) };
2391
+ }
2392
+
2393
+ /** The text form of a rule report: the entry's facts, then the page section. */
2394
+ export function formatRuleReport(report: RuleReport): string {
2395
+ if (report.status === 'unknown') {
2396
+ return `No rule ${JSON.stringify(report.id)} in this course. Rules in effect: ${report.known.join(', ')}.\n`;
2397
+ }
2398
+ if (report.status === 'retired') {
2399
+ const r = report.retired!;
2400
+ return (
2401
+ `${r.id} · retired ${r.date}${r.into ? `, folded into ${r.into}` : ''}\n` +
2402
+ ` was: ${r.title}\n ${r.why}\n`
2403
+ );
2404
+ }
2405
+ const rule = report.rule!;
2406
+ const lines = [
2407
+ `${rule.id} · ${rule.title}`,
2408
+ ` ${rule.kind} · elements ${rule.elements.join(', ')} · enforcement ${rule.enforcement} · editions ${String(rule.editions)} · ${rule.enabled ? 'on' : 'off (course.settings.json rules)'}${rule.waivable ? ' · waivable with {/* explico-allow ' + rule.id + ': reason */}' : ''}`,
2409
+ ` codes: ${rule.codes.length > 0 ? rule.codes.join(', ') : '(none)'}`,
2410
+ ];
2411
+ if (Object.keys(rule.settings).length > 0) lines.push(` settings: ${JSON.stringify(rule.settings)}`);
2412
+ lines.push(` summary: ${rule.summary}`);
2413
+ if (report.section) {
2414
+ lines.push(` page: ${report.section.file} (section below, ≈${report.section.tokens} tokens)`, '', report.section.markdown);
2415
+ } else {
2416
+ lines.push(` page: ${rule.doc}${rule.source === 'course' ? ' (the course\'s own link)' : ' (page not found beside the engine)'}`);
2417
+ }
2418
+ return `${lines.join('\n')}\n`;
2419
+ }
2420
+
2421
+ export function contentContext(
2422
+ manifest: Manifest,
2423
+ rawId: string,
2424
+ ): ContextReport {
2425
+ const lesson = findLesson(manifest, rawId);
2426
+ const { frontmatter, body } = lessonRaw(lesson);
2427
+
2428
+ const outcomeById = new Map(
2429
+ manifest.competencies.map((competency) => [
2430
+ String(competency.id),
2431
+ String(competency.outcome ?? ''),
2432
+ ]),
2433
+ );
2434
+ const outcomesFor = (ids: readonly string[]) =>
2435
+ ids
2436
+ .filter((id) => outcomeById.has(id))
2437
+ .map((id) => ({ id, outcome: outcomeById.get(id) ?? '' }));
2438
+
2439
+ const sourceById = new Map(
2440
+ manifest.sources.map((source) => [String(source.id), source]),
2441
+ );
2442
+ const citedLocatorsById = new Map<string, string[]>();
2443
+ for (const citation of lesson.citations) {
2444
+ const list = citedLocatorsById.get(citation.id) ?? [];
2445
+ if (citation.locator) list.push(citation.locator);
2446
+ citedLocatorsById.set(citation.id, list);
2447
+ }
2448
+
2449
+ const contextWithoutTokens = {
2450
+ lesson: {
2451
+ id: lesson.id,
2452
+ slug: lesson.slug,
2453
+ file: lesson.file,
2454
+ title: lesson.title,
2455
+ description: lesson.description,
2456
+ teaches: lesson.teaches,
2457
+ requires: lesson.requires,
2458
+ assumptions: lesson.assumptions,
2459
+ since: lesson.since,
2460
+ notationLocal:
2461
+ (frontmatter.notation as { local?: unknown } | undefined)?.local ?? [],
2462
+ body,
2463
+ },
2464
+ taughtOutcomes: outcomesFor(lesson.teaches),
2465
+ prerequisiteOutcomes: outcomesFor(lesson.requires),
2466
+ notation: lesson.notation.definitions.map((definition) => ({
2467
+ key: definition.key,
2468
+ latex: definition.notation,
2469
+ meaning: definition.summary,
2470
+ scope: definition.scope,
2471
+ ...(definition.units === undefined ? {} : { units: definition.units }),
2472
+ ...(definition.formula === undefined
2473
+ ? {}
2474
+ : { formula: definition.formula }),
2475
+ })),
2476
+ equations: lesson.equations.map((equation) => ({ ...equation })),
2477
+ tables: lesson.tables.map((table) => ({ ...table })),
2478
+ figures: lesson.figures.map((figure) => ({ ...figure })),
2479
+ diagrams: lesson.diagrams.map((diagram) => ({ ...diagram })),
2480
+ examples: lesson.examples.map((example) => ({ ...example })),
2481
+ knowledgeChecks: lesson.knowledgeChecks.map((check) => ({ ...check })),
2482
+ sections: lesson.headings.map((heading) => ({ ...heading })),
2483
+ sources: lesson.sources.map((id) => {
2484
+ const record = sourceById.get(id) as Record<string, unknown> | undefined;
2485
+ return {
2486
+ id,
2487
+ title: String(record?.title ?? id),
2488
+ type: String(record?.type ?? 'unknown'),
2489
+ ...(typeof record?.locator === 'string'
2490
+ ? { recordLocator: record.locator }
2491
+ : {}),
2492
+ citedLocators: citedLocatorsById.get(id) ?? [],
2493
+ };
2494
+ }),
2495
+ domainSignatures: domainSignaturesForSection(
2496
+ lesson.slug.split('/')[0] ?? '',
2497
+ ),
2498
+ diagnostics: lessonDiagnostics(manifest, lesson),
2499
+ };
2500
+
2501
+ return {
2502
+ ...contextWithoutTokens,
2503
+ tokens: tokenCounts(contextWithoutTokens),
2504
+ };
2505
+ }
2506
+
2507
+ // --- content symbol --------------------------------------------------
2508
+
2509
+ /*
2510
+ * Where a symbol is already defined, asked before an author declares it
2511
+ * again. Reads the registry the manifest holds: shared cards, page-local
2512
+ * entries (`notation.local`) and glosses (a card's `glosses:`), matched on
2513
+ * the latex as written, a shared card's `aliases`, or the key. A `contains`
2514
+ * list follows for a letter that sits inside a longer notation (`v` in
2515
+ * `v_0`), so a near miss is visible too. Read-only; every list is sorted,
2516
+ * so two runs print the same report.
2517
+ */
2518
+ /** Where a hit is defined: a shared card, a lesson's `notation.local`, or a card's gloss. */
2519
+ export type SymbolTier = 'shared' | 'local' | 'gloss';
2520
+
2521
+ export interface SymbolHit {
2522
+ readonly tier: SymbolTier;
2523
+ readonly key: string;
2524
+ readonly latex: string;
2525
+ readonly label: string;
2526
+ readonly meaning?: string;
2527
+ readonly units?: string;
2528
+ /** How the query matched: the latex as written, an alias, or the key. */
2529
+ readonly matchedBy: 'latex' | 'alias' | 'key';
2530
+ /** shared: the entry file; local: the lesson id; gloss: the owning card. */
2531
+ readonly where: string;
2532
+ /** shared only: the pages (lessons and overview pages) whose bundle binds the card. */
2533
+ readonly boundIn?: readonly string[];
2534
+ }
2535
+
2536
+ export interface SymbolReport {
2537
+ readonly query: string;
2538
+ readonly shared: readonly SymbolHit[];
2539
+ readonly local: readonly SymbolHit[];
2540
+ readonly glosses: readonly SymbolHit[];
2541
+ /** Entries whose latex contains the query as a glyph, not matched above. */
2542
+ readonly contains: readonly SymbolHit[];
2543
+ /** How many `contains` candidates there were before the list was capped. */
2544
+ readonly containsTotal: number;
2545
+ }
2546
+
2547
+ const SYMBOL_CONTAINS_CAP = 12;
2548
+
2549
+ type DefinitionRecord = Manifest['notation']['definitions'][number];
2550
+
2551
+ function byKeyThenWhere(left: SymbolHit, right: SymbolHit): number {
2552
+ return left.key.localeCompare(right.key) || left.where.localeCompare(right.where);
2553
+ }
2554
+
2555
+ export function contentSymbol(manifest: Manifest, rawQuery: string): SymbolReport {
2556
+ const query = rawQuery.trim();
2557
+ const boundIn = new Map<string, string[]>();
2558
+ for (const bundle of manifest.notation.bundles) {
2559
+ for (const binding of bundle.bindings) {
2560
+ const list = boundIn.get(binding.definitionId) ?? [];
2561
+ if (!list.includes(bundle.lessonId)) list.push(bundle.lessonId);
2562
+ boundIn.set(binding.definitionId, list);
2563
+ }
2564
+ }
2565
+
2566
+ // Every candidate, each with the way it would match, so the exact pass and
2567
+ // the `contains` pass read one list.
2568
+ interface Candidate {
2569
+ readonly hit: Omit<SymbolHit, 'matchedBy'>;
2570
+ readonly aliases: readonly string[];
2571
+ }
2572
+ const candidates: Candidate[] = [];
2573
+ const push = (definition: DefinitionRecord): void => {
2574
+ if (definition.kind === 'shared') {
2575
+ candidates.push({
2576
+ hit: {
2577
+ tier: 'shared',
2578
+ key: definition.key,
2579
+ latex: definition.notation,
2580
+ label: definition.label,
2581
+ meaning: definition.summary,
2582
+ ...(definition.units === undefined ? {} : { units: definition.units }),
2583
+ where: definition.source.file,
2584
+ boundIn: [...(boundIn.get(definition.id) ?? [])].sort(),
2585
+ },
2586
+ aliases: definition.aliases,
2587
+ });
2588
+ for (const gloss of definition.glosses) {
2589
+ candidates.push({
2590
+ hit: {
2591
+ tier: 'gloss',
2592
+ key: gloss.key,
2593
+ latex: gloss.notation,
2594
+ label: gloss.label,
2595
+ ...(gloss.units === undefined ? {} : { units: gloss.units }),
2596
+ where: gloss.ownerKey,
2597
+ },
2598
+ aliases: [],
2599
+ });
2600
+ }
2601
+ return;
2602
+ }
2603
+ candidates.push({
2604
+ hit: {
2605
+ tier: 'local',
2606
+ key: definition.key,
2607
+ latex: definition.notation,
2608
+ label: definition.label,
2609
+ meaning: definition.summary,
2610
+ ...(definition.units === undefined ? {} : { units: definition.units }),
2611
+ where: definition.lessonId,
2612
+ },
2613
+ aliases: [],
2614
+ });
2615
+ };
2616
+ for (const definition of manifest.notation.definitions) push(definition);
2617
+
2618
+ const exact: SymbolHit[] = [];
2619
+ const rest: Candidate[] = [];
2620
+ for (const candidate of candidates) {
2621
+ const { hit, aliases } = candidate;
2622
+ const matchedBy =
2623
+ hit.latex.trim() === query
2624
+ ? 'latex'
2625
+ : aliases.some((alias) => alias.trim() === query)
2626
+ ? 'alias'
2627
+ : hit.key === query
2628
+ ? 'key'
2629
+ : undefined;
2630
+ if (matchedBy) exact.push({ ...hit, matchedBy });
2631
+ else rest.push(candidate);
2632
+ }
2633
+
2634
+ const matcher = glyphMatcher(query);
2635
+ const containsAll = matcher
2636
+ ? rest
2637
+ .filter(({ hit }) => matcher.test(hit.latex))
2638
+ .map(({ hit }): SymbolHit => ({ ...hit, matchedBy: 'latex' }))
2639
+ .sort(byKeyThenWhere)
2640
+ : [];
2641
+
2642
+ const tier = (name: SymbolTier) =>
2643
+ exact.filter((hit) => hit.tier === name).sort(byKeyThenWhere);
2644
+ return {
2645
+ query,
2646
+ shared: tier('shared'),
2647
+ local: tier('local'),
2648
+ glosses: tier('gloss'),
2649
+ contains: containsAll.slice(0, SYMBOL_CONTAINS_CAP),
2650
+ containsTotal: containsAll.length,
2651
+ };
2652
+ }
2653
+
2654
+ function firstSentence(text: string, limit = 140): string {
2655
+ const sentence = text.trim().split(/(?<=[.!?])\s/)[0] ?? '';
2656
+ return sentence.length > limit ? `${sentence.slice(0, limit - 1)}…` : sentence;
2657
+ }
2658
+
2659
+ function symbolHitLine(hit: SymbolHit): string {
2660
+ const units = hit.units === undefined ? '' : ` (${hit.units})`;
2661
+ const where =
2662
+ hit.tier === 'shared'
2663
+ ? `${hit.where}; bound on ${hit.boundIn?.length ?? 0} page(s)` +
2664
+ (hit.boundIn && hit.boundIn.length > 0 ? `: ${hit.boundIn.join(', ')}` : '')
2665
+ : hit.tier === 'local'
2666
+ ? `lesson ${hit.where}`
2667
+ : `gloss on ${hit.where}`;
2668
+ const via = hit.matchedBy === 'latex' ? '' : ` [matched by ${hit.matchedBy}]`;
2669
+ return `${hit.key} — ${hit.label}${units} — ${where}${via}`;
2670
+ }
2671
+
2672
+ /**
2673
+ * The text form of a symbol report: one block per tier, then the near
2674
+ * misses, then one line that counts what was found, worded for the next
2675
+ * step an author takes.
2676
+ */
2677
+ export function formatSymbolReport(report: SymbolReport): string {
2678
+ const lines: string[] = [report.query];
2679
+ const section = (title: string, hits: readonly SymbolHit[]): void => {
2680
+ if (hits.length === 0) {
2681
+ lines.push(` ${title.padEnd(7)} (none)`);
2682
+ return;
2683
+ }
2684
+ for (const [index, hit] of hits.entries()) {
2685
+ lines.push(` ${(index === 0 ? title : '').padEnd(7)} ${symbolHitLine(hit)}`);
2686
+ if (hit.meaning) lines.push(` ${firstSentence(hit.meaning)}`);
2687
+ }
2688
+ };
2689
+ section('shared', report.shared);
2690
+ section('local', report.local);
2691
+ section('gloss', report.glosses);
2692
+ if (report.contains.length > 0) {
2693
+ for (const [index, hit] of report.contains.entries()) {
2694
+ lines.push(
2695
+ ` ${(index === 0 ? 'also' : '').padEnd(7)} ${hit.latex} — ${symbolHitLine(hit)}`,
2696
+ );
2697
+ }
2698
+ if (report.containsTotal > report.contains.length) {
2699
+ lines.push(
2700
+ ` … and ${report.containsTotal - report.contains.length} more; --json lists them all`,
2701
+ );
2702
+ }
2703
+ }
2704
+
2705
+ const pageMeanings = report.local.length + report.glosses.length;
2706
+ if (report.shared.length > 0) {
2707
+ const bound = report.shared.reduce((n, hit) => n + (hit.boundIn?.length ?? 0), 0);
2708
+ lines.push(
2709
+ `${report.shared.length} shared card(s), bound on ${bound} page(s). Reference it with [[key]] before the first math use.`,
2710
+ );
2711
+ } else if (pageMeanings > 0) {
2712
+ lines.push(
2713
+ `No shared card. It is a page meaning ${pageMeanings} time(s): ${report.local.length} local, ${report.glosses.length} gloss. A second page that needs the same meaning wants a shared card.`,
2714
+ );
2715
+ } else {
2716
+ lines.push(
2717
+ `Not defined anywhere in this course${report.containsTotal > 0 ? ' (near misses above)' : ''}.`,
2718
+ );
2719
+ }
2720
+ return `${lines.join('\n')}\n`;
2721
+ }
2722
+
2723
+ // --- content check ---------------------------------------------------
2724
+
2725
+ /**
2726
+ * A page `content check` checks: a lesson's (its chapter, or a slides-first
2727
+ * lesson's deck), or a deck beside its chapter or a part's, by its page id.
2728
+ */
2729
+ export type CheckTarget = Pick<ManifestLesson, 'id' | 'file' | 'assessments'> & {
2730
+ readonly notation: Pick<ManifestLesson['notation'], 'definitionIds'>;
2731
+ };
2732
+
2733
+ /**
2734
+ * The lesson `rawId` names, or the deck whose page it names: a deck beside
2735
+ * its chapter by its route (`motion/range/slides`), a part's deck by its own
2736
+ * (`motion/slides`).
2737
+ */
2738
+ export function findCheckTarget(manifest: Manifest, rawId: string): CheckTarget {
2739
+ const id = normalizeLessonId(rawId);
2740
+ const lesson = manifest.lessons.find((entry) => entry.id === id);
2741
+ if (lesson) return lesson;
2742
+ const deck = (manifest.decks ?? []).find((entry) => entry.pageId === id);
2743
+ if (deck) return { id: deck.pageId, file: deck.file, assessments: [], notation: deck.notation };
2744
+ throw new ContentCliError(
2745
+ 'unknown-lesson',
2746
+ `No lesson or deck ${JSON.stringify(id)}. Known ids: ${[
2747
+ ...manifest.lessons.map((entry) => entry.id),
2748
+ ...(manifest.decks ?? []).filter((entry) => entry.chapter !== undefined || entry.part !== undefined).map((entry) => entry.pageId),
2749
+ ].join(', ')}`,
2750
+ );
2751
+ }
2752
+
2753
+ export interface CheckLessonResult {
2754
+ readonly lesson: string;
2755
+ readonly file: string;
2756
+ readonly ok: boolean;
2757
+ readonly rendered: boolean;
2758
+ readonly diagnostics: readonly CliDiagnostic[];
2759
+ }
2760
+
2761
+ export interface CheckReport {
2762
+ readonly ok: boolean;
2763
+ readonly lessons: readonly CheckLessonResult[];
2764
+ }
2765
+
2766
+ async function checkOneLesson(
2767
+ manifest: Manifest,
2768
+ lesson: CheckTarget,
2769
+ ): Promise<CheckLessonResult> {
2770
+ const manifestDiagnostics = lessonDiagnostics(manifest, lesson);
2771
+ const { frontmatter, body } = lessonSource(
2772
+ readFileSync(lessonAbsolutePath(lesson), 'utf8'),
2773
+ );
2774
+ const render = await renderLesson(manifest, {
2775
+ body,
2776
+ frontmatter,
2777
+ path: lessonAbsolutePath(lesson),
2778
+ });
2779
+
2780
+ const diagnostics = [...manifestDiagnostics, ...render.diagnostics].sort(
2781
+ (a, b) =>
2782
+ a.code.localeCompare(b.code) || a.message.localeCompare(b.message),
2783
+ );
2784
+ return {
2785
+ lesson: lesson.id,
2786
+ file: lesson.file,
2787
+ ok: !diagnostics.some((diagnostic) => diagnostic.severity === 'error'),
2788
+ rendered: render.rendered,
2789
+ diagnostics,
2790
+ };
2791
+ }
2792
+
2793
+ /** `.mdx` / `.checks.yml` paths that differ from `HEAD`, tracked or not. */
2794
+ export function changedLessonIds(): string[] {
2795
+ let output: string;
2796
+ try {
2797
+ const tracked = execFileSync(
2798
+ 'git',
2799
+ ['diff', '--name-only', 'HEAD', '--', 'content/docs'],
2800
+ { cwd: REPOSITORY_ROOT, encoding: 'utf8' },
2801
+ );
2802
+ const untracked = execFileSync(
2803
+ 'git',
2804
+ ['ls-files', '--others', '--exclude-standard', '--', 'content/docs'],
2805
+ { cwd: REPOSITORY_ROOT, encoding: 'utf8' },
2806
+ );
2807
+ output = `${tracked}\n${untracked}`;
2808
+ } catch {
2809
+ throw new ContentCliError(
2810
+ 'no-git',
2811
+ 'Could not read git status for --changed. Run inside a git working tree.',
2812
+ );
2813
+ }
2814
+
2815
+ const ids = new Set<string>();
2816
+ for (const line of output.split('\n')) {
2817
+ const path = line.trim();
2818
+ if (!path) continue;
2819
+ const slug = path
2820
+ .replace(/^content\/docs\//, '')
2821
+ .replace(/\.checks\.yml$/, '.mdx')
2822
+ .replace(/\.mdx?$/, '');
2823
+ if (!isLessonSlug(slug)) continue;
2824
+ ids.add(slug.replace(/\//g, '.'));
2825
+ }
2826
+ return [...ids].sort();
2827
+ }
2828
+
2829
+ export async function contentCheck(
2830
+ manifest: Manifest,
2831
+ options: {
2832
+ lesson?: string;
2833
+ /** Several lessons, each checked; `lesson` is the one-lesson form. */
2834
+ lessons?: readonly string[];
2835
+ changed?: boolean;
2836
+ },
2837
+ ): Promise<CheckReport> {
2838
+ let targets: CheckTarget[];
1164
2839
  const named = [...(options.lesson ? [options.lesson] : []), ...(options.lessons ?? [])];
1165
2840
  if (options.changed) {
1166
2841
  const ids = changedLessonIds();
@@ -1172,7 +2847,7 @@ export async function contentCheck(
1172
2847
  'content check needs a <lesson> id or --changed.',
1173
2848
  );
1174
2849
  }
1175
- targets = named.map((id) => findLesson(manifest, id));
2850
+ targets = named.map((id) => findCheckTarget(manifest, id));
1176
2851
  }
1177
2852
 
1178
2853
  const lessons = await Promise.all(
@@ -1265,6 +2940,280 @@ After this lesson, you should be able to:
1265
2940
  };
1266
2941
  }
1267
2942
 
2943
+ /** One heading of a chapter, as a deck draft starts from it. */
2944
+ interface OutlineDraft {
2945
+ level: 2 | 3;
2946
+ title: string;
2947
+ /** The numbered floats under the heading, as `<Recall ref>` names them. */
2948
+ recalls: string[];
2949
+ /** The first sentence of its first paragraph: the frame's notes. */
2950
+ note: string;
2951
+ }
2952
+
2953
+ /**
2954
+ * A chapter's `##` and `###` headings, each with the numbered equations,
2955
+ * figures, tables and diagrams under it and its first sentence: what
2956
+ * `content new deck` drafts frames from.
2957
+ */
2958
+ function chapterOutline(content: string): OutlineDraft[] {
2959
+ const lines = content.split('\n');
2960
+ const drafts: OutlineDraft[] = [];
2961
+ let fence = false;
2962
+ let paragraph: string[] = [];
2963
+ let pendingTag: string | undefined;
2964
+ const flushParagraph = () => {
2965
+ const current = drafts.at(-1);
2966
+ if (current && current.note === '' && paragraph.length > 0) {
2967
+ const text = paragraph.join(' ');
2968
+ if (!/^\s*[<{:|]/.test(text)) current.note = firstSentence(text);
2969
+ }
2970
+ paragraph = [];
2971
+ };
2972
+ for (const line of lines) {
2973
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence;
2974
+ if (fence) continue;
2975
+ const heading = /^(#{2,3})[ \t]+(.+?)[ \t]*$/.exec(line);
2976
+ if (heading) {
2977
+ flushParagraph();
2978
+ drafts.push({
2979
+ level: heading[1]!.length as 2 | 3,
2980
+ title: heading[2]!.replace(/(?:\s*\[[a-z=-]+\])+\s*$/i, ''),
2981
+ recalls: [],
2982
+ note: '',
2983
+ });
2984
+ continue;
2985
+ }
2986
+ const current = drafts.at(-1);
2987
+ for (const match of line.matchAll(/\\label\{eq:([a-z0-9-]+)\}/g)) current?.recalls.push(`eq-${match[1]}`);
2988
+ // A float's tag may run over several lines: it counts where it opens.
2989
+ if (/<(Figure|Table|Diagram)\b/.test(line)) pendingTag = line;
2990
+ else if (pendingTag !== undefined) pendingTag += ` ${line}`;
2991
+ if (pendingTag !== undefined && pendingTag.includes('>')) {
2992
+ const tag = /<(Figure|Table|Diagram)\b[^>]*?\bid=["']([a-z0-9-]+)["']/.exec(pendingTag);
2993
+ if (tag) {
2994
+ const prefix = tag[1] === 'Figure' ? 'fig' : tag[1] === 'Table' ? 'tab' : 'dia';
2995
+ current?.recalls.push(`${prefix}-${tag[2]}`);
2996
+ }
2997
+ pendingTag = undefined;
2998
+ }
2999
+ if (line.trim() === '') flushParagraph();
3000
+ else if (!/^\s*(import|export)\s/.test(line) && !/^\s*[-*$<{|>]/.test(line)) paragraph.push(line.trim());
3001
+ }
3002
+ flushParagraph();
3003
+ return drafts;
3004
+ }
3005
+
3006
+ /** A draft frame: its title, a `<Recall>` per float, and its notes. */
3007
+ function draftFrame(out: string[], draft: Pick<OutlineDraft, 'title' | 'recalls' | 'note'>): void {
3008
+ out.push(`### ${draft.title}`, '');
3009
+ for (const ref of draft.recalls) out.push(`<Recall ref="${ref}" />`, '');
3010
+ out.push('<Notes>', draft.note || 'What you say on this frame.', '</Notes>', '');
3011
+ }
3012
+
3013
+ /**
3014
+ * `content new deck <lesson>`: the slide edition of a lesson, as a draft to
3015
+ * rewrite (`docs/authoring/deck.md`). Beside an existing chapter it starts
3016
+ * from the chapter: a `##` section per chapter section, a `###` frame per
3017
+ * subsection (or one for a section without any), a `<Recall>` for each
3018
+ * numbered equation, figure, table and diagram there, and the section's first
3019
+ * sentence in the frame's notes. Without a chapter it is a slides-first
3020
+ * lesson, with the lesson's frontmatter and a skeleton frame.
3021
+ *
3022
+ * `content new deck <part>`, for a part's directory with its page
3023
+ * (`index.mdx`), drafts the part's deck (`<part>/index.deck.mdx`): a
3024
+ * `## <chapter title> [chapter: <name>]` section per chapter, in the order
3025
+ * the tracks read them, and a frame per section of the chapter, with a
3026
+ * `<Recall>` for each float it numbers and its first sentence in the notes.
3027
+ */
3028
+ export function scaffoldDeck(rawId: string): Scaffold {
3029
+ const slug = rawId
3030
+ .trim()
3031
+ .replace(/(?:\.deck)?\.mdx?$/, '')
3032
+ .replace(/\./g, '/')
3033
+ .replace(/\/(?:index)?$/, '');
3034
+ if (!slug.includes('/') && ID_PATTERN.test(slug) && partPagePath(slug) !== undefined) {
3035
+ return scaffoldPartDeck(slug);
3036
+ }
3037
+ if (!slug.includes('/') || !isLessonSlug(slug) || slug.split('/').some((part) => !ID_PATTERN.test(part))) {
3038
+ throw new ContentCliError(
3039
+ 'invalid-id',
3040
+ `A deck is named by its lesson, <section>/<slug> (or <section>.<slug>), or by a part's directory ` +
3041
+ `with its page (content/docs/<part>/index.mdx), got ${JSON.stringify(rawId)}.`,
3042
+ );
3043
+ }
3044
+ const relPath = `content/docs/${slug}.deck.mdx`;
3045
+ if (existsSync(join(REPOSITORY_ROOT, relPath))) {
3046
+ throw new ContentCliError('exists', `${relPath} already exists; not overwriting.`);
3047
+ }
3048
+ const chapterPath = ['.mdx', '.md']
3049
+ .map((extension) => `content/docs/${slug}${extension}`)
3050
+ .find((path) => existsSync(join(REPOSITORY_ROOT, path)));
3051
+ const titleCase = (slug.split('/').at(-1) ?? slug)
3052
+ .split('-')
3053
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
3054
+ .join(' ');
3055
+
3056
+ if (chapterPath === undefined) {
3057
+ return {
3058
+ path: relPath,
3059
+ contents: `---
3060
+ title: ${titleCase}
3061
+ description: One sentence on what the student can do after this lesson.
3062
+ teaches: []
3063
+ assumptions: []
3064
+ ---
3065
+
3066
+ {/* A slides-first lesson: this deck is the lesson. ## opens a section, ### is a frame
3067
+ whose title is the claim it shows (rule D3); the title frame is generated. */}
3068
+
3069
+ ## The question
3070
+
3071
+ ### The claim this frame shows
3072
+
3073
+ - One line per point the frame makes
3074
+
3075
+ <Notes>
3076
+ What you say on this frame: the reasoning, the transition, the question to ask.
3077
+ </Notes>
3078
+ `,
3079
+ companions: [
3080
+ {
3081
+ path: `content/docs/${slug}.checks.yml`,
3082
+ contents: `# Assessment sets for this lesson. Ids resolve against content/assessments/.\n[]\n`,
3083
+ },
3084
+ ],
3085
+ };
3086
+ }
3087
+
3088
+ const raw = readFileSync(join(REPOSITORY_ROOT, chapterPath), 'utf8');
3089
+ const { frontmatter, content } = parseFrontmatter(raw) as {
3090
+ frontmatter?: Record<string, unknown>;
3091
+ content: string;
3092
+ };
3093
+ const data = frontmatter ?? {};
3094
+ const title = typeof data.title === 'string' ? data.title : titleCase;
3095
+ const description = typeof data.description === 'string' ? data.description : '';
3096
+ // The chapter's outline, with every numbered float under the heading it
3097
+ // follows; the frame draft is one per subsection, or per section without one.
3098
+ const drafts = chapterOutline(content);
3099
+
3100
+ const out: string[] = [
3101
+ '---',
3102
+ `title: ${JSON.stringify(title)}`,
3103
+ ...(description ? [`description: ${JSON.stringify(description)}`] : []),
3104
+ '---',
3105
+ '',
3106
+ `{/* Drafted by \`content new deck\` from ${chapterPath}. Rewrite each frame title as the claim it`,
3107
+ ' shows (rule D3), keep to what fits the frame (rule D2), and put what you say in its Notes',
3108
+ ' (rule D4). Each Recall shows the chapter\'s own float, with its number (rule D8). */}',
3109
+ '',
3110
+ ];
3111
+ const frame = (draft: OutlineDraft) => draftFrame(out, draft);
3112
+ drafts.forEach((draft, index) => {
3113
+ if (draft.level === 2) {
3114
+ out.push(`## ${draft.title}`, '');
3115
+ // A section with no subsection is drafted as one frame of its own.
3116
+ if (drafts[index + 1]?.level !== 3 || draft.recalls.length > 0) frame(draft);
3117
+ } else {
3118
+ frame(draft);
3119
+ }
3120
+ });
3121
+ if (drafts.length === 0) {
3122
+ out.push('## The question', '', '### The claim this frame shows', '', '<Notes>', 'What you say on this frame.', '</Notes>', '');
3123
+ }
3124
+ return { path: relPath, contents: `${out.join('\n').trimEnd()}\n`, companions: [] };
3125
+ }
3126
+
3127
+ /** A part's page, `content/docs/<dir>/index.mdx` (or `.md`), repository-relative, if it has one. */
3128
+ function partPagePath(dir: string): string | undefined {
3129
+ return ['.mdx', '.md']
3130
+ .map((extension) => `content/docs/${dir}/index${extension}`)
3131
+ .find((path) => existsSync(join(REPOSITORY_ROOT, path)));
3132
+ }
3133
+
3134
+ /** The lessons in the order the course's tracks read them (`content/tracks/*.json`, merged). */
3135
+ function trackOrder(): string[] {
3136
+ const directory = join(CONTENT_ROOT, 'tracks');
3137
+ if (!existsSync(directory)) return [];
3138
+ const tracks = readdirSync(directory)
3139
+ .filter((name) => name.endsWith('.json'))
3140
+ .sort()
3141
+ .flatMap((name) => {
3142
+ try {
3143
+ const data = JSON.parse(readFileSync(join(directory, name), 'utf8')) as { id?: unknown; lessons?: unknown };
3144
+ return typeof data.id === 'string' && Array.isArray(data.lessons)
3145
+ ? [{ id: data.id, lessons: data.lessons.filter((lesson): lesson is string => typeof lesson === 'string') }]
3146
+ : [];
3147
+ } catch {
3148
+ return [];
3149
+ }
3150
+ });
3151
+ return mergeTrackOrder(tracks);
3152
+ }
3153
+
3154
+ /** `content new deck <part>`: the part's deck, drafted from its chapters (see {@link scaffoldDeck}). */
3155
+ function scaffoldPartDeck(dir: string): Scaffold {
3156
+ const relPath = `content/docs/${dir}/index.deck.mdx`;
3157
+ if (existsSync(join(REPOSITORY_ROOT, relPath))) {
3158
+ throw new ContentCliError('exists', `${relPath} already exists; not overwriting.`);
3159
+ }
3160
+ const pagePath = partPagePath(dir)!;
3161
+ const page = (parseFrontmatter(readFileSync(join(REPOSITORY_ROOT, pagePath), 'utf8')).frontmatter ?? {}) as Record<
3162
+ string,
3163
+ unknown
3164
+ >;
3165
+ const title = typeof page.title === 'string' ? page.title : dir;
3166
+ const description = typeof page.description === 'string' ? page.description : '';
3167
+ // The part's chapters (not its slides-first lessons), as the tracks read them.
3168
+ const rank = new Map(trackOrder().map((lessonId, index) => [lessonId, index]));
3169
+ const chapters = readdirSync(join(DOCS_ROOT, dir))
3170
+ .filter((name) => /\.mdx?$/.test(name) && !/\.deck\.mdx?$/.test(name) && !/^index\.mdx?$/.test(name))
3171
+ .map((name) => ({ name: name.replace(/\.mdx?$/, ''), path: `content/docs/${dir}/${name}` }))
3172
+ .sort(
3173
+ (left, right) =>
3174
+ (rank.get(`${dir}.${left.name}`) ?? Number.POSITIVE_INFINITY) -
3175
+ (rank.get(`${dir}.${right.name}`) ?? Number.POSITIVE_INFINITY) || left.name.localeCompare(right.name),
3176
+ );
3177
+
3178
+ const out: string[] = [
3179
+ '---',
3180
+ `title: ${JSON.stringify(title)}`,
3181
+ ...(description ? [`description: ${JSON.stringify(description)}`] : []),
3182
+ '---',
3183
+ '',
3184
+ `{/* Drafted by \`content new deck\` from the chapters of ${dir}. A section per chapter names it`,
3185
+ ' with [chapter: <name>]: its divider is the chapter\'s address in this deck. Rewrite each frame',
3186
+ ' title as the claim it shows (rule D3), keep to what fits the frame (rule D2), and put what you',
3187
+ ' say in its Notes (rule D4). Each Recall shows the chapter\'s own float, with its number (rule D8). */}',
3188
+ '',
3189
+ ];
3190
+ for (const chapter of chapters) {
3191
+ const { frontmatter, content } = parseFrontmatter(readFileSync(join(REPOSITORY_ROOT, chapter.path), 'utf8')) as {
3192
+ frontmatter?: Record<string, unknown>;
3193
+ content: string;
3194
+ };
3195
+ const chapterTitle = typeof frontmatter?.title === 'string' ? frontmatter.title : chapter.name;
3196
+ out.push(`## ${chapterTitle} [chapter: ${chapter.name}]`, '');
3197
+ // A frame per section, with every float of the section and its subsections.
3198
+ const sections: Pick<OutlineDraft, 'title' | 'recalls' | 'note'>[] = [];
3199
+ for (const draft of chapterOutline(content)) {
3200
+ const current = sections.at(-1);
3201
+ if (draft.level === 2 || current === undefined) {
3202
+ sections.push({ title: draft.title, recalls: [...draft.recalls], note: draft.note });
3203
+ } else {
3204
+ current.recalls.push(...draft.recalls);
3205
+ if (current.note === '') current.note = draft.note;
3206
+ }
3207
+ }
3208
+ if (sections.length === 0) sections.push({ title: 'The claim this frame shows', recalls: [], note: '' });
3209
+ for (const section of sections) draftFrame(out, section);
3210
+ }
3211
+ if (chapters.length === 0) {
3212
+ out.push('## The question', '', '### The claim this frame shows', '', '<Notes>', 'What you say on this frame.', '</Notes>', '');
3213
+ }
3214
+ return { path: relPath, contents: `${out.join('\n').trimEnd()}\n`, companions: [] };
3215
+ }
3216
+
1268
3217
  export function scaffoldTerm(rawKey: string): Scaffold {
1269
3218
  const key = rawKey.trim();
1270
3219
  if (!ID_PATTERN.test(key)) {