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
@@ -7,13 +7,25 @@
7
7
  * supplies its own value of this shape.
8
8
  */
9
9
 
10
- export interface CourseSection {
10
+ /**
11
+ * One **part** — a sidebar group, holding the chapters (lessons) whose slugs
12
+ * start with `dir`. A part's 1-based position in `CourseConfig.parts` is the
13
+ * number before the dot in every equation, table, and figure number inside it.
14
+ * See `docs/authoring/structure.md` for the full level taxonomy.
15
+ */
16
+ export interface CoursePart {
11
17
  /** Lesson-slug prefix, e.g. `bonds` for `bonds/yield-to-maturity`. */
12
18
  readonly dir: string;
13
19
  /** Sidebar group heading. */
14
20
  readonly label: string;
15
21
  }
16
22
 
23
+ /**
24
+ * @deprecated Renamed to {@link CoursePart}. "Section" now only ever means an
25
+ * `##` heading inside a lesson. Removed in the next MINOR release.
26
+ */
27
+ export type CourseSection = CoursePart;
28
+
17
29
  export interface CourseConventions {
18
30
  /** Notation key a lesson's stated sign convention must resolve to. */
19
31
  readonly signConventionKey: string;
@@ -74,8 +86,16 @@ export interface CourseAbout {
74
86
  export interface CourseConfig {
75
87
  readonly title: string;
76
88
  readonly description: string;
77
- /** Sidebar section groups, in order. */
78
- readonly sections: readonly CourseSection[];
89
+ /**
90
+ * The course's parts (sidebar groups), in order. Exactly one of `parts` or
91
+ * the deprecated `sections` must be given.
92
+ */
93
+ readonly parts?: readonly CoursePart[];
94
+ /**
95
+ * @deprecated Renamed to {@link CourseConfig.parts}. Still honoured, so a
96
+ * course on the old name keeps building; removed in the next MINOR release.
97
+ */
98
+ readonly sections?: readonly CoursePart[];
79
99
  /** Namespaces used by competency / notation `domain` fields. */
80
100
  readonly domains: readonly string[];
81
101
  readonly conventions: CourseConventions;
@@ -84,3 +104,19 @@ export interface CourseConfig {
84
104
  /** Edits to the shipped named-operator baseline (rule N8). Optional. */
85
105
  readonly operators?: CourseOperators;
86
106
  }
107
+
108
+ /**
109
+ * The course's parts, from whichever field the config uses. Throws rather than
110
+ * silently producing a course with no sidebar groups — a config with neither
111
+ * field would otherwise number every float by the `##`-section fallback.
112
+ */
113
+ export function coursePartsOf(config: CourseConfig): readonly CoursePart[] {
114
+ const parts = config.parts ?? config.sections;
115
+ if (!parts || parts.length === 0) {
116
+ throw new Error(
117
+ 'CourseConfig needs at least one entry in `parts` (a sidebar group: ' +
118
+ '{ dir, label }). The old name `sections` is still accepted.',
119
+ );
120
+ }
121
+ return parts;
122
+ }
@@ -156,7 +156,7 @@ export type SidebarGroup =
156
156
  export function buildSidebar(
157
157
  tracks: readonly TrackLike[],
158
158
  lessonSlugs: readonly string[],
159
- sections: readonly SidebarSection[],
159
+ parts: readonly SidebarSection[],
160
160
  ): SidebarGroup[] {
161
161
  const rank = new Map(
162
162
  mergeTrackOrder(tracks).map((lessonId, index) => [lessonId, index]),
@@ -164,7 +164,7 @@ export function buildSidebar(
164
164
  const positionOf = (slug: string) =>
165
165
  rank.get(lessonIdFromSlug(slug)) ?? Number.POSITIVE_INFINITY;
166
166
 
167
- const groups: SidebarGroup[] = sections.map(({ dir, label }) => ({
167
+ const groups: SidebarGroup[] = parts.map(({ dir, label }) => ({
168
168
  label,
169
169
  items: lessonSlugs
170
170
  .filter((slug) => slug.startsWith(`${dir}/`))
@@ -47,6 +47,8 @@ import {
47
47
  type ConsistencyDiagnostic,
48
48
  } from '../reference/consistency';
49
49
  import { validateNotationAlignment } from '../reference/curriculum-alignment';
50
+ import { countDisplayEquations } from '../reference/equations.mjs';
51
+ import { countKeyedJsxFloats } from '../reference/keyed-jsx-floats.mjs';
50
52
  import {
51
53
  validateEquations,
52
54
  type EquationDiagnostic,
@@ -80,7 +82,7 @@ import type {
80
82
  NotationLessonInput,
81
83
  NotationPageBundle,
82
84
  } from '../reference/types';
83
- import type { CourseConfig } from './course-config';
85
+ import { coursePartsOf, type CourseConfig } from './course-config';
84
86
  import {
85
87
  resolveKnowledgeVersion,
86
88
  type KnowledgeVersionDiagnostic,
@@ -116,8 +118,12 @@ import {
116
118
  * against `content/CHANGELOG.md`) and `diagnostics.knowledge`.
117
119
  * v6 (chapter numbering): `lessons[].chapter` — the lesson's 1-based course
118
120
  * ordinal, the chapter in every equation/table/figure number (`(2.1)`).
121
+ * v7 (part numbering): `lessons[].part` (the sidebar group's 1-based ordinal,
122
+ * now the number before the dot), `lessons[].chapter` redefined as the
123
+ * lesson's ordinal *within its part*, and `lessons[].numbering` — the
124
+ * per-kind offsets that make `n` run continuously across a part.
119
125
  */
120
- export const MANIFEST_SCHEMA_VERSION = 6;
126
+ export const MANIFEST_SCHEMA_VERSION = 7;
121
127
 
122
128
  const REPOSITORY_ROOT = process.cwd();
123
129
  // Content is the top-level `content/` folder; the build output is `build/`
@@ -206,11 +212,28 @@ export interface ManifestLesson {
206
212
  /** Docs-loader slug, e.g. `bonds/price-yield-relationship`. */
207
213
  readonly slug: string;
208
214
  /**
209
- * The lesson's 1-based ordinal in the course (merged track order, then any
210
- * unranked lessons by slug). It is the chapter in every equation, table, and
211
- * figure number on the page — `(2.1)` is the first equation of chapter 2.
215
+ * The 1-based ordinal of the **part** this lesson sits in — its sidebar
216
+ * group's position in `courseConfig.parts`. It is the number before the dot
217
+ * in every equation, table, and figure number on the page: `(2.1)` is the
218
+ * first equation of part 2. See `docs/authoring/structure.md`.
219
+ */
220
+ readonly part: number;
221
+ /**
222
+ * The lesson's 1-based ordinal **within its part** (merged track order, then
223
+ * any unranked lessons by slug) — its chapter number. This orders the
224
+ * sidebar and the reading path; it does not appear in a float's number.
212
225
  */
213
226
  readonly chapter: number;
227
+ /**
228
+ * How many floats of each kind the earlier lessons of this part already
229
+ * used. The lesson's own floats continue from here, so a part's sequence
230
+ * runs unbroken across its chapters instead of restarting on every page.
231
+ */
232
+ readonly numbering: {
233
+ readonly equations: number;
234
+ readonly tables: number;
235
+ readonly figures: number;
236
+ };
214
237
  readonly file: string;
215
238
  readonly title: string;
216
239
  readonly description: string;
@@ -586,45 +609,84 @@ export async function compileManifest(
586
609
  const sidebar = buildSidebar(
587
610
  catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
588
611
  lessonSlugs,
589
- courseConfig.sections,
612
+ coursePartsOf(courseConfig),
590
613
  );
591
614
 
592
- // A lesson's chapter is its position when the sidebar is read top to bottom
593
- // (sections in config order, lessons within a section by merged track order
594
- // then slug). It prefixes every equation / table / figure number on the page
595
- // (`(2.1)` = first equation of chapter 2). Derived from `sidebar` so there is
596
- // one lesson-ordering source, not two.
597
- const chapterByLessonId = new Map<string, number>();
598
- let chapterCursor = 0;
615
+ // Part and chapter, read straight off the sidebar so there is one
616
+ // lesson-ordering source and not two: a part is a sidebar group (parts in
617
+ // config order), a chapter is a lesson's position inside its part (merged
618
+ // track order, then slug). The part is what prefixes every equation / table
619
+ // / figure number — `(2.1)` is the first equation of part 2 — while `n`
620
+ // continues across the part's chapters rather than restarting on each page,
621
+ // so every lesson also needs the running count that precedes it.
622
+ // See `docs/authoring/structure.md`.
623
+ const bodyByLessonId = new Map(
624
+ notationInput.lessons.map((lesson) => [lesson.lessonId, lesson.body]),
625
+ );
626
+ const placementByLessonId = new Map<
627
+ string,
628
+ {
629
+ part: number;
630
+ chapter: number;
631
+ numbering: { equations: number; tables: number; figures: number };
632
+ }
633
+ >();
634
+ let partCursor = 0;
599
635
  for (const group of sidebar) {
600
- for (const item of group.items) {
601
- if (typeof item !== 'string') continue; // the static Reference group
602
- chapterByLessonId.set(lessonIdFromSlug(item), ++chapterCursor);
636
+ const lessonIds = group.items
637
+ .filter((item): item is string => typeof item === 'string')
638
+ .map(lessonIdFromSlug);
639
+ if (lessonIds.length === 0) continue; // the static Reference group
640
+ partCursor += 1;
641
+ // Running totals for this part, advanced one chapter at a time.
642
+ const used = { equations: 0, tables: 0, figures: 0 };
643
+ let chapterCursor = 0;
644
+ for (const lessonId of lessonIds) {
645
+ const body = bodyByLessonId.get(lessonId) ?? '';
646
+ placementByLessonId.set(lessonId, {
647
+ part: partCursor,
648
+ chapter: ++chapterCursor,
649
+ numbering: { ...used },
650
+ });
651
+ used.equations += countDisplayEquations(body);
652
+ used.tables += countKeyedJsxFloats(body, { tagName: 'Table' });
653
+ used.figures += countKeyedJsxFloats(body, { tagName: 'Figure' });
603
654
  }
604
655
  }
605
656
 
606
- const keyedFloatLessonInputs = notationInput.lessons.map((lesson) => ({
607
- lessonId: lesson.lessonId,
608
- slug: lesson.lessonId.replace(/\./g, '/'),
609
- file: lesson.source.file,
610
- body: lesson.body,
611
- chapter: chapterByLessonId.get(lesson.lessonId),
612
- }));
613
- const equationValidation = validateEquations(keyedFloatLessonInputs);
657
+ const floatInputs = (kind: 'equations' | 'tables' | 'figures') =>
658
+ notationInput.lessons.map((lesson) => {
659
+ const placement = placementByLessonId.get(lesson.lessonId);
660
+ return {
661
+ lessonId: lesson.lessonId,
662
+ slug: lesson.lessonId.replace(/\./g, '/'),
663
+ file: lesson.source.file,
664
+ body: lesson.body,
665
+ ...(placement
666
+ ? {
667
+ numbering: {
668
+ prefix: placement.part,
669
+ offset: placement.numbering[kind],
670
+ },
671
+ }
672
+ : {}),
673
+ };
674
+ });
675
+ const equationValidation = validateEquations(floatInputs('equations'));
614
676
  const equationsBySlug = new Map(
615
677
  Object.entries(equationValidation.numbersBySlug).map(([slug, table]) => [
616
678
  slug,
617
679
  Object.entries(table).map(([key, number]) => ({ key, number })),
618
680
  ]),
619
681
  );
620
- const tableValidation = validateTables(keyedFloatLessonInputs);
682
+ const tableValidation = validateTables(floatInputs('tables'));
621
683
  const tablesBySlug = new Map(
622
684
  Object.entries(tableValidation.numbersBySlug).map(([slug, table]) => [
623
685
  slug,
624
686
  Object.entries(table).map(([key, number]) => ({ key, number })),
625
687
  ]),
626
688
  );
627
- const figureValidation = validateFigures(keyedFloatLessonInputs);
689
+ const figureValidation = validateFigures(floatInputs('figures'));
628
690
  const figuresBySlug = new Map(
629
691
  Object.entries(figureValidation.numbersBySlug).map(([slug, table]) => [
630
692
  slug,
@@ -645,10 +707,16 @@ export async function compileManifest(
645
707
  lessons.push({
646
708
  id,
647
709
  slug: id.replace(/\./g, '/'),
648
- // 0 = the lesson is not placed in any configured section; a curriculum
710
+ // 0 = the lesson is not placed in any configured part; a curriculum
649
711
  // diagnostic covers that separately, and both numbering paths then fall
650
712
  // back to the legacy `##`-section scheme.
651
- chapter: chapterByLessonId.get(id) ?? 0,
713
+ part: placementByLessonId.get(id)?.part ?? 0,
714
+ chapter: placementByLessonId.get(id)?.chapter ?? 0,
715
+ numbering: placementByLessonId.get(id)?.numbering ?? {
716
+ equations: 0,
717
+ tables: 0,
718
+ figures: 0,
719
+ },
652
720
  file: notationLesson.source.file,
653
721
  title: frontmatter.title,
654
722
  description: frontmatter.description,
@@ -842,13 +910,17 @@ export function readManifestSync(): Manifest {
842
910
  export async function loadManifest(
843
911
  courseConfig?: CourseConfig,
844
912
  ): Promise<Manifest> {
845
- if (existsSync(MANIFEST_PATH)) return readManifestSync();
846
- if (!courseConfig) {
847
- throw new Error(
848
- `${relative(REPOSITORY_ROOT, MANIFEST_PATH)} not found. Run \`pnpm validate:content\` first.`,
849
- );
913
+ // A caller that has the course config is an authoring/build entry point. It
914
+ // must compile the current content tree even when a persisted manifest is
915
+ // present: using that file here can split the page in two, with remark
916
+ // seeing current frontmatter while Astro components render stale cards.
917
+ if (courseConfig) {
918
+ const manifest = await compileManifest(courseConfig);
919
+ writeManifest(manifest);
920
+ return manifest;
850
921
  }
851
- const manifest = await compileManifest(courseConfig);
852
- writeManifest(manifest);
853
- return manifest;
922
+ if (existsSync(MANIFEST_PATH)) return readManifestSync();
923
+ throw new Error(
924
+ `${relative(REPOSITORY_ROOT, MANIFEST_PATH)} not found. Run \`pnpm validate:content\` first.`,
925
+ );
854
926
  }
@@ -711,6 +711,17 @@ for (const gloss of glossItems) {
711
711
  pointer-events: none;
712
712
  }
713
713
 
714
+ /* KaTeX draws a radical sign, an extensible arrow (`\xrightarrow`), and
715
+ other stretchy glyphs as an absolutely-positioned SVG layer that is
716
+ painted after — and on top of — the content it decorates. Without this,
717
+ a pointer over a symbol inside `\sqrt{…}` or under an arrow hits that
718
+ overlay instead of the notation marker beneath it. The radicand lives in
719
+ the sibling `.svg-align` layer, which stays interactive. */
720
+ :global(.katex-html .sqrt .vlist > span:not(.svg-align)),
721
+ :global(.katex-html .hide-tail) {
722
+ pointer-events: none;
723
+ }
724
+
714
725
  :global(.katex-html [data-notation-key]) {
715
726
  cursor: help;
716
727
  }
@@ -23,7 +23,68 @@ import DefaultSidebar from '@astrojs/starlight/components/Sidebar.astro';
23
23
  size="1rem"
24
24
  />
25
25
  </button>
26
- <div id="starlight__navigation-content" class="layout-navigation-content">
26
+ <div
27
+ id="starlight__navigation-content"
28
+ class="layout-navigation-content layout-compact-sidebar"
29
+ >
27
30
  <DefaultSidebar />
31
+ <script>
32
+ import { createBrowserPreferenceStore } from '../../session/PreferenceStore';
33
+
34
+ (() => {
35
+ if (!matchMedia('(min-width: 50rem)').matches) return;
36
+ const root = document.querySelector('#starlight__navigation-content');
37
+ const stateRoot = root?.querySelector('sl-sidebar-state-persist');
38
+ if (!stateRoot) return;
39
+
40
+ const groups = [
41
+ ...stateRoot.querySelectorAll(':scope > ul > li > details'),
42
+ ];
43
+ const storageKey = 'explico-sidebar-groups';
44
+ const preferences = createBrowserPreferenceStore();
45
+ let compact = groups.map(() => true);
46
+ try {
47
+ const saved = JSON.parse(preferences.get(storageKey) || 'null');
48
+ if (
49
+ saved?.hash === stateRoot.dataset.hash &&
50
+ Array.isArray(saved.compact)
51
+ ) {
52
+ compact = groups.map((_, index) => saved.compact[index] !== false);
53
+ }
54
+ } catch {}
55
+
56
+ const save = () => {
57
+ try {
58
+ preferences.set(
59
+ storageKey,
60
+ JSON.stringify({
61
+ hash: stateRoot.dataset.hash,
62
+ compact: groups.map((group) => group.hasAttribute('data-compact')),
63
+ }),
64
+ );
65
+ } catch {}
66
+ };
67
+
68
+ groups.forEach((group, index) => {
69
+ // The links remain rendered and operable in compact mode; only their
70
+ // labels collapse. Keep <details> open so this stays true for assistive
71
+ // technology as well as pointer users.
72
+ group.setAttribute('open', '');
73
+ group.toggleAttribute('data-compact', compact[index]);
74
+ const summary = group.querySelector(':scope > summary');
75
+ summary?.setAttribute('aria-expanded', String(!compact[index]));
76
+ summary?.addEventListener('click', (event) => {
77
+ event.preventDefault();
78
+ group.toggleAttribute('data-compact');
79
+ group.setAttribute('open', '');
80
+ summary.setAttribute(
81
+ 'aria-expanded',
82
+ String(!group.hasAttribute('data-compact')),
83
+ );
84
+ save();
85
+ });
86
+ });
87
+ })();
88
+ </script>
28
89
  </div>
29
90
  </div>
@@ -9,6 +9,7 @@
9
9
  * D5); this component never rebuilds the registry.
10
10
  */
11
11
  import DefaultFooter from '@astrojs/starlight/components/Footer.astro';
12
+ import { assertLocalNotationParity } from '../../compiler/astro';
12
13
  import { loadManifest } from '../../compiler/manifest';
13
14
  import {
14
15
  isLessonSlug,
@@ -34,6 +35,16 @@ const definitions: NotationLayerDefinition[] = [];
34
35
  if (lessonId) {
35
36
  const manifest = await loadManifest();
36
37
  const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
38
+ const routeData = Astro.locals.starlightRoute.entry.data as {
39
+ notation?: { local?: readonly { key?: unknown }[] };
40
+ };
41
+ const authoredLocalKeys = (routeData.notation?.local ?? [])
42
+ .map((entry) => entry.key)
43
+ .filter((key): key is string => typeof key === 'string');
44
+ const renderedLocalKeys = (lesson?.notation.definitions ?? [])
45
+ .filter((definition) => definition.scope === 'local')
46
+ .map((definition) => definition.key);
47
+ assertLocalNotationParity(lessonId, authoredLocalKeys, renderedLocalKeys);
37
48
  for (const definition of lesson?.notation.definitions ?? []) {
38
49
  definitions.push({
39
50
  key: definition.key,
@@ -2,8 +2,9 @@
2
2
  /**
3
3
  * A referenceable table (F-tables-figures) — the table equivalent of a
4
4
  * keyed display equation. `remark-notation.mjs` numbers every `<Table>` on a
5
- * lesson page `‹chapter›.‹n›` (chapter = the lesson's course ordinal, `n`
6
- * sequential over the lesson), keyed or not, and injects the resulting
5
+ * lesson page `‹part›.‹n›` (the part is the sidebar group's ordinal, `n`
6
+ * sequential over every `<Table>` in that part — see
7
+ * `docs/authoring/structure.md`), keyed or not, and injects the resulting
7
8
  * `number`/`anchorId` as props; this component only renders what it's given
8
9
  * — it never computes its own number, same as `equationNode()`'s output for
9
10
  * equations.
@@ -54,10 +54,12 @@ export interface EquationLessonInput {
54
54
  readonly file: string;
55
55
  readonly body: string;
56
56
  /**
57
- * The lesson's 1-based ordinal in the course. When set, equations are
58
- * numbered `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
57
+ * `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
58
+ * and how many display equations earlier lessons of that part already used.
59
+ * When set, equations are numbered `‹part›.‹offset + n›`; when omitted, the
60
+ * legacy `##`-section scheme. See `docs/authoring/structure.md`.
59
61
  */
60
- readonly chapter?: number;
62
+ readonly numbering?: { readonly prefix: number; readonly offset: number };
61
63
  }
62
64
 
63
65
  const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
@@ -95,7 +97,7 @@ export function validateEquations(
95
97
  )) {
96
98
  const seen = new Set<string>();
97
99
  const table: Record<string, string> = {};
98
- for (const raw of scanEquationLabels(lesson.body, lesson.chapter) as {
100
+ for (const raw of scanEquationLabels(lesson.body, lesson.numbering) as {
99
101
  key: string;
100
102
  number: string;
101
103
  section: number;
@@ -2,12 +2,17 @@
2
2
  * Equation identity (Step D7, extended F3) — shared, framework-free helpers.
3
3
  *
4
4
  * Every display equation (`$$…$$`) in a lesson is numbered and given an `id`
5
- * from appearance order. When the caller supplies a `chapter` (the lesson's
6
- * ordinal position in the course), the number is `‹chapter›.‹n›` — `n` counts
7
- * every display equation in the lesson and `##` headings do not affect it
8
- * (`(2.4)` = fourth display equation of the second lesson). With no `chapter`
9
- * the number falls back to the `##`-section scheme `‹section›.‹indexInSection›`
10
- * (used by hand-built test trees and non-lesson pages). An author additionally
5
+ * from appearance order. When the caller supplies `numbering`
6
+ * (`{ prefix, offset }` — see `resolveNumbering`), the number is
7
+ * `‹part›.‹offset + n›`: the prefix is the **part**'s ordinal among the sidebar
8
+ * groups, `n` counts every display equation in the lesson, and `offset` carries
9
+ * the count from earlier lessons of the same part so the sequence runs
10
+ * continuously across a part rather than restarting on every page. `##` and
11
+ * `###` headings never affect it — `(2.4)` is the fourth display equation of
12
+ * part 2, wherever in that part it sits. See `docs/authoring/structure.md` for
13
+ * the level names. With no `numbering` the number falls back to the
14
+ * `##`-section scheme `‹section›.‹indexInSection›` (used by hand-built test
15
+ * trees and non-lesson pages). An author additionally
11
16
  * marks an equation with a trailing `\label{eq:<key>}` inside its `$$…$$`
12
17
  * block (inside math, so the `:` is safe and the form matches amsmath) when
13
18
  * they want a *stable* anchor that survives edits and cross-page references:
@@ -63,19 +68,42 @@ function equationNumber(section, index) {
63
68
  }
64
69
 
65
70
  /**
66
- * The visible number for one display equation. With `chapter` (the lesson's
67
- * course ordinal) the scheme is `‹chapter›.‹indexInLesson›` and `##` headings
71
+ * Normalise the numbering input to `{ prefix, offset }`.
72
+ *
73
+ * `prefix` is the number before the dot — the **part**'s ordinal (its position
74
+ * among the sidebar groups). `offset` is how many display equations already
75
+ * appeared in earlier lessons of that same part, so `n` runs continuously
76
+ * across a part instead of restarting on every page. A bare number is the
77
+ * legacy per-lesson form (`prefix = n`, `offset = 0`) and is still accepted so
78
+ * hand-built trees and unit tests keep working. `undefined`/`null`/`0` ⇒ the
79
+ * `##`-section fallback for non-lesson pages.
80
+ */
81
+ export function resolveNumbering(input) {
82
+ if (input == null) return { prefix: undefined, offset: 0 };
83
+ if (typeof input === 'number') {
84
+ return { prefix: input || undefined, offset: 0 };
85
+ }
86
+ return {
87
+ prefix: input.prefix || undefined,
88
+ offset: Number(input.offset) || 0,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The visible number for one display equation. With a `prefix` (the part's
94
+ * ordinal) the scheme is `‹part›.‹offset + indexInLesson›` and `##` headings
68
95
  * are ignored; without it, the legacy `##`-section scheme.
69
96
  */
70
97
  export function displayEquationNumber({
71
- chapter,
98
+ prefix,
99
+ offset = 0,
72
100
  section,
73
101
  indexInSection,
74
102
  indexInLesson,
75
103
  }) {
76
- return chapter == null
104
+ return prefix == null
77
105
  ? equationNumber(section, indexInSection)
78
- : `${chapter}.${indexInLesson}`;
106
+ : `${prefix}.${offset + indexInLesson}`;
79
107
  }
80
108
 
81
109
  /** The positional anchor id for an unkeyed equation, e.g. `2.4` → `eq-2-4`. */
@@ -109,15 +137,18 @@ export function stripJsxComponents(markdown, keep) {
109
137
  }
110
138
 
111
139
  /**
112
- * Every keyed display equation in a Markdown body, in appearance order, with
113
- * its assigned number. `$$…$$` blocks only. With `chapter` (the lesson's
114
- * course ordinal) the number is `‹chapter›.‹n›`, `n` sequential over every
115
- * display block in the lesson; without it, `##` headings advance a section
116
- * counter and the number is `‹section›.‹indexInSection›`. Either way every
117
- * display block advances the index, so a keyed equation's number reflects its
118
- * position among *all* display equations, not only the keyed ones.
140
+ * Every display equation in a Markdown body, keyed or not, in appearance order
141
+ * with its assigned number. `$$…$$` blocks only. With `numbering`
142
+ * (`{ prefix, offset }`, the part's ordinal and the count of display equations
143
+ * in earlier lessons of that part) the number is `‹part›.‹offset + n›`, `n`
144
+ * sequential over every display block in the lesson; without it, `##` headings
145
+ * advance a section counter and the number is `‹section›.‹indexInSection›`.
146
+ * Either way every display block advances the index, so a keyed equation's
147
+ * number reflects its position among *all* display equations, not only the
148
+ * keyed ones.
119
149
  */
120
- export function scanEquationLabels(markdown, chapter) {
150
+ export function scanDisplayEquations(markdown, numbering) {
151
+ const { prefix, offset } = resolveNumbering(numbering);
121
152
  const text = stripJsxComponents(stripFencedCode(markdown));
122
153
  const tokens = [];
123
154
  const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
@@ -148,11 +179,11 @@ export function scanEquationLabels(markdown, chapter) {
148
179
  }
149
180
  indexInSection += 1;
150
181
  indexInLesson += 1;
151
- if (token.key == null) continue;
152
182
  labels.push({
153
183
  key: token.key,
154
184
  number: displayEquationNumber({
155
- chapter,
185
+ prefix,
186
+ offset,
156
187
  section,
157
188
  indexInSection,
158
189
  indexInLesson,
@@ -164,14 +195,31 @@ export function scanEquationLabels(markdown, chapter) {
164
195
  return labels;
165
196
  }
166
197
 
198
+ /** The keyed subset of `scanDisplayEquations` — the manifest builder's view. */
199
+ export function scanEquationLabels(markdown, numbering) {
200
+ return scanDisplayEquations(markdown, numbering).filter(
201
+ (equation) => equation.key != null,
202
+ );
203
+ }
204
+
205
+ /**
206
+ * How many display equations a lesson body holds. This is what makes `n` run
207
+ * continuously across a part: a lesson's `offset` is the sum of this over every
208
+ * earlier lesson in the same part.
209
+ */
210
+ export function countDisplayEquations(markdown) {
211
+ return scanDisplayEquations(markdown).length;
212
+ }
213
+
167
214
  /**
168
215
  * The mdast equivalent of `scanEquationLabels` for *every* display equation:
169
216
  * walk the tree in document order, advancing the section counter on every
170
217
  * depth-2 heading and emitting a record for every `math` node. `key` is the
171
- * `\label{eq:…}` payload when present, else `null`. `chapter` is threaded the
218
+ * `\label{eq:…}` payload when present, else `null`. `numbering` is threaded the
172
219
  * same way as in `scanEquationLabels`.
173
220
  */
174
- export function collectDisplayEquations(tree, chapter) {
221
+ export function collectDisplayEquations(tree, numbering) {
222
+ const { prefix, offset } = resolveNumbering(numbering);
175
223
  const equations = [];
176
224
  let section = 0;
177
225
  let indexInSection = 0;
@@ -196,7 +244,8 @@ export function collectDisplayEquations(tree, chapter) {
196
244
  equations.push({
197
245
  key: label ? label[1] : null,
198
246
  number: displayEquationNumber({
199
- chapter,
247
+ prefix,
248
+ offset,
200
249
  section,
201
250
  indexInSection,
202
251
  indexInLesson,
@@ -214,8 +263,8 @@ export function collectDisplayEquations(tree, chapter) {
214
263
  * The keyed subset of `collectDisplayEquations` — the tree-walk counterpart of
215
264
  * `scanEquationLabels`. Numbers reflect position among all display equations.
216
265
  */
217
- export function collectEquationLabels(tree, chapter) {
218
- return collectDisplayEquations(tree, chapter).filter(
266
+ export function collectEquationLabels(tree, numbering) {
267
+ return collectDisplayEquations(tree, numbering).filter(
219
268
  (equation) => equation.key != null,
220
269
  );
221
270
  }
@@ -69,10 +69,13 @@ export interface KeyedFloatLessonInput {
69
69
  readonly file: string;
70
70
  readonly body: string;
71
71
  /**
72
- * The lesson's 1-based ordinal in the course. When set, floats are numbered
73
- * `‹chapter›.‹n›`; when omitted, the legacy `##`-section scheme.
72
+ * `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
73
+ * and how many floats of this kind earlier lessons of that part already used.
74
+ * When set, floats are numbered `‹part›.‹offset + n›`; when omitted, the
75
+ * legacy `##`-section scheme. Each kind counts on its own, so the caller
76
+ * passes a different `offset` for `<Table>` than for `<Figure>`.
74
77
  */
75
- readonly chapter?: number;
78
+ readonly numbering?: { readonly prefix: number; readonly offset: number };
76
79
  }
77
80
 
78
81
  const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
@@ -117,7 +120,7 @@ export function validateKeyedFloats(
117
120
  const table: Record<string, string> = {};
118
121
  for (const raw of scanKeyedJsxFloats(lesson.body, {
119
122
  tagName,
120
- chapter: lesson.chapter,
123
+ numbering: lesson.numbering,
121
124
  }) as {
122
125
  key: string | null;
123
126
  number: string;