explico 0.3.0 → 0.4.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 (41) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +21 -2
  3. package/dist/compiler/astro.d.ts +30 -0
  4. package/dist/compiler/astro.js +79 -0
  5. package/dist/compiler/cli.js +5 -9
  6. package/dist/compiler/course-config.d.ts +29 -4
  7. package/dist/compiler/course-config.js +12 -0
  8. package/dist/compiler/lesson-derivation.d.ts +1 -1
  9. package/dist/compiler/lesson-derivation.js +2 -2
  10. package/dist/compiler/manifest.d.ts +25 -4
  11. package/dist/compiler/manifest.js +59 -27
  12. package/dist/reference/equations-validate.d.ts +8 -3
  13. package/dist/reference/equations-validate.js +1 -1
  14. package/dist/reference/equations.d.mts +58 -22
  15. package/dist/reference/equations.mjs +75 -26
  16. package/dist/reference/keyed-float-validate.d.ts +9 -3
  17. package/dist/reference/keyed-float-validate.js +1 -1
  18. package/dist/reference/keyed-jsx-floats.d.mts +18 -10
  19. package/dist/reference/keyed-jsx-floats.mjs +42 -18
  20. package/dist/reference/remark-notation.d.mts +54 -5
  21. package/dist/reference/remark-notation.mjs +161 -17
  22. package/docs/authoring/README.md +4 -0
  23. package/docs/authoring/migrating.md +62 -0
  24. package/docs/authoring/notation.md +8 -7
  25. package/docs/authoring/structure.md +139 -0
  26. package/package.json +1 -1
  27. package/src/compiler/astro.ts +127 -0
  28. package/src/compiler/cli.ts +5 -9
  29. package/src/compiler/course-config.ts +39 -3
  30. package/src/compiler/lesson-derivation.ts +2 -2
  31. package/src/compiler/manifest.ts +108 -36
  32. package/src/components/notation/NotationLayer.astro +11 -0
  33. package/src/components/starlight/LayoutSidebar.astro +62 -1
  34. package/src/components/starlight/LessonFooter.astro +11 -0
  35. package/src/components/table/Table.astro +3 -2
  36. package/src/reference/equations-validate.ts +6 -4
  37. package/src/reference/equations.mjs +75 -26
  38. package/src/reference/keyed-float-validate.ts +7 -4
  39. package/src/reference/keyed-jsx-floats.mjs +42 -18
  40. package/src/reference/remark-notation.mjs +161 -17
  41. package/src/styles/global.css +129 -0
package/CHANGELOG.md CHANGED
@@ -14,6 +14,75 @@ under **Breaking** below; a PATCH never breaks.
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.4.0] - 2026-09-09
18
+
19
+ ### Breaking
20
+
21
+ - **Equation, table, and figure numbers are now `‹part›.‹n›`, not
22
+ `‹lesson›.‹n›`.** A *part* is a sidebar group; `n` counts that kind of float
23
+ across the whole part, running unbroken from one chapter into the next.
24
+ Previously the prefix was the lesson's ordinal down the entire course and `n`
25
+ restarted on every page, so a sixteen-lesson course had sixteen prefixes.
26
+ Every number in an existing course changes. Nothing an author wrote changes —
27
+ numbers were never hand-typed — but a published link of the form `#eq-2-4`
28
+ (the positional anchor of an *unkeyed* equation) moves; a keyed `#eq-<key>`
29
+ anchor is unaffected. Manifest schema is now **v7**: `lessons[].part` is new,
30
+ `lessons[].chapter` is redefined as the lesson's ordinal *within its part*,
31
+ and `lessons[].numbering` carries the per-kind running counts.
32
+ - **`courseConfig.sections` is renamed `courseConfig.parts`.** "Section" now
33
+ only ever means an `##` heading inside a lesson. The old name still works and
34
+ is removed in the next MINOR release; `CourseSection` is a deprecated alias
35
+ for `CoursePart`.
36
+
37
+ ### Added
38
+
39
+ - [`docs/authoring/structure.md`](docs/authoring/structure.md) — the structural
40
+ taxonomy the rest of the docs speak in: course → part → chapter → section →
41
+ subsection, which levels a number counts, and why the numbering invariant is
42
+ build-enforced. Two levels were previously both called "section", and two
43
+ were both called "chapter".
44
+ - `remarkNotation` accepts a `manifest` option that supplies every numbering
45
+ input at once — `equations`, `tables`, `figures`, and the part/offset
46
+ placement map, all read off the same compiled manifest. This is now the recommended wiring in
47
+ `astro.config.mjs`; the individual options remain, and an explicit one still
48
+ wins over the manifest. `numberingFromManifest(manifest)` is exported for
49
+ callers that need the derived object on its own.
50
+ - `explicoContentRefresh(courseConfig, manifest)` recompiles the manifest and
51
+ restarts Astro development after any `content/` change, independent of the
52
+ editor making it.
53
+
54
+ ### Fixed
55
+
56
+ - **Equation, table, and figure numbers no longer disagree between a page and
57
+ the references to it (WERN).** A course that passed the manifest number tables
58
+ to `remarkNotation` without the placement map silently numbered every page by
59
+ `##` section — restarting at `1.1` on each page — while cross-page references
60
+ kept reading the manifest, so one equation was `(2.2)` on its own page and
61
+ `(4.5)` everywhere else. `remarkNotation` now cross-checks the number
62
+ it computes for each keyed equation / table / figure against the manifest
63
+ number for the same key and fails the build on any disagreement, naming the
64
+ key, both numbers, and the missing placement. The same omission was
65
+ present in `explico content check`, which therefore validated lessons under a
66
+ numbering scheme the real build never used; it now shares the manifest wiring.
67
+
68
+ ### Changed
69
+
70
+ - Parts in the left sidebar now start collapsed, retain compact chapter icons
71
+ with hover/focus labels, and expand to the full chapter names when the part
72
+ title is opened. (Previously described as "chapter groups" with "subchapter"
73
+ icons — the same UI, named per the taxonomy.)
74
+
75
+ ### Fixed
76
+
77
+ - `loadManifest(courseConfig)` now always compiles current course content, and
78
+ `LessonFooter` rejects local-notation key drift. Current math hover markers can
79
+ no longer be silently paired with stale cards from a persisted manifest.
80
+ - Notation hover markers inside `\sqrt{…}` (and under an extensible arrow such
81
+ as `\xrightarrow`) are reachable again: KaTeX paints the radical/arrow SVG as
82
+ an absolutely-positioned overlay on top of the content it decorates, which
83
+ was swallowing the pointer. `NotationLayer` now sets `pointer-events: none`
84
+ on that overlay layer, matching the existing fraction/subscript workaround.
85
+
17
86
  ## [0.3.0] - 2026-09-05
18
87
 
19
88
  ### Breaking
@@ -96,7 +165,9 @@ under **Breaking** below; a PATCH never breaks.
96
165
  system with its completeness gate, the curriculum-graph validator, the
97
166
  Astro/Starlight UI, the LMS / session / analytics seams, and the `content` CLI.
98
167
 
99
- [Unreleased]: https://github.com/danieltuzes/explico/compare/v0.2.0...HEAD
168
+ [Unreleased]: https://github.com/danieltuzes/explico/compare/v0.4.0...HEAD
169
+ [0.4.0]: https://github.com/danieltuzes/explico/compare/v0.3.0...v0.4.0
170
+ [0.3.0]: https://github.com/danieltuzes/explico/compare/v0.2.0...v0.3.0
100
171
  [0.2.0]: https://github.com/danieltuzes/explico/compare/v0.1.1...v0.2.0
101
172
  [0.1.1]: https://github.com/danieltuzes/explico/compare/v0.1.0...v0.1.1
102
173
  [0.1.0]: https://github.com/danieltuzes/explico/releases/tag/v0.1.0
package/README.md CHANGED
@@ -69,6 +69,7 @@ export { collections } from 'explico/content-config';
69
69
 
70
70
  ```js
71
71
  // astro.config.mjs
72
+ import { explicoContentRefresh } from 'explico/compiler/astro.js';
72
73
  import { loadManifest } from 'explico/compiler/manifest.js';
73
74
  import remarkNotation from 'explico/reference/remark-notation.mjs';
74
75
  import remarkCitation from 'explico/reference/remark-citation.mjs';
@@ -77,12 +78,30 @@ import rehypeFailKatexErrors from 'explico/reference/rehype-fail-katex-errors.mj
77
78
  import { courseConfig } from './content/course.config.ts';
78
79
 
79
80
  const manifest = await loadManifest(courseConfig);
80
- // … starlight({ customCss: ['explico/styles/global.css'],
81
+ // markdown.remarkPlugins: [
82
+ // remarkMath,
83
+ // // One `manifest` carries every numbering input — the equation / table /
84
+ // // figure number tables *and* the part each lesson sits in. Pass the
85
+ // // tables without the placement and the page numbers its equations by `##`
86
+ // // section while references to it read the part scheme, so the same
87
+ // // equation reads (2.2) at home and (4.5) abroad; the plugin rejects that
88
+ // // at build time rather than shipping it.
89
+ // [remarkNotation, { definitions: manifest.notation.raw, manifest }],
90
+ // [remarkCitation, { sources: manifest.sources }],
91
+ // ]
92
+ // … integrations: [explicoContentRefresh(courseConfig, manifest),
93
+ // starlight({ customCss: ['explico/styles/global.css'],
81
94
  // components: { Footer: 'explico/components/starlight/LessonFooter.astro',
82
95
  // SocialIcons: 'explico/components/starlight/LayoutSocialIcons.astro', … } })
83
- // … markdown.remarkPlugins / rehypePlugins from the manifest
96
+ // … rehypePlugins: [[rehypeKatex, createNotationKatexOptions()], rehypeFailKatexErrors]
84
97
  ```
85
98
 
99
+ `loadManifest(courseConfig)` always recompiles current content before Astro
100
+ starts. `explicoContentRefresh(courseConfig, manifest)` recompiles the manifest
101
+ and restarts the development server when any file under `content/` is added,
102
+ changed, or removed, keeping Markdown transforms and UI components on the same
103
+ model without an editor-specific task.
104
+
86
105
  ```jsonc
87
106
  // package.json — the read-side CLI
88
107
  "scripts": { "validate:content": "tsx scripts/compile-manifest.ts" }
@@ -0,0 +1,30 @@
1
+ import { AstroIntegration } from 'astro';
2
+ import { CourseConfig } from './course-config.js';
3
+ import { Manifest } from './manifest.js';
4
+ import '../curriculum/validation.js';
5
+ import '../reference/consistency.js';
6
+ import './collections.js';
7
+ import '../reference/types.js';
8
+ import '../reference/lint-ignore.js';
9
+ import '../reference/equations-validate.js';
10
+ import '../reference/tables-validate.js';
11
+ import '../reference/keyed-float-validate.js';
12
+ import '../reference/figures-validate.js';
13
+ import '../reference/gate-math.js';
14
+ import '../reference/gloss.js';
15
+ import './knowledge-version.js';
16
+ import './lesson-derivation.js';
17
+
18
+ /** Fail loudly instead of rendering math triggers whose local cards are stale. */
19
+ declare function assertLocalNotationParity(lessonId: string, authoredKeys: readonly string[], renderedKeys: readonly string[]): void;
20
+ /**
21
+ * Restart Astro when an authored content file changes. Astro reloads MDX on
22
+ * its own, but Explico's manifest is created by `astro.config.mjs`; a restart
23
+ * is what makes the remark plugins and page UI consume the same fresh model.
24
+ *
25
+ * Add this integration once to `integrations` in `astro.config.mjs`. The
26
+ * restart is automatic and editor-agnostic (change/add/unlink all work).
27
+ */
28
+ declare function explicoContentRefresh(courseConfig: CourseConfig, manifest: Manifest): AstroIntegration;
29
+
30
+ export { assertLocalNotationParity, explicoContentRefresh };
@@ -0,0 +1,79 @@
1
+ import { resolve, sep } from "node:path";
2
+ import {
3
+ compileManifest,
4
+ writeManifest
5
+ } from "./manifest.js";
6
+ const watchedServers = /* @__PURE__ */ new WeakSet();
7
+ function assertLocalNotationParity(lessonId, authoredKeys, renderedKeys) {
8
+ const authored = [...new Set(authoredKeys)].sort();
9
+ const rendered = [...new Set(renderedKeys)].sort();
10
+ if (authored.length === rendered.length && authored.every((key, index) => key === rendered[index])) {
11
+ return;
12
+ }
13
+ throw new Error(
14
+ `Stale Explico manifest for ${lessonId}: lesson notation.local has [${authored.join(", ")}], but the page notation bundle has [${rendered.join(", ")}]. Pass courseConfig to loadManifest() and add explicoContentRefresh() to the Astro integrations.`
15
+ );
16
+ }
17
+ function replaceInPlace(target, source) {
18
+ if (Array.isArray(target) && Array.isArray(source)) {
19
+ target.splice(0, target.length, ...source);
20
+ return;
21
+ }
22
+ if (!target || !source || typeof target !== "object" || typeof source !== "object") {
23
+ return;
24
+ }
25
+ const mutable = target;
26
+ const fresh = source;
27
+ for (const key of Object.keys(mutable)) {
28
+ if (!(key in fresh)) delete mutable[key];
29
+ }
30
+ for (const [key, value] of Object.entries(fresh)) {
31
+ const current = mutable[key];
32
+ if (Array.isArray(current) && Array.isArray(value) || current && value && typeof current === "object" && typeof value === "object" && !Array.isArray(current) && !Array.isArray(value)) {
33
+ replaceInPlace(current, value);
34
+ } else {
35
+ mutable[key] = value;
36
+ }
37
+ }
38
+ }
39
+ function explicoContentRefresh(courseConfig, manifest) {
40
+ return {
41
+ name: "explico-content-refresh",
42
+ hooks: {
43
+ "astro:server:setup": ({ server }) => {
44
+ if (watchedServers.has(server)) return;
45
+ watchedServers.add(server);
46
+ const contentRoot = `${resolve(process.cwd(), "content")}${sep}`;
47
+ let timer;
48
+ let restarting = false;
49
+ const refresh = (path) => {
50
+ const absolute = resolve(path);
51
+ if (!absolute.startsWith(contentRoot) || restarting) return;
52
+ if (timer) clearTimeout(timer);
53
+ timer = setTimeout(async () => {
54
+ restarting = true;
55
+ try {
56
+ const fresh = await compileManifest(courseConfig);
57
+ writeManifest(fresh);
58
+ replaceInPlace(manifest, fresh);
59
+ await server.restart();
60
+ } catch (error) {
61
+ server.config.logger.error(
62
+ `Explico could not refresh the content manifest: ${error instanceof Error ? error.message : String(error)}`
63
+ );
64
+ } finally {
65
+ restarting = false;
66
+ }
67
+ }, 50);
68
+ };
69
+ server.watcher.on("add", refresh);
70
+ server.watcher.on("change", refresh);
71
+ server.watcher.on("unlink", refresh);
72
+ }
73
+ }
74
+ };
75
+ }
76
+ export {
77
+ assertLocalNotationParity,
78
+ explicoContentRefresh
79
+ };
@@ -121,15 +121,11 @@ function processorFor(manifest) {
121
121
  // shape here that it does there (D7/F-tables-figures parity).
122
122
  remarkMdx,
123
123
  remarkMath,
124
- [
125
- remarkNotation,
126
- {
127
- definitions: manifest.notation.raw,
128
- equations: manifest.equations.numbersBySlug,
129
- tables: manifest.tables.numbersBySlug,
130
- figures: manifest.figures.numbersBySlug
131
- }
132
- ],
124
+ // `manifest` carries the number tables *and* the part/offset placement
125
+ // together, so this check numbers a page exactly as the Astro build
126
+ // does. Passing the tables alone silently numbered pages by `##` section
127
+ // here while cross-page references read the part-scheme manifest.
128
+ [remarkNotation, { definitions: manifest.notation.raw, manifest }],
133
129
  [remarkCitation, { sources: manifest.sources }]
134
130
  ],
135
131
  rehypePlugins: [
@@ -6,12 +6,23 @@
6
6
  * data value and passes it here, typed against `CourseConfig`. A second course
7
7
  * supplies its own value of this shape.
8
8
  */
9
- interface CourseSection {
9
+ /**
10
+ * One **part** — a sidebar group, holding the chapters (lessons) whose slugs
11
+ * start with `dir`. A part's 1-based position in `CourseConfig.parts` is the
12
+ * number before the dot in every equation, table, and figure number inside it.
13
+ * See `docs/authoring/structure.md` for the full level taxonomy.
14
+ */
15
+ interface CoursePart {
10
16
  /** Lesson-slug prefix, e.g. `bonds` for `bonds/yield-to-maturity`. */
11
17
  readonly dir: string;
12
18
  /** Sidebar group heading. */
13
19
  readonly label: string;
14
20
  }
21
+ /**
22
+ * @deprecated Renamed to {@link CoursePart}. "Section" now only ever means an
23
+ * `##` heading inside a lesson. Removed in the next MINOR release.
24
+ */
25
+ type CourseSection = CoursePart;
15
26
  interface CourseConventions {
16
27
  /** Notation key a lesson's stated sign convention must resolve to. */
17
28
  readonly signConventionKey: string;
@@ -68,8 +79,16 @@ interface CourseAbout {
68
79
  interface CourseConfig {
69
80
  readonly title: string;
70
81
  readonly description: string;
71
- /** Sidebar section groups, in order. */
72
- readonly sections: readonly CourseSection[];
82
+ /**
83
+ * The course's parts (sidebar groups), in order. Exactly one of `parts` or
84
+ * the deprecated `sections` must be given.
85
+ */
86
+ readonly parts?: readonly CoursePart[];
87
+ /**
88
+ * @deprecated Renamed to {@link CourseConfig.parts}. Still honoured, so a
89
+ * course on the old name keeps building; removed in the next MINOR release.
90
+ */
91
+ readonly sections?: readonly CoursePart[];
73
92
  /** Namespaces used by competency / notation `domain` fields. */
74
93
  readonly domains: readonly string[];
75
94
  readonly conventions: CourseConventions;
@@ -78,5 +97,11 @@ interface CourseConfig {
78
97
  /** Edits to the shipped named-operator baseline (rule N8). Optional. */
79
98
  readonly operators?: CourseOperators;
80
99
  }
100
+ /**
101
+ * The course's parts, from whichever field the config uses. Throws rather than
102
+ * silently producing a course with no sidebar groups — a config with neither
103
+ * field would otherwise number every float by the `##`-section fallback.
104
+ */
105
+ declare function coursePartsOf(config: CourseConfig): readonly CoursePart[];
81
106
 
82
- export type { CourseAbout, CourseAuthor, CourseConfig, CourseConventions, CourseOperators, CourseSection };
107
+ export { type CourseAbout, type CourseAuthor, type CourseConfig, type CourseConventions, type CourseOperators, type CoursePart, type CourseSection, coursePartsOf };
@@ -0,0 +1,12 @@
1
+ function coursePartsOf(config) {
2
+ const parts = config.parts ?? config.sections;
3
+ if (!parts || parts.length === 0) {
4
+ throw new Error(
5
+ "CourseConfig needs at least one entry in `parts` (a sidebar group: { dir, label }). The old name `sections` is still accepted."
6
+ );
7
+ }
8
+ return parts;
9
+ }
10
+ export {
11
+ coursePartsOf
12
+ };
@@ -84,6 +84,6 @@ type SidebarGroup = {
84
84
  * merged track order (ties broken by slug), then the static Reference group.
85
85
  * Replaces seven `autogenerate` blocks that read an authored `sidebar.order`.
86
86
  */
87
- declare function buildSidebar(tracks: readonly TrackLike[], lessonSlugs: readonly string[], sections: readonly SidebarSection[]): SidebarGroup[];
87
+ declare function buildSidebar(tracks: readonly TrackLike[], lessonSlugs: readonly string[], parts: readonly SidebarSection[]): SidebarGroup[];
88
88
 
89
89
  export { type SidebarGroup, type SidebarSection, buildSidebar, citedSourceIds, deriveRequires, deriveSources, isLessonSlug, lessonIdFromSlug, mergeTrackOrder, parseCheckList };
@@ -59,12 +59,12 @@ function mergeTrackOrder(tracks) {
59
59
  }
60
60
  return ordered;
61
61
  }
62
- function buildSidebar(tracks, lessonSlugs, sections) {
62
+ function buildSidebar(tracks, lessonSlugs, parts) {
63
63
  const rank = new Map(
64
64
  mergeTrackOrder(tracks).map((lessonId, index) => [lessonId, index])
65
65
  );
66
66
  const positionOf = (slug) => rank.get(lessonIdFromSlug(slug)) ?? Number.POSITIVE_INFINITY;
67
- const groups = sections.map(({ dir, label }) => ({
67
+ const groups = parts.map(({ dir, label }) => ({
68
68
  label,
69
69
  items: lessonSlugs.filter((slug) => slug.startsWith(`${dir}/`)).sort(
70
70
  (left, right) => positionOf(left) - positionOf(right) || left.localeCompare(right)
@@ -57,8 +57,12 @@ import '../reference/keyed-float-validate.js';
57
57
  * against `content/CHANGELOG.md`) and `diagnostics.knowledge`.
58
58
  * v6 (chapter numbering): `lessons[].chapter` — the lesson's 1-based course
59
59
  * ordinal, the chapter in every equation/table/figure number (`(2.1)`).
60
+ * v7 (part numbering): `lessons[].part` (the sidebar group's 1-based ordinal,
61
+ * now the number before the dot), `lessons[].chapter` redefined as the
62
+ * lesson's ordinal *within its part*, and `lessons[].numbering` — the
63
+ * per-kind offsets that make `n` run continuously across a part.
60
64
  */
61
- declare const MANIFEST_SCHEMA_VERSION = 6;
65
+ declare const MANIFEST_SCHEMA_VERSION = 7;
62
66
  declare const MANIFEST_PATH: string;
63
67
  interface ManifestCitation {
64
68
  readonly id: string;
@@ -108,11 +112,28 @@ interface ManifestLesson {
108
112
  /** Docs-loader slug, e.g. `bonds/price-yield-relationship`. */
109
113
  readonly slug: string;
110
114
  /**
111
- * The lesson's 1-based ordinal in the course (merged track order, then any
112
- * unranked lessons by slug). It is the chapter in every equation, table, and
113
- * figure number on the page — `(2.1)` is the first equation of chapter 2.
115
+ * The 1-based ordinal of the **part** this lesson sits in — its sidebar
116
+ * group's position in `courseConfig.parts`. It is the number before the dot
117
+ * in every equation, table, and figure number on the page: `(2.1)` is the
118
+ * first equation of part 2. See `docs/authoring/structure.md`.
119
+ */
120
+ readonly part: number;
121
+ /**
122
+ * The lesson's 1-based ordinal **within its part** (merged track order, then
123
+ * any unranked lessons by slug) — its chapter number. This orders the
124
+ * sidebar and the reading path; it does not appear in a float's number.
114
125
  */
115
126
  readonly chapter: number;
127
+ /**
128
+ * How many floats of each kind the earlier lessons of this part already
129
+ * used. The lesson's own floats continue from here, so a part's sequence
130
+ * runs unbroken across its chapters instead of restarting on every page.
131
+ */
132
+ readonly numbering: {
133
+ readonly equations: number;
134
+ readonly tables: number;
135
+ readonly figures: number;
136
+ };
116
137
  readonly file: string;
117
138
  readonly title: string;
118
139
  readonly description: string;
@@ -16,6 +16,8 @@ import {
16
16
  runConsistencyChecks
17
17
  } from "../reference/consistency.js";
18
18
  import { validateNotationAlignment } from "../reference/curriculum-alignment.js";
19
+ import { countDisplayEquations } from "../reference/equations.mjs";
20
+ import { countKeyedJsxFloats } from "../reference/keyed-jsx-floats.mjs";
19
21
  import {
20
22
  validateEquations
21
23
  } from "../reference/equations-validate.js";
@@ -32,6 +34,7 @@ import { loadLintIgnore } from "../reference/lint-ignore.js";
32
34
  import { buildNotationRegistry } from "../reference/registry.js";
33
35
  import { formulaGlyphScope } from "../reference/gloss.js";
34
36
  import { resolveReferenceId } from "../reference/references.js";
37
+ import { coursePartsOf } from "./course-config.js";
35
38
  import {
36
39
  resolveKnowledgeVersion
37
40
  } from "./knowledge-version.js";
@@ -48,7 +51,7 @@ import {
48
51
  isLessonSlug,
49
52
  lessonIdFromSlug
50
53
  } from "./lesson-derivation.js";
51
- const MANIFEST_SCHEMA_VERSION = 6;
54
+ const MANIFEST_SCHEMA_VERSION = 7;
52
55
  const REPOSITORY_ROOT = process.cwd();
53
56
  const CONTENT_ROOT = join(REPOSITORY_ROOT, "content");
54
57
  const MANIFEST_PATH = join(REPOSITORY_ROOT, "build", "manifest.json");
@@ -231,38 +234,61 @@ async function compileManifest(courseConfig) {
231
234
  const sidebar = buildSidebar(
232
235
  catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
233
236
  lessonSlugs,
234
- courseConfig.sections
237
+ coursePartsOf(courseConfig)
235
238
  );
236
- const chapterByLessonId = /* @__PURE__ */ new Map();
237
- let chapterCursor = 0;
239
+ const bodyByLessonId = new Map(
240
+ notationInput.lessons.map((lesson) => [lesson.lessonId, lesson.body])
241
+ );
242
+ const placementByLessonId = /* @__PURE__ */ new Map();
243
+ let partCursor = 0;
238
244
  for (const group of sidebar) {
239
- for (const item of group.items) {
240
- if (typeof item !== "string") continue;
241
- chapterByLessonId.set(lessonIdFromSlug(item), ++chapterCursor);
245
+ const lessonIds = group.items.filter((item) => typeof item === "string").map(lessonIdFromSlug);
246
+ if (lessonIds.length === 0) continue;
247
+ partCursor += 1;
248
+ const used = { equations: 0, tables: 0, figures: 0 };
249
+ let chapterCursor = 0;
250
+ for (const lessonId of lessonIds) {
251
+ const body = bodyByLessonId.get(lessonId) ?? "";
252
+ placementByLessonId.set(lessonId, {
253
+ part: partCursor,
254
+ chapter: ++chapterCursor,
255
+ numbering: { ...used }
256
+ });
257
+ used.equations += countDisplayEquations(body);
258
+ used.tables += countKeyedJsxFloats(body, { tagName: "Table" });
259
+ used.figures += countKeyedJsxFloats(body, { tagName: "Figure" });
242
260
  }
243
261
  }
244
- const keyedFloatLessonInputs = notationInput.lessons.map((lesson) => ({
245
- lessonId: lesson.lessonId,
246
- slug: lesson.lessonId.replace(/\./g, "/"),
247
- file: lesson.source.file,
248
- body: lesson.body,
249
- chapter: chapterByLessonId.get(lesson.lessonId)
250
- }));
251
- const equationValidation = validateEquations(keyedFloatLessonInputs);
262
+ const floatInputs = (kind) => notationInput.lessons.map((lesson) => {
263
+ const placement = placementByLessonId.get(lesson.lessonId);
264
+ return {
265
+ lessonId: lesson.lessonId,
266
+ slug: lesson.lessonId.replace(/\./g, "/"),
267
+ file: lesson.source.file,
268
+ body: lesson.body,
269
+ ...placement ? {
270
+ numbering: {
271
+ prefix: placement.part,
272
+ offset: placement.numbering[kind]
273
+ }
274
+ } : {}
275
+ };
276
+ });
277
+ const equationValidation = validateEquations(floatInputs("equations"));
252
278
  const equationsBySlug = new Map(
253
279
  Object.entries(equationValidation.numbersBySlug).map(([slug, table]) => [
254
280
  slug,
255
281
  Object.entries(table).map(([key, number]) => ({ key, number }))
256
282
  ])
257
283
  );
258
- const tableValidation = validateTables(keyedFloatLessonInputs);
284
+ const tableValidation = validateTables(floatInputs("tables"));
259
285
  const tablesBySlug = new Map(
260
286
  Object.entries(tableValidation.numbersBySlug).map(([slug, table]) => [
261
287
  slug,
262
288
  Object.entries(table).map(([key, number]) => ({ key, number }))
263
289
  ])
264
290
  );
265
- const figureValidation = validateFigures(keyedFloatLessonInputs);
291
+ const figureValidation = validateFigures(floatInputs("figures"));
266
292
  const figuresBySlug = new Map(
267
293
  Object.entries(figureValidation.numbersBySlug).map(([slug, table]) => [
268
294
  slug,
@@ -282,10 +308,16 @@ async function compileManifest(courseConfig) {
282
308
  lessons.push({
283
309
  id,
284
310
  slug: id.replace(/\./g, "/"),
285
- // 0 = the lesson is not placed in any configured section; a curriculum
311
+ // 0 = the lesson is not placed in any configured part; a curriculum
286
312
  // diagnostic covers that separately, and both numbering paths then fall
287
313
  // back to the legacy `##`-section scheme.
288
- chapter: chapterByLessonId.get(id) ?? 0,
314
+ part: placementByLessonId.get(id)?.part ?? 0,
315
+ chapter: placementByLessonId.get(id)?.chapter ?? 0,
316
+ numbering: placementByLessonId.get(id)?.numbering ?? {
317
+ equations: 0,
318
+ tables: 0,
319
+ figures: 0
320
+ },
289
321
  file: notationLesson.source.file,
290
322
  title: frontmatter.title,
291
323
  description: frontmatter.description,
@@ -437,15 +469,15 @@ function readManifestSync() {
437
469
  return JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
438
470
  }
439
471
  async function loadManifest(courseConfig) {
440
- if (existsSync(MANIFEST_PATH)) return readManifestSync();
441
- if (!courseConfig) {
442
- throw new Error(
443
- `${relative(REPOSITORY_ROOT, MANIFEST_PATH)} not found. Run \`pnpm validate:content\` first.`
444
- );
472
+ if (courseConfig) {
473
+ const manifest = await compileManifest(courseConfig);
474
+ writeManifest(manifest);
475
+ return manifest;
445
476
  }
446
- const manifest = await compileManifest(courseConfig);
447
- writeManifest(manifest);
448
- return manifest;
477
+ if (existsSync(MANIFEST_PATH)) return readManifestSync();
478
+ throw new Error(
479
+ `${relative(REPOSITORY_ROOT, MANIFEST_PATH)} not found. Run \`pnpm validate:content\` first.`
480
+ );
449
481
  }
450
482
  export {
451
483
  MANIFEST_PATH,
@@ -45,10 +45,15 @@ interface EquationLessonInput {
45
45
  readonly file: string;
46
46
  readonly body: string;
47
47
  /**
48
- * The lesson's 1-based ordinal in the course. When set, equations are
49
- * numbered `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
48
+ * `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
49
+ * and how many display equations earlier lessons of that part already used.
50
+ * When set, equations are numbered `‹part›.‹offset + n›`; when omitted, the
51
+ * legacy `##`-section scheme. See `docs/authoring/structure.md`.
50
52
  */
51
- readonly chapter?: number;
53
+ readonly numbering?: {
54
+ readonly prefix: number;
55
+ readonly offset: number;
56
+ };
52
57
  }
53
58
  declare function validateEquations(lessons: readonly EquationLessonInput[]): EquationValidation;
54
59
 
@@ -25,7 +25,7 @@ function validateEquations(lessons) {
25
25
  )) {
26
26
  const seen = /* @__PURE__ */ new Set();
27
27
  const table = {};
28
- for (const raw of scanEquationLabels(lesson.body, lesson.chapter)) {
28
+ for (const raw of scanEquationLabels(lesson.body, lesson.numbering)) {
29
29
  if (seen.has(raw.key)) {
30
30
  diagnostics.push({
31
31
  code: "eq-duplicate-key",