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
@@ -671,26 +671,146 @@ function inspectExplainCalls(value, resolve, file, node, references) {
671
671
  }
672
672
  }
673
673
 
674
+ /**
675
+ * The numbering inputs a lesson page needs, read off one manifest.
676
+ *
677
+ * Every number on a page comes from two places that MUST agree: the page's own
678
+ * counters (`numbering`) and the manifest tables a cross-page `[[slug#eq-key]]`
679
+ * is resolved against (`equations` / `tables` / `figures`). Deriving all four
680
+ * from the same manifest in one step is what stops a course config from
681
+ * supplying one without the other — the omission that produced page-local
682
+ * `(2.2)` and cross-page `(4.5)` for the same equation.
683
+ *
684
+ * `numbering` maps a lesson slug to `{ part, equations, tables, figures }`:
685
+ * the part's ordinal (the number before the dot) and, per kind of float, how
686
+ * many of them the earlier lessons of that part already used.
687
+ */
688
+ export function numberingFromManifest(manifest) {
689
+ if (!manifest || typeof manifest !== 'object') return {};
690
+ return {
691
+ equations: manifest.equations?.numbersBySlug ?? {},
692
+ tables: manifest.tables?.numbersBySlug ?? {},
693
+ figures: manifest.figures?.numbersBySlug ?? {},
694
+ numbering: Object.fromEntries(
695
+ (manifest.lessons ?? []).map((lesson) => [
696
+ lesson.slug,
697
+ {
698
+ part: lesson.part,
699
+ equations: lesson.numbering?.equations ?? 0,
700
+ tables: lesson.numbering?.tables ?? 0,
701
+ figures: lesson.numbering?.figures ?? 0,
702
+ },
703
+ ]),
704
+ ),
705
+ };
706
+ }
707
+
708
+ /**
709
+ * `{ prefix, offset }` for one kind of float on one page, from whichever
710
+ * numbering option the caller supplied. `numbering` is the current form;
711
+ * `chapters` is the superseded per-lesson form (prefix = the lesson's ordinal,
712
+ * no offset) and is still read so an unmigrated config keeps working.
713
+ */
714
+ function pageNumbering(options, slug, kind) {
715
+ if (!slug) return undefined;
716
+ const placement = options.numbering?.[slug];
717
+ if (placement?.part) {
718
+ return { prefix: placement.part, offset: placement[kind] ?? 0 };
719
+ }
720
+ const chapter = options.chapters?.[slug];
721
+ return chapter ? { prefix: chapter, offset: 0 } : undefined;
722
+ }
723
+
724
+ /**
725
+ * Fail when the number this page just computed for a keyed float disagrees
726
+ * with the number the manifest publishes for the same key (WERN — a reference
727
+ * that shows a different number than its target). The two are produced by the
728
+ * same algorithm from the same content, so any disagreement means the render
729
+ * path and the manifest were fed different inputs; in practice, an
730
+ * `astro.config.mjs` that passes the manifest number tables but not
731
+ * `numbering`, which silently drops the page back to the legacy `##`-section
732
+ * scheme.
733
+ */
734
+ function assertManifestNumberParity({
735
+ file,
736
+ slug,
737
+ pageLabels,
738
+ crossPage,
739
+ placement,
740
+ noun,
741
+ syntax,
742
+ }) {
743
+ // No entry for this slug at all means the caller supplied no manifest table
744
+ // (hand-built test trees, non-lesson pages) — nothing to cross-check.
745
+ const published = crossPage?.[slug];
746
+ if (!published) return;
747
+
748
+ for (const [key, number] of pageLabels) {
749
+ const expected = published[key];
750
+ if (expected === undefined) {
751
+ fail(
752
+ file,
753
+ `Stale Explico manifest for ${slug}: this page numbers ${noun} ` +
754
+ `${syntax(key)} as (${number}), but the manifest lists no such ` +
755
+ `${noun} for the lesson. Recompile the manifest before rendering.`,
756
+ );
757
+ }
758
+ if (expected !== number) {
759
+ fail(
760
+ file,
761
+ `Inconsistent ${noun} numbering for ${syntax(key)} in ${slug}: this ` +
762
+ `page renders it as (${number}) while a cross-page reference to it ` +
763
+ `resolves to (${expected}). ` +
764
+ (placement === undefined
765
+ ? 'The remarkNotation options carry the manifest number tables ' +
766
+ 'but no `numbering` entry for this lesson, so the page fell ' +
767
+ 'back to the legacy `##`-section scheme. Pass `manifest` to ' +
768
+ 'remarkNotation so both sides use the part scheme.'
769
+ : 'The page counters and the manifest disagree; recompile the ' +
770
+ 'manifest from the current content.'),
771
+ );
772
+ }
773
+ }
774
+ }
775
+
674
776
  /**
675
777
  * @param {{
676
778
  * definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>),
677
779
  * resolve?: (key: string, context: object) => object | undefined,
678
780
  * unknown?: 'error' | 'warn' | 'ignore',
679
781
  * base?: string,
782
+ * manifest?: object,
680
783
  * equations?: Record<string, Record<string, string>>,
681
784
  * tables?: Record<string, Record<string, string>>,
682
785
  * figures?: Record<string, Record<string, string>>,
786
+ * numbering?: Record<string, { part: number, equations: number, tables: number, figures: number }>,
683
787
  * chapters?: Record<string, number>,
684
788
  * }} [options]
685
789
  *
686
- * `chapters` maps a lesson slug to its 1-based course ordinal. When the
687
- * current page has one, every display equation / `<Table>` / `<Figure>` is
688
- * numbered `‹chapter›.‹n›` (`##` headings ignored); with no entry the legacy
689
- * `##`-section scheme applies. It must agree with `manifest.lessons[].chapter`,
690
- * which is what feeds `equations`/`tables`/`figures` above.
790
+ * `manifest` is the preferred wiring: pass the compiled manifest and the four
791
+ * numbering inputs below are read off it together, so they cannot drift apart.
792
+ * They stay available individually for hand-built trees, and an explicitly
793
+ * supplied one still wins over the manifest.
794
+ *
795
+ * `numbering` maps a lesson slug to its **part** (the number before the dot)
796
+ * and, per kind of float, how many the earlier lessons of that part already
797
+ * used. Every display equation / `<Table>` / `<Figure>` on the page is then
798
+ * numbered `‹part›.‹offset + n›`, with `##` and `###` headings ignored; with no
799
+ * entry the legacy `##`-section scheme applies. It must agree with
800
+ * `manifest.lessons[]`, which is what feeds `equations`/`tables`/`figures`
801
+ * above — supplying the tables without `numbering` numbers the same equation
802
+ * twice, and `assertManifestNumberParity` turns that into a build error rather
803
+ * than a page whose references disagree with their targets.
804
+ *
805
+ * `chapters` is the superseded per-lesson form (slug → the lesson's course
806
+ * ordinal, no running offset). Still read when `numbering` has no entry, so a
807
+ * config written against the old option keeps building.
691
808
  */
692
809
  export default function remarkNotation(options = {}) {
693
810
  const unknown = options.unknown ?? 'error';
811
+ // The manifest supplies every numbering input at once; an explicit option
812
+ // still overrides it, so existing per-field wiring keeps working.
813
+ const numbering = { ...numberingFromManifest(options.manifest), ...options };
694
814
 
695
815
  return function transform(tree, file) {
696
816
  const suppliedDefinitions =
@@ -703,10 +823,10 @@ export default function remarkNotation(options = {}) {
703
823
  );
704
824
  const localDefinitions = localDefinitionsFromFile(file);
705
825
  const lessonId = lessonIdFrom(file);
706
- // The lesson's course ordinal (see the `chapters` option). Falsy / missing
707
- // ⇒ the legacy `##`-section numbering, so `0` and `undefined` behave alike.
708
- const chapter =
709
- (lessonId && options.chapters?.[lessonId.replace(/\./g, '/')]) || undefined;
826
+ const lessonSlug = lessonId ? lessonId.replace(/\./g, '/') : undefined;
827
+ // `{ prefix, offset }` per kind of float — see the `numbering` option.
828
+ // Missing ⇒ the legacy `##`-section numbering.
829
+ const equationNumbering = pageNumbering(numbering, lessonSlug, 'equations');
710
830
  const registryFile = isNotationRegistryFile(file);
711
831
  const mathScope = pageGlyphScope(
712
832
  localDefinitions,
@@ -718,8 +838,9 @@ export default function remarkNotation(options = {}) {
718
838
 
719
839
  // Equation identity (D7): assign every `\label{eq:…}` its number before any
720
840
  // math node is rewritten, so a `[[eq-key]]` earlier in the page still
721
- // resolves. `chapter` picks the `‹chapter›.‹n›` vs `##`-section scheme.
722
- const equationLabels = collectEquationLabels(tree, chapter);
841
+ // resolves. `equationNumbering` picks the `‹part›.‹n›` vs `##`-section
842
+ // scheme.
843
+ const equationLabels = collectEquationLabels(tree, equationNumbering);
723
844
  const pageLabels = new Map();
724
845
  for (const label of equationLabels) {
725
846
  if (pageLabels.has(label.key)) {
@@ -734,14 +855,25 @@ export default function remarkNotation(options = {}) {
734
855
  const eqCtx = {
735
856
  base: options.base ?? '',
736
857
  pageLabels,
737
- crossPage: options.equations ?? {},
858
+ crossPage: numbering.equations ?? {},
738
859
  };
860
+ // WERN guard: what this page prints next to the equation must be what a
861
+ // reference from another page prints for it.
862
+ assertManifestNumberParity({
863
+ file,
864
+ slug: lessonSlug,
865
+ pageLabels,
866
+ crossPage: eqCtx.crossPage,
867
+ placement: equationNumbering,
868
+ noun: 'equation',
869
+ syntax: (key) => `eq:${key}`,
870
+ });
739
871
 
740
872
  // Number and anchor every display equation on a lesson page, keyed or
741
873
  // not (F3). Non-lesson pages (notation registry, stray docs) keep the
742
874
  // keyed-only behaviour so their body math is left untouched.
743
875
  const displayEquations = lessonId
744
- ? collectDisplayEquations(tree, chapter)
876
+ ? collectDisplayEquations(tree, equationNumbering)
745
877
  : [];
746
878
  const equationByNode = new Map(
747
879
  displayEquations.map((equation) => [equation.node, equation]),
@@ -750,9 +882,10 @@ export default function remarkNotation(options = {}) {
750
882
  // Keyed floats (F-tables-figures): every `<Table>`/`<Figure>` on a lesson
751
883
  // page is numbered and anchored, keyed or not — same rule as equations,
752
884
  // via the shared engine in `keyed-jsx-floats.mjs`.
753
- const makeFloatCtx = (tagName, crossPage) => {
885
+ const makeFloatCtx = (tagName, crossPage, kind) => {
886
+ const floatNumbering = pageNumbering(numbering, lessonSlug, kind);
754
887
  const floats = lessonId
755
- ? collectKeyedJsxFloats(tree, { tagName, chapter })
888
+ ? collectKeyedJsxFloats(tree, { tagName, numbering: floatNumbering })
756
889
  : [];
757
890
  const pageFloatLabels = new Map();
758
891
  for (const float of floats) {
@@ -766,6 +899,15 @@ export default function remarkNotation(options = {}) {
766
899
  }
767
900
  pageFloatLabels.set(float.key, float.number);
768
901
  }
902
+ assertManifestNumberParity({
903
+ file,
904
+ slug: lessonSlug,
905
+ pageLabels: pageFloatLabels,
906
+ crossPage: crossPage ?? {},
907
+ placement: floatNumbering,
908
+ noun: tagName,
909
+ syntax: (key) => `<${tagName} id="${key}">`,
910
+ });
769
911
  return {
770
912
  ctx: {
771
913
  base: options.base ?? '',
@@ -777,11 +919,13 @@ export default function remarkNotation(options = {}) {
777
919
  };
778
920
  const { ctx: tableCtx, byNode: tableByNode } = makeFloatCtx(
779
921
  'Table',
780
- options.tables,
922
+ numbering.tables,
923
+ 'tables',
781
924
  );
782
925
  const { ctx: figureCtx, byNode: figureByNode } = makeFloatCtx(
783
926
  'Figure',
784
- options.figures,
927
+ numbering.figures,
928
+ 'figures',
785
929
  );
786
930
 
787
931
  const resolve = (key, kind, node) => {
@@ -38,6 +38,10 @@ this folder, reviewed like any other change.
38
38
 
39
39
  ## How a rule is written
40
40
 
41
+ [`structure.md`](structure.md) is not a rule set — it is the taxonomy the rules
42
+ speak in: what a part, a chapter, a section, and a subsection are, and which of
43
+ them an equation number counts.
44
+
41
45
  Each rule has a stable ID — a letter for its area (`N` notation,
42
46
  `V` visualizations, `C` versioning) plus a number — and this shape:
43
47
 
@@ -19,6 +19,68 @@ required config block throws with the section of this file to read.
19
19
 
20
20
  ---
21
21
 
22
+ ## 0.3.x → 0.4.0
23
+
24
+ ### 1. Rename `sections` to `parts` in `content/course.config.ts`
25
+
26
+ A sidebar group is now a **part** — "section" only ever means an `##` heading
27
+ inside a lesson. See [`structure.md`](structure.md) for the full taxonomy.
28
+
29
+ ```diff
30
+ - sections: [
31
+ + parts: [
32
+ { dir: 'foundations', label: 'Foundations' },
33
+ { dir: 'bonds', label: 'Bonds' },
34
+ ],
35
+ ```
36
+
37
+ `sections` is still read, so this is not urgent; it is removed in the next
38
+ MINOR release.
39
+
40
+ ### 2. Pass the manifest to `remarkNotation`
41
+
42
+ Replace the individual numbering options with the manifest itself. This is what
43
+ guarantees the number a page prints and the number a reference to it prints are
44
+ computed from the same inputs — supplying one without the other is what made an
45
+ equation read `(2.2)` on its own page and `(4.5)` everywhere else.
46
+
47
+ ```diff
48
+ [remarkNotation, {
49
+ definitions: manifest.notation.raw,
50
+ - equations: manifest.equations.numbersBySlug,
51
+ + manifest,
52
+ base: publicBase,
53
+ }],
54
+ ```
55
+
56
+ The engine now cross-checks the two and **fails the build** when they disagree,
57
+ so a config left half-wired errors instead of shipping wrong numbers.
58
+
59
+ ### 3. Expect every equation, table, and figure number to change
60
+
61
+ Numbers are now `‹part›.‹n›`: the prefix is the part's ordinal, and `n` runs
62
+ across the whole part rather than restarting on each page. A course whose
63
+ lessons were chapters 1…16 now has as many prefixes as it has sidebar groups.
64
+
65
+ Nothing in `content/` needs editing — numbers were never hand-typed, and every
66
+ `[[eq-key]]` / `[[slug#eq-key]]` reference keeps resolving. Two things to check:
67
+
68
+ - **Prose that spells a number out** ("as equation 4.5 showed") was already
69
+ against rule N-references; find it with `grep -rnE '\((1?[0-9])\.[0-9]+\)'
70
+ content/` and convert it to a `[[eq-key]]` reference.
71
+ - **External links to a positional anchor** (`#eq-2-4`, an *unkeyed* equation)
72
+ now point elsewhere. Keyed anchors (`#eq-<key>`) are stable and unaffected —
73
+ give an equation a `\label{eq:…}` if you need to link to it from outside.
74
+
75
+ ### 4. Manifest schema is v7
76
+
77
+ `lessons[].part` is new; `lessons[].chapter` now means the lesson's ordinal
78
+ *within its part*, not down the whole course; `lessons[].numbering` carries the
79
+ running float counts each chapter starts from. A client asserting on
80
+ `MANIFEST_SCHEMA_VERSION` needs the bump.
81
+
82
+ ---
83
+
22
84
  ## 0.2.x → 0.3.0
23
85
 
24
86
  ### 1. Add the required `about` block to `content/course.config.ts`
@@ -147,13 +147,14 @@ If prose points back at a display equation — this page or another — the equa
147
147
  has `\label{eq:key}` and the reference is `[[eq-key]]`. Do not hand-number
148
148
  equations in the text.
149
149
 
150
- _Rationale._ The engine numbers every display equation `‹chapter›.‹n›` — the
151
- chapter is the lesson's position in the course, `n` counts the display
152
- equations in the lesson in appearance order (`##` sub-headings do not affect
153
- it) — and derives the anchor and any cross-lesson link from the label. A
154
- hand-typed "(3)" goes stale the moment an equation is inserted above it, or the
155
- lesson moves in the course order. `<Table>` and `<Figure>` are numbered the
156
- same way, each with its own counter.
150
+ _Rationale._ The engine numbers every display equation `‹part›.‹n›` — the part
151
+ is the sidebar group's position in `courseConfig.parts`, `n` counts the display
152
+ equations across that whole part in appearance order, continuing from one
153
+ chapter into the next (`##` and `###` headings do not affect it; see
154
+ [`structure.md`](structure.md)) — and derives the anchor and any cross-lesson
155
+ link from the label. A hand-typed "(3)" goes stale the moment an equation is
156
+ inserted above it, or the chapter moves within its part. `<Table>` and
157
+ `<Figure>` are numbered the same way, each with its own counter.
157
158
 
158
159
  _How to apply._ Only label equations you actually reference; an unlabelled
159
160
  display equation is still fine.
@@ -0,0 +1,139 @@
1
+ # Course structure — the taxonomy
2
+
3
+ > **Status: adopted.** The engine implements these names and this numbering.
4
+ > `courseConfig.parts` is the current field; `sections` still works and is
5
+ > removed in the next MINOR release.
6
+
7
+ A course built on explico has five nested structural levels. Two of them are
8
+ currently called by more than one name, and two different levels are both
9
+ called "section". This file fixes one name per level so the words stop
10
+ overlapping.
11
+
12
+ ## The five levels
13
+
14
+ | Level | What it physically is | Where it is declared | Names it used to carry |
15
+ | --- | --- | --- | --- |
16
+ | 1 | The whole material | `courseConfig.title` | course |
17
+ | 2 | **Part** — a collapsible group in the left sidebar; one content directory | `courseConfig.parts[] = { dir, label }` | `CourseSection`, **"section"**, **"chapter"** (sidebar UI) |
18
+ | 3 | **Chapter** — one `.mdx` file = one page = one URL | `content/docs/<dir>/<name>.mdx` | `lesson`, **"subchapter"** (sidebar UI) |
19
+ | 4 | **Section** — an `##` heading inside a lesson | the `.mdx` body | — |
20
+ | 5 | **Subsection** — a `###` heading inside a lesson | the `.mdx` body | *(unnamed)* |
21
+
22
+ A **track** (`content/tracks/*.json`) is not one of these levels. It is an
23
+ ordered learning path over lesson ids; it feeds the order lessons appear in
24
+ inside their sidebar group, and it may span groups. It is never displayed as a
25
+ container.
26
+
27
+ ### The two collisions this resolved
28
+
29
+ **"Section" meant both level 2 and level 4.** `courseConfig.sections` was a
30
+ sidebar group; the `section` counter in `src/reference/equations.mjs` and
31
+ `src/reference/keyed-jsx-floats.mjs` counts `##` headings inside one lesson.
32
+ They are two levels apart and nothing in the names says so.
33
+
34
+ **"Chapter" meant both level 2 and level 3.** The numbering code said a chapter
35
+ was a lesson — `manifest.lessons[].chapter` was the lesson's 1-based ordinal read
36
+ straight down the sidebar, and it was the number before the dot in `(4.5)`. The
37
+ sidebar UI said a chapter is a *group*: from the engine changelog, "Chapter
38
+ groups in the left sidebar now start collapsed, retain compact **subchapter**
39
+ icons … and expand to the full lesson names when the chapter title is opened."
40
+ So the reader was shown "chapter = group, subchapter = lesson" while the numbers
41
+ were built on "chapter = lesson". A number could not be read without knowing
42
+ which of the two vocabularies the writer had in mind.
43
+
44
+ ## The names
45
+
46
+ One name per level, borrowed from the book metaphor a reader already has:
47
+
48
+ | Level | Proposed name | Playground example | time-series example |
49
+ | --- | --- | --- | --- |
50
+ | 1 | **Course** | Explico Playground | MTH 9893 Time Series Analysis |
51
+ | 2 | **Part** | "Motion in the plane" (`motion/`) | "L1 · Foundations & evidence" |
52
+ | 3 | **Chapter** — one lesson, one page, one URL | "Position and Coordinate Systems" | "Dependence: ACF and PACF" |
53
+ | 4 | **Section** — an `##` heading | "Two ways to name the same point" | — |
54
+ | 5 | **Subsection** — a `###` heading | "From Cartesian to polar" | — |
55
+
56
+ Consequences:
57
+
58
+ - `courseConfig.sections` is renamed `courseConfig.parts`. "Section" now only
59
+ ever means an `##` heading. `sections` is still accepted, and is removed in
60
+ the next MINOR release.
61
+ - The sidebar UI no longer says "chapter" for a group or "subchapter" for a
62
+ lesson; a group is a **part**, and the thing inside it is a **chapter**.
63
+ - "Lesson" and "chapter" stay as two words for level 3 on purpose: *lesson* is
64
+ the pedagogical unit (it teaches competencies, carries assessments), *chapter*
65
+ is that same unit's position in the reading order. A file is a lesson; its
66
+ ordinal is its chapter number.
67
+
68
+ ## What the playground demonstrates
69
+
70
+ The playground is the worked example for every level. It was deliberately
71
+ extended for this: until now it had a single sidebar group, which made levels 2
72
+ and 3 indistinguishable — one group with four lessons numbers identically
73
+ whether you count groups or lessons, which is why a numbering bug and this
74
+ naming collision both stayed invisible.
75
+
76
+ ```
77
+ Course Explico Playground
78
+ ├── Part 1 Motion in the plane content/docs/motion/
79
+ │ ├── Ch 1 Position and Coordinate Systems position-and-coordinates.mdx
80
+ │ │ ├── § Locating a point in the plane ##
81
+ │ │ ├── § Two ways to name the same point ##
82
+ │ │ │ ├── §§ From Cartesian to polar ###
83
+ │ │ │ └── §§ From polar to Cartesian ###
84
+ │ │ ├── § Velocity and acceleration as derivatives ##
85
+ │ │ └── § The special case: constant acceleration ##
86
+ │ │ ├── §§ Integrating once: velocity ###
87
+ │ │ └── §§ Integrating twice: position ###
88
+ │ └── Ch 2 Velocity Under Constant Acceleration constant-acceleration.mdx
89
+ └── Part 2 Projectile motion content/docs/projectiles/
90
+ ├── Ch 1 Projectile Range projectile-range.mdx
91
+ └── Ch 2 Optimal Launch Angle optimal-launch-angle.mdx
92
+ ```
93
+
94
+ Which numbers every float like this — the part supplies the prefix, and the
95
+ index carries on across the part's chapters:
96
+
97
+ ```
98
+ Part 1 Motion in the plane
99
+ Ch 1 Position and Coordinate Systems (1.1) (1.2) (1.3) (1.4) (1.5) (1.6) (1.7)
100
+ Ch 2 Velocity Under Constant Accel. (1.8)
101
+ Part 2 Projectile motion
102
+ Ch 1 Projectile Range (2.1) (2.2)
103
+ Ch 2 Optimal Launch Angle (2.3) (2.4) (2.5) (2.6)
104
+ ```
105
+
106
+ ## Numbering
107
+
108
+ A display equation, `<Table>`, or `<Figure>` is numbered **`‹part›.‹n›`**:
109
+
110
+ - **`part`** is the part's 1-based position in `courseConfig.parts`.
111
+ - **`n`** counts that kind of float across the *whole part*, in document order,
112
+ running unbroken from one chapter into the next. Each kind counts on its own,
113
+ so a `<Table>` never advances the `<Figure>` sequence.
114
+ - **Sections and subsections do not participate.** Neither `##` nor `###`
115
+ resets `n` or appears in the number.
116
+ - **Keyed or not, everything counts.** An equation with no `\label{eq:…}` still
117
+ takes a number and a positional anchor (`#eq-2-4`); it just cannot be
118
+ referenced by name.
119
+
120
+ In the playground: part 1 runs `1.1` … `1.7` through chapter 1 and continues
121
+ `1.8` in chapter 2; part 2 opens at `2.1`.
122
+
123
+ `manifest.lessons[]` carries what the render path needs to reproduce this:
124
+ `part`, `chapter`, and `numbering.{equations,tables,figures}` — the running
125
+ count each chapter starts from.
126
+
127
+ ### The invariant, and why it is build-enforced
128
+
129
+ The number a page prints beside an equation and the number a cross-page
130
+ `[[slug#eq-key]]` prints for it are computed by two code paths from the same
131
+ content. `remarkNotation` cross-checks them for every keyed float and fails the
132
+ build on any disagreement, naming the key and both numbers.
133
+
134
+ This exists because the disagreement is otherwise silent and shipped: a course
135
+ config that passed the manifest number tables without the placement map
136
+ numbered its pages by `##` section while every reference to them read the
137
+ manifest, so one equation was `(2.2)` on its own page and `(4.5)` everywhere
138
+ else. Wire the plugin by handing it the whole manifest — `[remarkNotation,
139
+ { definitions: manifest.notation.raw, manifest }]` — and the two cannot drift.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explico",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Build-time engine for prerequisite-aware, notation-rigorous course content: one validated content model, a semantic notation/citation/equation system with a completeness gate, and a deterministic JSON manifest.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,127 @@
1
+ import { resolve, sep } from 'node:path';
2
+
3
+ import type { AstroIntegration } from 'astro';
4
+ import type { CourseConfig } from './course-config';
5
+ import {
6
+ compileManifest,
7
+ type Manifest,
8
+ writeManifest,
9
+ } from './manifest';
10
+
11
+ // Vite keeps its server object across a restart. Do not accumulate watcher
12
+ // callbacks each time Astro reloads its configuration.
13
+ const watchedServers = new WeakSet<object>();
14
+
15
+ /** Fail loudly instead of rendering math triggers whose local cards are stale. */
16
+ export function assertLocalNotationParity(
17
+ lessonId: string,
18
+ authoredKeys: readonly string[],
19
+ renderedKeys: readonly string[],
20
+ ): void {
21
+ const authored = [...new Set(authoredKeys)].sort();
22
+ const rendered = [...new Set(renderedKeys)].sort();
23
+ if (
24
+ authored.length === rendered.length &&
25
+ authored.every((key, index) => key === rendered[index])
26
+ ) {
27
+ return;
28
+ }
29
+ throw new Error(
30
+ `Stale Explico manifest for ${lessonId}: lesson notation.local has ` +
31
+ `[${authored.join(', ')}], but the page notation bundle has ` +
32
+ `[${rendered.join(', ')}]. Pass courseConfig to loadManifest() and add ` +
33
+ `explicoContentRefresh() to the Astro integrations.`,
34
+ );
35
+ }
36
+
37
+ function replaceInPlace(target: unknown, source: unknown): void {
38
+ if (Array.isArray(target) && Array.isArray(source)) {
39
+ target.splice(0, target.length, ...source);
40
+ return;
41
+ }
42
+ if (
43
+ !target ||
44
+ !source ||
45
+ typeof target !== 'object' ||
46
+ typeof source !== 'object'
47
+ ) {
48
+ return;
49
+ }
50
+ const mutable = target as Record<string, unknown>;
51
+ const fresh = source as Record<string, unknown>;
52
+ for (const key of Object.keys(mutable)) {
53
+ if (!(key in fresh)) delete mutable[key];
54
+ }
55
+ for (const [key, value] of Object.entries(fresh)) {
56
+ const current = mutable[key];
57
+ if (
58
+ (Array.isArray(current) && Array.isArray(value)) ||
59
+ (current &&
60
+ value &&
61
+ typeof current === 'object' &&
62
+ typeof value === 'object' &&
63
+ !Array.isArray(current) &&
64
+ !Array.isArray(value))
65
+ ) {
66
+ replaceInPlace(current, value);
67
+ } else {
68
+ mutable[key] = value;
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Restart Astro when an authored content file changes. Astro reloads MDX on
75
+ * its own, but Explico's manifest is created by `astro.config.mjs`; a restart
76
+ * is what makes the remark plugins and page UI consume the same fresh model.
77
+ *
78
+ * Add this integration once to `integrations` in `astro.config.mjs`. The
79
+ * restart is automatic and editor-agnostic (change/add/unlink all work).
80
+ */
81
+ export function explicoContentRefresh(
82
+ courseConfig: CourseConfig,
83
+ manifest: Manifest,
84
+ ): AstroIntegration {
85
+ return {
86
+ name: 'explico-content-refresh',
87
+ hooks: {
88
+ 'astro:server:setup': ({ server }) => {
89
+ if (watchedServers.has(server)) return;
90
+ watchedServers.add(server);
91
+ const contentRoot = `${resolve(process.cwd(), 'content')}${sep}`;
92
+ let timer: ReturnType<typeof setTimeout> | undefined;
93
+ let restarting = false;
94
+
95
+ const refresh = (path: string) => {
96
+ const absolute = resolve(path);
97
+ if (!absolute.startsWith(contentRoot) || restarting) return;
98
+ if (timer) clearTimeout(timer);
99
+ timer = setTimeout(async () => {
100
+ restarting = true;
101
+ try {
102
+ const fresh = await compileManifest(courseConfig);
103
+ writeManifest(fresh);
104
+ // Astro/Vite retains the configuration object during a server
105
+ // restart. Update it recursively so remark plugin option arrays
106
+ // and maps captured from `manifest` also see the new model.
107
+ replaceInPlace(manifest, fresh);
108
+ await server.restart();
109
+ } catch (error) {
110
+ server.config.logger.error(
111
+ `Explico could not refresh the content manifest: ${
112
+ error instanceof Error ? error.message : String(error)
113
+ }`,
114
+ );
115
+ } finally {
116
+ restarting = false;
117
+ }
118
+ }, 50);
119
+ };
120
+
121
+ server.watcher.on('add', refresh);
122
+ server.watcher.on('change', refresh);
123
+ server.watcher.on('unlink', refresh);
124
+ },
125
+ },
126
+ };
127
+ }
@@ -221,15 +221,11 @@ function processorFor(manifest: Manifest) {
221
221
  // shape here that it does there (D7/F-tables-figures parity).
222
222
  remarkMdx,
223
223
  remarkMath,
224
- [
225
- remarkNotation,
226
- {
227
- definitions: manifest.notation.raw,
228
- equations: manifest.equations.numbersBySlug,
229
- tables: manifest.tables.numbersBySlug,
230
- figures: manifest.figures.numbersBySlug,
231
- },
232
- ],
224
+ // `manifest` carries the number tables *and* the part/offset placement
225
+ // together, so this check numbers a page exactly as the Astro build
226
+ // does. Passing the tables alone silently numbered pages by `##` section
227
+ // here while cross-page references read the part-scheme manifest.
228
+ [remarkNotation, { definitions: manifest.notation.raw, manifest }],
233
229
  [remarkCitation, { sources: manifest.sources }],
234
230
  ],
235
231
  rehypePlugins: [