explico 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/CHANGELOG.md +879 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +43 -0
  6. package/dist/compiler/cli-main.js +392 -0
  7. package/dist/compiler/cli.d.ts +424 -9
  8. package/dist/compiler/cli.js +1289 -8
  9. package/dist/compiler/collections.d.ts +88 -1
  10. package/dist/compiler/collections.js +170 -25
  11. package/dist/compiler/course-config.d.ts +1 -1
  12. package/dist/compiler/course-config.js +105 -1
  13. package/dist/compiler/decks.d.ts +161 -0
  14. package/dist/compiler/decks.js +235 -0
  15. package/dist/compiler/exports/combined-html.d.ts +5 -1
  16. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  17. package/dist/compiler/exports/deck-fit.js +90 -0
  18. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  19. package/dist/compiler/exports/export-chapters.js +64 -5
  20. package/dist/compiler/exports/export-pages.d.ts +30 -3
  21. package/dist/compiler/exports/export-pages.js +24 -1
  22. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  23. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  24. package/dist/compiler/exports/latex/cache.js +129 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  26. package/dist/compiler/exports/latex/capture.js +3 -1
  27. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  28. package/dist/compiler/exports/latex/compile.js +12 -1
  29. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  30. package/dist/compiler/exports/latex/deck.js +349 -0
  31. package/dist/compiler/exports/latex/document.d.ts +51 -6
  32. package/dist/compiler/exports/latex/document.js +71 -12
  33. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  34. package/dist/compiler/exports/latex/explico.cls +13 -183
  35. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  36. package/dist/compiler/exports/latex/serialize.js +4 -2
  37. package/dist/compiler/exports/latex-export.d.ts +24 -2
  38. package/dist/compiler/exports/latex-export.js +107 -26
  39. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  40. package/dist/compiler/exports/markdown/serialize.js +85 -8
  41. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  42. package/dist/compiler/exports/markdown-export.js +50 -11
  43. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  44. package/dist/compiler/exports/standalone-html.js +3 -3
  45. package/dist/compiler/exports/static-tree.d.ts +38 -1
  46. package/dist/compiler/exports/static-tree.js +120 -4
  47. package/dist/compiler/knowledge-version.d.ts +26 -2
  48. package/dist/compiler/knowledge-version.js +27 -17
  49. package/dist/compiler/lesson-derivation.d.ts +143 -6
  50. package/dist/compiler/lesson-derivation.js +145 -13
  51. package/dist/compiler/manifest.d.ts +133 -4
  52. package/dist/compiler/manifest.js +359 -58
  53. package/dist/compiler/page-versions.d.ts +160 -0
  54. package/dist/compiler/page-versions.js +198 -0
  55. package/dist/compiler/render-diagnostics.d.ts +5 -1
  56. package/dist/compiler/resolution-report.d.ts +24 -6
  57. package/dist/compiler/resolution-report.js +22 -4
  58. package/dist/compiler/validate-content.d.ts +7 -0
  59. package/dist/compiler/validate-content.js +160 -0
  60. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  61. package/dist/curriculum/competency-graph.d.ts +54 -0
  62. package/dist/curriculum/competency-graph.js +41 -0
  63. package/dist/reference/base-library.d.mts +8 -0
  64. package/dist/reference/base-library.mjs +19 -0
  65. package/dist/reference/consistency.d.ts +8 -4
  66. package/dist/reference/consistency.js +76 -26
  67. package/dist/reference/deck-remark.d.mts +123 -0
  68. package/dist/reference/deck-remark.mjs +600 -0
  69. package/dist/reference/deck-source.d.ts +107 -0
  70. package/dist/reference/deck-source.js +140 -0
  71. package/dist/reference/decks-validate.d.ts +133 -0
  72. package/dist/reference/decks-validate.js +359 -0
  73. package/dist/reference/derived-entries.d.ts +5 -1
  74. package/dist/reference/diagram-fit.d.ts +1 -1
  75. package/dist/reference/figure-symbols.d.ts +23 -6
  76. package/dist/reference/figure-symbols.js +51 -15
  77. package/dist/reference/float-name.d.ts +43 -0
  78. package/dist/reference/float-name.js +58 -0
  79. package/dist/reference/folds-validate.d.ts +1 -1
  80. package/dist/reference/gate-abbreviations.d.ts +1 -1
  81. package/dist/reference/gate-indices.js +5 -4
  82. package/dist/reference/gate-math.d.ts +29 -2
  83. package/dist/reference/gate-math.js +99 -18
  84. package/dist/reference/gate-part-pages.d.ts +1 -1
  85. package/dist/reference/lab-katex-validate.d.ts +10 -6
  86. package/dist/reference/lab-katex-validate.js +17 -9
  87. package/dist/reference/lab-math-scope.js +29 -4
  88. package/dist/reference/math-glyphs.d.mts +25 -0
  89. package/dist/reference/math-glyphs.mjs +189 -25
  90. package/dist/reference/page-notation.d.ts +22 -3
  91. package/dist/reference/page-notation.js +32 -10
  92. package/dist/reference/references.d.ts +14 -1
  93. package/dist/reference/references.js +13 -0
  94. package/dist/reference/registry-scope.d.mts +33 -0
  95. package/dist/reference/registry-scope.mjs +34 -0
  96. package/dist/reference/registry.js +29 -10
  97. package/dist/reference/rehype-deck.d.mts +6 -0
  98. package/dist/reference/rehype-deck.mjs +286 -0
  99. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  100. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  101. package/dist/reference/remark-notation.mjs +249 -40
  102. package/dist/reference/source-lines.d.mts +12 -0
  103. package/dist/reference/source-lines.mjs +65 -0
  104. package/dist/reference/types.d.ts +28 -0
  105. package/dist/rules/catalog.d.ts +21 -4
  106. package/dist/rules/catalog.js +221 -2
  107. package/dist/rules/course-rules.d.ts +5 -1
  108. package/dist/rules/course-rules.js +17 -3
  109. package/dist/rules/course-rules.schema.json +12 -0
  110. package/dist/rules/in-effect.d.ts +13 -3
  111. package/dist/rules/in-effect.js +30 -7
  112. package/dist/rules/rules.schema.json +259 -1
  113. package/dist/rules/settings.d.ts +1 -1
  114. package/dist/session/PreferenceStore.d.ts +6 -0
  115. package/dist/session/PreferenceStore.js +18 -1
  116. package/dist/session/deck-memory.d.ts +116 -0
  117. package/dist/session/deck-memory.js +143 -0
  118. package/dist/session/reading-memory.d.ts +96 -0
  119. package/dist/session/reading-memory.js +133 -0
  120. package/docs/VERSIONING.md +21 -18
  121. package/docs/authoring/README.md +29 -9
  122. package/docs/authoring/agents.md +63 -16
  123. package/docs/authoring/components.md +15 -0
  124. package/docs/authoring/deck.md +588 -0
  125. package/docs/authoring/migrating.md +1045 -16
  126. package/docs/authoring/notation.md +49 -6
  127. package/docs/authoring/structure.md +80 -1
  128. package/docs/authoring/versioning.md +54 -2
  129. package/docs/authoring/visualizations.md +7 -2
  130. package/docs/course-agent/AGENTS.md +37 -0
  131. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  132. package/docs/course-agent/skills/draft-lesson/SKILL.md +37 -0
  133. package/docs/course-agent/skills/migrate-course/SKILL.md +31 -0
  134. package/docs/course-agent/skills/release-content/SKILL.md +24 -0
  135. package/docs/diagram-scaling.md +148 -0
  136. package/docs/privacy.md +59 -0
  137. package/package.json +8 -2
  138. package/src/compiler/astro.ts +60 -21
  139. package/src/compiler/cli-main.ts +469 -0
  140. package/src/compiler/cli.ts +1905 -16
  141. package/src/compiler/collections.ts +284 -25
  142. package/src/compiler/course-config.ts +267 -0
  143. package/src/compiler/decks.ts +455 -0
  144. package/src/compiler/exports/deck-fit.ts +127 -0
  145. package/src/compiler/exports/export-chapters.ts +105 -6
  146. package/src/compiler/exports/export-pages.ts +46 -2
  147. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  148. package/src/compiler/exports/latex/cache.ts +183 -0
  149. package/src/compiler/exports/latex/capture.ts +5 -2
  150. package/src/compiler/exports/latex/compile.ts +18 -0
  151. package/src/compiler/exports/latex/deck.ts +419 -0
  152. package/src/compiler/exports/latex/document.ts +117 -21
  153. package/src/compiler/exports/latex/explico-common.sty +198 -0
  154. package/src/compiler/exports/latex/explico.cls +13 -183
  155. package/src/compiler/exports/latex/serialize.ts +25 -21
  156. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  157. package/src/compiler/exports/latex-export.ts +187 -45
  158. package/src/compiler/exports/markdown/serialize.ts +118 -9
  159. package/src/compiler/exports/markdown-export.ts +56 -11
  160. package/src/compiler/exports/standalone-html.ts +8 -7
  161. package/src/compiler/exports/static-tree.ts +175 -3
  162. package/src/compiler/knowledge-version.ts +56 -21
  163. package/src/compiler/lesson-derivation.ts +282 -16
  164. package/src/compiler/manifest.ts +558 -63
  165. package/src/compiler/page-versions.ts +359 -0
  166. package/src/compiler/resolution-report.ts +38 -7
  167. package/src/compiler/validate-content.ts +212 -0
  168. package/src/components/CourseContents.astro +124 -6
  169. package/src/components/CurriculumMap.astro +49 -1
  170. package/src/components/FloatIndex.astro +1 -1
  171. package/src/components/PartOverview.astro +51 -5
  172. package/src/components/assessments/AssessmentSet.astro +41 -15
  173. package/src/components/block-heading.ts +15 -6
  174. package/src/components/card-carry.ts +7 -4
  175. package/src/components/card-manager.ts +14 -2
  176. package/src/components/card-memory.ts +13 -9
  177. package/src/components/deck/DeckEnhancer.astro +13 -0
  178. package/src/components/deck/deck-context.ts +104 -0
  179. package/src/components/deck/deck-controller.ts +1287 -0
  180. package/src/components/deck/deck-map.ts +91 -0
  181. package/src/components/deck-lane.ts +163 -0
  182. package/src/components/diagram/Diagram.astro +131 -86
  183. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  184. package/src/components/figure/Figure.astro +7 -2
  185. package/src/components/figure/FloatCaption.astro +5 -3
  186. package/src/components/fold/Fold.astro +2 -2
  187. package/src/components/notation/NotationFormula.astro +81 -11
  188. package/src/components/notation/NotationGlossary.astro +132 -1
  189. package/src/components/notation/NotationLayer.astro +15 -7
  190. package/src/components/starlight/AboutPanel.astro +186 -3
  191. package/src/components/starlight/CollapsibleSections.astro +24 -3
  192. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  193. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  194. package/src/components/starlight/LayoutHeader.astro +17 -0
  195. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  196. package/src/components/starlight/LayoutSidebar.astro +807 -21
  197. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  198. package/src/components/starlight/LessonExportActions.astro +27 -9
  199. package/src/components/starlight/LessonFooter.astro +59 -0
  200. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  201. package/src/components/starlight/TextSizeControl.astro +24 -13
  202. package/src/components/starlight/page-marks.ts +139 -0
  203. package/src/components/starlight/section-summary.ts +1 -1
  204. package/src/components/starlight/sidebar-editions.ts +245 -0
  205. package/src/components/table/Table.astro +15 -7
  206. package/src/content-config.ts +87 -1
  207. package/src/curriculum/competency-graph.ts +95 -0
  208. package/src/reference/base-library.mjs +19 -0
  209. package/src/reference/consistency.ts +126 -39
  210. package/src/reference/deck-remark.mjs +600 -0
  211. package/src/reference/deck-source.ts +273 -0
  212. package/src/reference/decks-validate.ts +604 -0
  213. package/src/reference/diagram-layout.ts +3 -1
  214. package/src/reference/figure-symbols.ts +108 -31
  215. package/src/reference/folds-validate.ts +1 -1
  216. package/src/reference/gate-indices.ts +5 -3
  217. package/src/reference/gate-math.ts +169 -25
  218. package/src/reference/lab-katex-validate.ts +30 -15
  219. package/src/reference/lab-math-scope.ts +34 -3
  220. package/src/reference/math-glyphs.mjs +189 -25
  221. package/src/reference/page-notation.ts +69 -15
  222. package/src/reference/references.ts +28 -0
  223. package/src/reference/registry-scope.mjs +34 -0
  224. package/src/reference/registry.ts +41 -11
  225. package/src/reference/rehype-deck.mjs +286 -0
  226. package/src/reference/rehype-heading-anchors.mjs +48 -6
  227. package/src/reference/remark-notation.mjs +249 -40
  228. package/src/reference/source-lines.mjs +65 -0
  229. package/src/reference/types.ts +28 -0
  230. package/src/rules/catalog.ts +257 -4
  231. package/src/rules/course-rules.ts +21 -1
  232. package/src/rules/in-effect.ts +54 -8
  233. package/src/session/PreferenceStore.ts +23 -0
  234. package/src/session/deck-memory.ts +263 -0
  235. package/src/session/reading-memory.ts +243 -0
  236. package/src/styles/deck.css +1150 -0
  237. package/src/styles/global.css +958 -27
  238. package/src/styles/zones.css +277 -0
  239. /package/src/{components → reference}/float-name.ts +0 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * A page's own content version (rule C5): the knowledge version its content
3
+ * last changed in, as against the course's, which moves at every release.
4
+ *
5
+ * A build sees one commit, and "unchanged since 0.2.0" is history, so the
6
+ * record is a committed file, `content/versions.json`, kept like a lockfile:
7
+ * `pnpm validate:content` works the next record out from the committed one
8
+ * and the content tree, writes it back on a developer's machine, and in CI
9
+ * (`CI` set) fails when the committed file is not the one it would write.
10
+ * The manifest carries each chapter's and part page's `since` either way,
11
+ * computed from the committed record by the same rules, so a build never
12
+ * depends on the file having been rewritten.
13
+ *
14
+ * What counts as the page: its own file, its `.checks.yml` and the
15
+ * assessments it lists, the shared notation entries its text uses, the
16
+ * sources it cites, and its numbers (`part.chapter` and the float offsets its
17
+ * earlier chapters leave it). A lab, `content/domain/`, a part's label or the
18
+ * engine change a page's PDF without changing its stamp.
19
+ *
20
+ * The three rules, applied to the committed entry of each page:
21
+ *
22
+ * 1. The record's `version` is not `about.version`: a release came after the
23
+ * file was written, and a release takes every change `## [Unreleased]`
24
+ * held. Every page with a `pending` hash, or never released, is now
25
+ * `since` that version.
26
+ * 2. The page hashes as recorded: `since` stays. A pending edit undone
27
+ * (the page hashes as it did at `since`) is unchanged again.
28
+ * 3. The page changed: with bullets under `## [Unreleased]` the change is
29
+ * not yet released (`pending`); with none it is the release's own, or a
30
+ * fix rule C1 lets go unlisted, and `since` is `about.version`.
31
+ *
32
+ * Pure except `readPageVersions` and `syncPageVersions`, which do the file IO.
33
+ */
34
+
35
+ import { createHash } from 'node:crypto';
36
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
37
+ import { join } from 'node:path';
38
+
39
+ import type { KnowledgeVersionDiagnostic } from './knowledge-version';
40
+
41
+ /** Repo-relative path of the record. */
42
+ export const PAGE_VERSIONS_FILE = 'content/versions.json';
43
+ export const PAGE_VERSIONS_GENERATED_BY = 'pnpm validate:content (rule C5); do not edit';
44
+ /**
45
+ * The format of the record and of the hashes in it. A record of another
46
+ * format keeps every page's `since` and takes the hashes as they are now, so
47
+ * a change to what is hashed never marks every page changed.
48
+ */
49
+ export const PAGE_VERSIONS_FORMAT = 1;
50
+
51
+ export interface PageVersionEntry {
52
+ /** The release the page's content last changed in; `null` before its first. */
53
+ readonly since: string | null;
54
+ /** `sha256:…` of the page's inputs as of `since`. */
55
+ readonly source: string;
56
+ /** The inputs' hash now, while they differ from `source` and `## [Unreleased]` has bullets. */
57
+ readonly pending?: string;
58
+ }
59
+
60
+ export interface PageVersionsRecord {
61
+ readonly generatedBy: string;
62
+ readonly format: number;
63
+ /** `about.version` when the record was written. */
64
+ readonly version: string;
65
+ /** By docs slug (`motion/constant-acceleration`, `motion/index`), sorted. */
66
+ readonly pages: Readonly<Record<string, PageVersionEntry>>;
67
+ }
68
+
69
+ /** What the manifest carries for the record (`manifest.pageVersions`). */
70
+ export interface ManifestPageVersions {
71
+ /** False when rule C5 is off: every page then carries the course's version. */
72
+ readonly enabled: boolean;
73
+ /** Repo-relative path of the record, `content/versions.json`. */
74
+ readonly file: string;
75
+ /** The record as `validate:content` should write it. */
76
+ readonly record: PageVersionsRecord;
77
+ }
78
+
79
+ const ENTRY_HASH = /^sha256:[0-9a-f]{64}$/u;
80
+
81
+ function compareSlugs(a: string, b: string): number {
82
+ return a < b ? -1 : a > b ? 1 : 0;
83
+ }
84
+
85
+ /** JSON with recursively sorted keys and a trailing newline, as the resolution reports are written. */
86
+ function stableJson(value: unknown): string {
87
+ const sortKeys = (node: unknown): unknown => {
88
+ if (Array.isArray(node)) return node.map(sortKeys);
89
+ if (node && typeof node === 'object') {
90
+ return Object.fromEntries(
91
+ Object.keys(node as Record<string, unknown>)
92
+ .sort()
93
+ .map((key) => [key, sortKeys((node as Record<string, unknown>)[key])]),
94
+ );
95
+ }
96
+ return node;
97
+ };
98
+ return `${JSON.stringify(sortKeys(value), null, 2)}\n`;
99
+ }
100
+
101
+ /**
102
+ * The hash of one page's inputs: each labelled file digest, in label order,
103
+ * then the page's facts (its numbers) as sorted JSON.
104
+ */
105
+ export function pageSourceDigest(
106
+ inputs: readonly { readonly label: string; readonly digest: string }[],
107
+ facts: Record<string, unknown> = {},
108
+ ): string {
109
+ const hash = createHash('sha256');
110
+ for (const { label, digest } of [...inputs].sort((a, b) => compareSlugs(a.label, b.label))) {
111
+ hash.update(`${label} ${digest}\n`);
112
+ }
113
+ hash.update(stableJson(facts));
114
+ return `sha256:${hash.digest('hex')}`;
115
+ }
116
+
117
+ /**
118
+ * Semver order over `x.y.z`, a prerelease below its release; `null` (not
119
+ * yet released) above everything, since it is the newest state a page can
120
+ * be in. A string that is not semver sorts by text, after every semver.
121
+ */
122
+ export function compareKnowledgeVersions(a: string | null, b: string | null): number {
123
+ if (a === b) return 0;
124
+ if (a === null) return 1;
125
+ if (b === null) return -1;
126
+ const parse = (value: string) => /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/u.exec(value.trim());
127
+ const left = parse(a);
128
+ const right = parse(b);
129
+ if (!left || !right) {
130
+ if (left) return -1;
131
+ if (right) return 1;
132
+ return compareSlugs(a, b);
133
+ }
134
+ for (const index of [1, 2, 3]) {
135
+ const difference = Number(left[index]) - Number(right[index]);
136
+ if (difference !== 0) return Math.sign(difference);
137
+ }
138
+ const leftPre = left[4];
139
+ const rightPre = right[4];
140
+ if (leftPre === rightPre) return 0;
141
+ if (leftPre === undefined) return 1;
142
+ if (rightPre === undefined) return -1;
143
+ const leftParts = leftPre.split('.');
144
+ const rightParts = rightPre.split('.');
145
+ for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
146
+ const l = leftParts[index];
147
+ const r = rightParts[index];
148
+ if (l === undefined) return -1;
149
+ if (r === undefined) return 1;
150
+ const numeric = /^\d+$/u.test(l) && /^\d+$/u.test(r);
151
+ const difference = numeric ? Number(l) - Number(r) : compareSlugs(l, r);
152
+ if (difference !== 0) return Math.sign(difference);
153
+ }
154
+ return 0;
155
+ }
156
+
157
+ /**
158
+ * The version of a document made of several pages: the newest among them,
159
+ * `null` when any page has a change not yet released, `null` for no pages.
160
+ */
161
+ export function newestSince(values: readonly (string | null)[]): string | null {
162
+ if (values.length === 0) return null;
163
+ return values.reduce<string | null>(
164
+ (newest, value) => (compareKnowledgeVersions(value, newest) > 0 ? value : newest),
165
+ values[0]!,
166
+ );
167
+ }
168
+
169
+ /** The version a page's entry gives it: `null` while it has a change not yet released. */
170
+ export function sinceOf(entry: PageVersionEntry): string | null {
171
+ return entry.pending !== undefined || entry.since === null ? null : entry.since;
172
+ }
173
+
174
+ /**
175
+ * The phrase every export prints for a document's version (rule C5):
176
+ * "unchanged since version 0.2.0", or, for a change not yet released, "with
177
+ * changes after version 0.4.0, not yet released", 0.4.0 being the newest
178
+ * release, the last one the document agrees with.
179
+ */
180
+ export function versionPhrase(since: string | null, current: string): string {
181
+ return since === null
182
+ ? `with changes after version ${current}, not yet released`
183
+ : `unchanged since version ${since}`;
184
+ }
185
+
186
+ export interface NextPageVersionsInput {
187
+ /** The committed record, or `undefined` for a course without one. */
188
+ readonly previous: PageVersionsRecord | undefined;
189
+ /** `about.version`. */
190
+ readonly version: string;
191
+ /** Whether `## [Unreleased]` holds bullets. */
192
+ readonly unreleased: boolean;
193
+ /** Every tracked page, with its inputs' hash now. */
194
+ readonly pages: readonly { readonly slug: string; readonly source: string }[];
195
+ }
196
+
197
+ /** The record `validate:content` writes: the committed one, brought to the content as it is now. */
198
+ export function nextPageVersions(input: NextPageVersionsInput): PageVersionsRecord {
199
+ const { previous, version, unreleased } = input;
200
+ const released = previous !== undefined && previous.version !== version;
201
+ const sameFormat = previous !== undefined && previous.format === PAGE_VERSIONS_FORMAT;
202
+ const pages: Record<string, PageVersionEntry> = {};
203
+ for (const page of [...input.pages].sort((a, b) => compareSlugs(a.slug, b.slug))) {
204
+ const current = page.source;
205
+ let entry = previous?.pages[page.slug];
206
+ if (entry && !sameFormat) {
207
+ // Another format hashed differently: the page keeps its version, and
208
+ // whether it has a pending change, with the hash as it is now.
209
+ entry = { since: entry.since, source: current, ...(entry.pending === undefined ? {} : { pending: current }) };
210
+ }
211
+ if (entry && released && (entry.pending !== undefined || entry.since === null)) {
212
+ // 1. The release took every change that was waiting.
213
+ entry = { since: version, source: entry.pending ?? entry.source };
214
+ }
215
+ if (entry === undefined) {
216
+ pages[page.slug] = unreleased ? { since: null, source: current } : { since: version, source: current };
217
+ } else if ((entry.pending ?? entry.source) === current) {
218
+ // 2. As recorded.
219
+ pages[page.slug] = entry;
220
+ } else if (entry.pending !== undefined && entry.source === current) {
221
+ // 2. A pending edit undone: the page is as it was at `since`.
222
+ pages[page.slug] = { since: entry.since, source: entry.source };
223
+ } else if (unreleased) {
224
+ // 3. Changed, and the change waits for a release.
225
+ pages[page.slug] =
226
+ entry.since === null ? { since: null, source: current } : { since: entry.since, source: entry.source, pending: current };
227
+ } else {
228
+ // 3. Changed with nothing waiting: the release's own change, or one rule C1 lets go unlisted.
229
+ pages[page.slug] = { since: version, source: current };
230
+ }
231
+ }
232
+ return { generatedBy: PAGE_VERSIONS_GENERATED_BY, format: PAGE_VERSIONS_FORMAT, version, pages };
233
+ }
234
+
235
+ /** The record's text as `syncPageVersions` writes it. */
236
+ export function serializePageVersions(record: PageVersionsRecord): string {
237
+ return stableJson(record);
238
+ }
239
+
240
+ /**
241
+ * The committed record, or `undefined` when there is none. A file that is
242
+ * not a record (hand-edited, truncated) is reported as `page-versions-invalid`
243
+ * and treated as none: every page then counts as new.
244
+ */
245
+ export function readPageVersions(
246
+ path: string,
247
+ file: string = PAGE_VERSIONS_FILE,
248
+ ): { readonly record: PageVersionsRecord | undefined; readonly diagnostics: readonly KnowledgeVersionDiagnostic[] } {
249
+ if (!existsSync(path)) return { record: undefined, diagnostics: [] };
250
+ const invalid = (reason: string) => ({
251
+ record: undefined,
252
+ diagnostics: [
253
+ {
254
+ code: 'page-versions-invalid' as const,
255
+ severity: 'warning' as const,
256
+ message: `${file} is not a record validate:content wrote (${reason}); it is rebuilt, and every page counts as new. Restore the committed file to keep the pages' versions (rule C5).`,
257
+ file,
258
+ },
259
+ ],
260
+ });
261
+ let parsed: unknown;
262
+ try {
263
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
264
+ } catch (error) {
265
+ return invalid(error instanceof Error ? error.message : String(error));
266
+ }
267
+ if (!parsed || typeof parsed !== 'object') return invalid('not an object');
268
+ const { version, format, pages, generatedBy } = parsed as Record<string, unknown>;
269
+ if (typeof version !== 'string' || typeof format !== 'number' || !pages || typeof pages !== 'object') {
270
+ return invalid('missing version, format or pages');
271
+ }
272
+ const entries: Record<string, PageVersionEntry> = {};
273
+ for (const [slug, value] of Object.entries(pages as Record<string, unknown>)) {
274
+ if (!value || typeof value !== 'object') return invalid(`page ${slug} is not an object`);
275
+ const { since, source, pending } = value as Record<string, unknown>;
276
+ if (since !== null && typeof since !== 'string') return invalid(`page ${slug}: since must be a version or null`);
277
+ if (typeof source !== 'string' || !ENTRY_HASH.test(source)) return invalid(`page ${slug}: source is not a sha256`);
278
+ if (pending !== undefined && (typeof pending !== 'string' || !ENTRY_HASH.test(pending))) {
279
+ return invalid(`page ${slug}: pending is not a sha256`);
280
+ }
281
+ entries[slug] = { since, source, ...(pending === undefined ? {} : { pending }) };
282
+ }
283
+ return {
284
+ record: { generatedBy: typeof generatedBy === 'string' ? generatedBy : '', format, version, pages: entries },
285
+ diagnostics: [],
286
+ };
287
+ }
288
+
289
+ /** The one error rule C5 raises: frozen, and the committed record is not the content's. */
290
+ export interface PageVersionsStaleDiagnostic {
291
+ readonly code: 'page-versions-stale';
292
+ readonly severity: 'error';
293
+ readonly message: string;
294
+ readonly file: string;
295
+ }
296
+
297
+ export interface PageVersionsSync {
298
+ /** Absolute path of the record. */
299
+ readonly path: string;
300
+ /** The record on disk was not the one the content gives. */
301
+ readonly stale: boolean;
302
+ /** The record was written (never when frozen). */
303
+ readonly written: boolean;
304
+ /** Pages whose entry changed, or `['*']` for a record written first. */
305
+ readonly changed: readonly string[];
306
+ /** Set when frozen and stale: the error that fails the build. */
307
+ readonly diagnostic?: PageVersionsStaleDiagnostic;
308
+ }
309
+
310
+ /** `CI` set (and not `false` or `0`): the record is frozen, like a lockfile. */
311
+ export function pageVersionsFrozen(): boolean {
312
+ const ci = process.env.CI;
313
+ return !!ci && ci !== 'false' && ci !== '0';
314
+ }
315
+
316
+ /**
317
+ * Bring `content/versions.json` to what the manifest computed. On a
318
+ * developer's machine a stale record is rewritten and the caller logs the
319
+ * pages that moved; frozen (CI by default) a stale record is left as it is
320
+ * and the result carries a `page-versions-stale` error, so a content change
321
+ * cannot land without its record.
322
+ */
323
+ export function syncPageVersions(
324
+ manifest: { readonly pageVersions: ManifestPageVersions },
325
+ options: { readonly frozen?: boolean; readonly root?: string } = {},
326
+ ): PageVersionsSync {
327
+ const root = options.root ?? process.cwd();
328
+ const { enabled, file, record } = manifest.pageVersions;
329
+ const path = join(root, file);
330
+ if (!enabled) return { path, stale: false, written: false, changed: [] };
331
+ const expected = serializePageVersions(record);
332
+ const onDisk = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
333
+ if (onDisk === expected) return { path, stale: false, written: false, changed: [] };
334
+ const previous = onDisk === undefined ? undefined : readPageVersions(path, file).record;
335
+ const changed =
336
+ previous === undefined
337
+ ? ['*']
338
+ : [...new Set([...Object.keys(previous.pages), ...Object.keys(record.pages)])]
339
+ .filter((slug) => JSON.stringify(previous.pages[slug] ?? null) !== JSON.stringify(record.pages[slug] ?? null))
340
+ .sort(compareSlugs);
341
+ const frozen = options.frozen ?? pageVersionsFrozen();
342
+ if (frozen) {
343
+ const moved = changed[0] === '*' ? 'the file is missing' : `${changed.length} page(s): ${changed.slice(0, 6).join(', ')}${changed.length > 6 ? ', …' : ''}`;
344
+ return {
345
+ path,
346
+ stale: true,
347
+ written: false,
348
+ changed,
349
+ diagnostic: {
350
+ code: 'page-versions-stale',
351
+ severity: 'error',
352
+ message: `${file} is out of date (${moved}). Run \`pnpm validate:content\` and commit ${file} with the change (rule C5).`,
353
+ file,
354
+ },
355
+ };
356
+ }
357
+ writeFileSync(path, expected);
358
+ return { path, stale: true, written: true, changed };
359
+ }
@@ -9,13 +9,16 @@
9
9
  * lesson — plus `resolution/consistency-report.json` for the Tier-3 corpus
10
10
  * checks. `pnpm validate:content` regenerates them and **fails if a committed
11
11
  * file is stale**, so a drift between content and the checked-in report cannot
12
- * pass CI unnoticed.
12
+ * pass CI unnoticed. A report git does not track (a course that ignores
13
+ * `build/`) is refreshed and reported, never a failure: nothing could be
14
+ * committed, so nothing can be stale, and the first run passes.
13
15
  *
14
16
  * The reports are deterministic: keys are sorted recursively, rows follow the
15
17
  * manifest's stable ordering, and nothing records a timestamp or a content
16
18
  * hash — a file changes only when a resolution or a finding changes.
17
19
  */
18
20
 
21
+ import { execFileSync } from 'node:child_process';
19
22
  import {
20
23
  existsSync,
21
24
  mkdirSync,
@@ -100,14 +103,38 @@ export function buildResolutionReports(
100
103
 
101
104
  export interface ResolutionReportSync {
102
105
  readonly written: number;
103
- /** Repo-relative paths that were out of date (created, changed, or removed). */
106
+ /**
107
+ * Repo-relative paths git tracks that were out of date (created, changed,
108
+ * or removed): the caller fails on these so the checked-in reports stay honest.
109
+ */
104
110
  readonly stale: readonly string[];
111
+ /** Paths rewritten or removed that git does not track: refreshed, nothing to commit. */
112
+ readonly refreshed: readonly string[];
113
+ }
114
+
115
+ /**
116
+ * The report files git tracks under `dir`, by name. Empty when `dir` is
117
+ * ignored, outside a repository, or git is not on the path: then no report
118
+ * can be committed, so none can be stale.
119
+ */
120
+ export function trackedReportNames(dir: string): Set<string> {
121
+ try {
122
+ const out = execFileSync('git', ['ls-files', '-z', '--', '.'], {
123
+ cwd: dir,
124
+ stdio: ['ignore', 'pipe', 'ignore'],
125
+ encoding: 'utf8',
126
+ });
127
+ return new Set(out.split('\0').filter((name) => name !== ''));
128
+ } catch {
129
+ return new Set();
130
+ }
105
131
  }
106
132
 
107
133
  /**
108
134
  * Write every expected report and prune orphans. Returns the paths that did not
109
- * already match on disk — the caller turns a non-empty list into a build
110
- * failure so the checked-in reports stay honest.
135
+ * already match on disk, split by whether git tracks them — the caller turns a
136
+ * non-empty `stale` list into a build failure so the checked-in reports stay
137
+ * honest, and prints `refreshed` as information.
111
138
  */
112
139
  export function syncResolutionReports(
113
140
  manifest: Manifest,
@@ -120,7 +147,9 @@ export function syncResolutionReports(
120
147
  ? readdirSync(RESOLUTION_DIR).filter((name) => name.endsWith('.json'))
121
148
  : [],
122
149
  );
150
+ const tracked = trackedReportNames(RESOLUTION_DIR);
123
151
  const stale: string[] = [];
152
+ const refreshed: string[] = [];
124
153
 
125
154
  for (const [relPath, content] of [...expected].sort(([a], [b]) =>
126
155
  a.localeCompare(b),
@@ -133,7 +162,7 @@ export function syncResolutionReports(
133
162
  : undefined;
134
163
  if (current !== content) {
135
164
  writeFileSync(absolute, content);
136
- stale.push(
165
+ (tracked.has(name) ? stale : refreshed).push(
137
166
  `${relPath} (${current === undefined ? 'missing' : 'changed'})`,
138
167
  );
139
168
  }
@@ -141,10 +170,12 @@ export function syncResolutionReports(
141
170
 
142
171
  for (const orphan of [...onDisk].sort()) {
143
172
  rmSync(join(RESOLUTION_DIR, orphan));
144
- stale.push(`${RESOLUTION_PREFIX}${orphan} (stale, removed)`);
173
+ (tracked.has(orphan) ? stale : refreshed).push(
174
+ `${RESOLUTION_PREFIX}${orphan} (stale, removed)`,
175
+ );
145
176
  }
146
177
 
147
- return { written: expected.size, stale };
178
+ return { written: expected.size, stale, refreshed };
148
179
  }
149
180
 
150
181
  export { RESOLUTION_DIR };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `pnpm validate:content` — regenerate `build/manifest.json` and report its
3
+ * diagnostics, whole. A course's `scripts/compile-manifest.ts` is three lines:
4
+ *
5
+ * import { runValidateContent } from 'explico/compiler/validate-content';
6
+ * import { courseConfig } from '../content/course.config';
7
+ * process.exitCode = await runValidateContent(courseConfig);
8
+ *
9
+ * It compiles the one manifest (the single walk of the content tree), writes
10
+ * it deterministically, prints the warnings, refreshes the per-lesson
11
+ * resolution reports, `content/versions.json` (rule C5) and the
12
+ * rules-in-effect page, and returns 1 if any blocking diagnostic is present.
13
+ * `pnpm build` runs it first, so `astro build` and every page component read a
14
+ * current manifest and never re-read the content tree.
15
+ *
16
+ * Every line printed is also recorded, in Python's `logging` format, to
17
+ * `build/logs/<timestamp>/{error,warning,info}.log` (`./logging`); `log.<level>()`
18
+ * returns its message so the printed line and the recorded one cannot drift.
19
+ */
20
+ import { notationSourceLocatorGaps } from './collections';
21
+ import type { CourseConfig } from './course-config';
22
+ import { createRunLog, diagnosticLine } from './logging';
23
+ import { compileManifest, MANIFEST_PATH, writeManifest } from './manifest';
24
+ import { syncPageVersions } from './page-versions';
25
+ import { syncResolutionReports } from './resolution-report';
26
+ import { consistencyCounts } from '../reference/consistency';
27
+ import { curriculumErrors } from '../curriculum/validation';
28
+ import { writeRulesInEffect } from '../rules/in-effect';
29
+
30
+ export async function runValidateContent(courseConfig: CourseConfig): Promise<number> {
31
+ const log = createRunLog();
32
+
33
+ let manifest;
34
+ try {
35
+ manifest = await compileManifest(courseConfig);
36
+ } catch (error) {
37
+ // The log keeps why the run ended; the console gets the message, not a stack.
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ log.error('manifest', `Manifest compilation failed: ${message}`);
40
+ const summary = log.close();
41
+ console.error(`Manifest compilation failed: ${message}\nRun log: ${summary.relativeDirectory}.`);
42
+ return 1;
43
+ }
44
+ writeManifest(manifest);
45
+
46
+ const {
47
+ curriculum,
48
+ notation,
49
+ alignment,
50
+ math,
51
+ consistency,
52
+ equations,
53
+ tables,
54
+ figures,
55
+ diagrams,
56
+ checks,
57
+ examples,
58
+ folds,
59
+ knowledge,
60
+ rawFloats,
61
+ config,
62
+ waivers,
63
+ } = manifest.diagnostics;
64
+
65
+ for (const warning of curriculum.filter((issue) => issue.severity === 'warning')) {
66
+ console.warn(log.warning('curriculum', `[${warning.kind}] ${warning.message}`));
67
+ }
68
+ for (const warning of notation.filter((d) => d.severity === 'warning')) {
69
+ console.warn(log.warning('notation', `[${warning.code}] ${warning.message}`));
70
+ }
71
+ for (const warning of alignment.filter((d) => d.severity === 'warning')) {
72
+ console.warn(log.warning('alignment', `[${warning.code}] ${warning.message}`));
73
+ }
74
+ const floatGroups = [
75
+ ['equations', equations],
76
+ ['tables', tables],
77
+ ['figures', figures],
78
+ ['diagrams', diagrams],
79
+ ['checks', checks],
80
+ ['examples', examples],
81
+ ['folds', folds],
82
+ ['rawFloats', rawFloats],
83
+ ] as const;
84
+ for (const [source, group] of floatGroups) {
85
+ for (const warning of group.filter((d) => d.severity === 'warning')) {
86
+ console.warn(log.warning(source, `[${warning.code}] ${warning.message}`));
87
+ }
88
+ }
89
+ // `diagnosticLine` prefixes a code owned by a lettered rule with its ID
90
+ // (`[N9 prose-notation] file:line: …`), so the console names the rule page.
91
+ for (const issue of math.filter((item) => item.severity === 'warning')) {
92
+ console.warn(log.warning('math', diagnosticLine(issue)));
93
+ }
94
+ for (const warning of knowledge) {
95
+ console.warn(log.warning('knowledge', `[${warning.code}] ${warning.message}`));
96
+ }
97
+ // The course settings themselves: a deprecated top-level block, a retired rule.
98
+ for (const warning of config) {
99
+ console.warn(log.warning('config', `[${warning.code}] ${warning.message}`));
100
+ }
101
+ // Per-place waivers: a stale one is a warning; a malformed or invalid one
102
+ // fails below with the other errors.
103
+ for (const warning of waivers.filter((d) => d.severity === 'warning')) {
104
+ console.warn(log.warning('waivers', diagnosticLine(warning)));
105
+ }
106
+
107
+ const locatorGaps = notationSourceLocatorGaps();
108
+ if (locatorGaps.bare > 0) {
109
+ console.warn(
110
+ log.warning(
111
+ 'consistency',
112
+ `[notation-source-locator] ${locatorGaps.bare} of ${locatorGaps.total} notation source citations lack a non-empty locator.`,
113
+ ),
114
+ );
115
+ }
116
+
117
+ const consistencyWarnings = consistency.filter((diagnostic) => diagnostic.severity === 'warning');
118
+ if (consistencyWarnings.length > 0) {
119
+ console.warn(
120
+ log.warning(
121
+ 'consistency',
122
+ `[consistency] ${consistencyWarnings.length} Tier-3 finding(s): ` +
123
+ Object.entries(consistencyCounts(consistencyWarnings))
124
+ .filter(([, count]) => count > 0)
125
+ .map(([code, count]) => `${code}=${count}`)
126
+ .join(', '),
127
+ ),
128
+ );
129
+ }
130
+
131
+ // Every severity-`info` diagnostic goes to the log only.
132
+ log.infoDiagnostics(manifest.diagnostics);
133
+ for (const diagnostic of consistencyWarnings) {
134
+ log.info('consistency', `[${diagnostic.code}] ${diagnostic.message}`);
135
+ }
136
+
137
+ const reports = syncResolutionReports(manifest);
138
+ console.log(
139
+ log.info(
140
+ 'manifest',
141
+ `Resolution report: ${reports.written} file(s) under resolution/` +
142
+ (reports.refreshed.length > 0
143
+ ? ` (${reports.refreshed.length} refreshed; git does not track them, nothing to commit).`
144
+ : '.'),
145
+ ),
146
+ );
147
+ // Each page's own version (rule C5): rewritten here and committed with the
148
+ // change; in CI a record that would change fails below, like a lockfile.
149
+ const pageVersions = syncPageVersions(manifest);
150
+ if (pageVersions.written) {
151
+ console.log(
152
+ log.info(
153
+ 'page-versions',
154
+ `content/versions.json ${
155
+ pageVersions.changed[0] === '*' ? 'written' : `updated for ${pageVersions.changed.length} page(s)`
156
+ } — commit it with the change (rule C5).`,
157
+ ),
158
+ );
159
+ }
160
+ // The page an agent reads before drafting. Regenerated every run, never committed.
161
+ writeRulesInEffect(manifest);
162
+ console.log(
163
+ log.info('manifest', `Rules in effect: build/rules-in-effect.md (${manifest.rules.disabled.length} rule(s) off).`),
164
+ );
165
+
166
+ const failures: string[] = [];
167
+ // `block(source, …)`: a blocking finding under its log source (not a diagnostic code).
168
+ const block = (source: string, message: string): void => {
169
+ failures.push(log.error(source, message));
170
+ };
171
+ for (const stalePath of reports.stale) {
172
+ block('resolution-report', `[resolution-report] ${stalePath} is out of date — commit the regenerated resolution/ files`);
173
+ }
174
+ if (pageVersions.diagnostic) block('page-versions', diagnosticLine(pageVersions.diagnostic));
175
+ for (const issue of curriculumErrors(curriculum)) block('curriculum', `[curriculum:${issue.kind}] ${issue.message}`);
176
+ for (const issue of notation.filter((d) => d.severity === 'error')) block('notation', `[notation:${issue.code}] ${issue.message}`);
177
+ for (const issue of alignment.filter((d) => d.severity === 'error')) block('alignment', `[alignment:${issue.code}] ${issue.message}`);
178
+ for (const issue of math.filter((item) => item.severity === 'error')) block('math', diagnosticLine(issue));
179
+ for (const issue of waivers.filter((d) => d.severity === 'error')) block('waivers', diagnosticLine(issue));
180
+ for (const [source, group] of floatGroups) {
181
+ for (const issue of group.filter((d) => d.severity === 'error')) block(source, `[${issue.code}] ${issue.message}`);
182
+ }
183
+
184
+ if (failures.length > 0) {
185
+ const summary = log.close();
186
+ console.error(
187
+ `Content manifest has ${failures.length} blocking diagnostic(s):\n${failures.map((line) => `- ${line}`).join('\n')}\n` +
188
+ `Run log: ${summary.relativeDirectory} (${summary.counts.ERROR} error(s), ${summary.counts.WARNING} warning(s)).`,
189
+ );
190
+ return 1;
191
+ }
192
+
193
+ const itemCount = manifest.lessons.reduce((count, lesson) => count + lesson.assessments.length, 0);
194
+ const relPath = MANIFEST_PATH.replace(`${process.cwd()}/`, '');
195
+ console.log(
196
+ log.info(
197
+ 'manifest',
198
+ `Manifest written to ${relPath} (schema v${manifest.schemaVersion}, knowledge v${manifest.about.knowledge.version}): ` +
199
+ `${manifest.competencies.length} competencies, ${manifest.lessons.length} lessons, ` +
200
+ `${itemCount} lesson assessment links, ${manifest.notation.definitions.length} notation definitions, ` +
201
+ `${manifest.sources.length} sources, ${manifest.tracks.length} track(s). ` +
202
+ `Content fingerprint ${manifest.hashes.contentTree}.`,
203
+ ),
204
+ );
205
+
206
+ const summary = log.close();
207
+ console.log(
208
+ `Run log: ${summary.relativeDirectory} ` +
209
+ `(${summary.counts.ERROR} error(s), ${summary.counts.WARNING} warning(s), ${summary.counts.INFO} info).`,
210
+ );
211
+ return 0;
212
+ }