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.
- package/CHANGELOG.md +72 -1
- package/README.md +21 -2
- package/dist/compiler/astro.d.ts +30 -0
- package/dist/compiler/astro.js +79 -0
- package/dist/compiler/cli.js +5 -9
- package/dist/compiler/course-config.d.ts +29 -4
- package/dist/compiler/course-config.js +12 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +25 -4
- package/dist/compiler/manifest.js +59 -27
- package/dist/reference/equations-validate.d.ts +8 -3
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +58 -22
- package/dist/reference/equations.mjs +75 -26
- package/dist/reference/keyed-float-validate.d.ts +9 -3
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/keyed-jsx-floats.d.mts +18 -10
- package/dist/reference/keyed-jsx-floats.mjs +42 -18
- package/dist/reference/remark-notation.d.mts +54 -5
- package/dist/reference/remark-notation.mjs +161 -17
- package/docs/authoring/README.md +4 -0
- package/docs/authoring/migrating.md +62 -0
- package/docs/authoring/notation.md +8 -7
- package/docs/authoring/structure.md +139 -0
- package/package.json +1 -1
- package/src/compiler/astro.ts +127 -0
- package/src/compiler/cli.ts +5 -9
- package/src/compiler/course-config.ts +39 -3
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +108 -36
- package/src/components/notation/NotationLayer.astro +11 -0
- package/src/components/starlight/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LessonFooter.astro +11 -0
- package/src/components/table/Table.astro +3 -2
- package/src/reference/equations-validate.ts +6 -4
- package/src/reference/equations.mjs +75 -26
- package/src/reference/keyed-float-validate.ts +7 -4
- package/src/reference/keyed-jsx-floats.mjs +42 -18
- package/src/reference/remark-notation.mjs +161 -17
- 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
|
-
* `
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
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
|
-
|
|
707
|
-
//
|
|
708
|
-
|
|
709
|
-
|
|
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. `
|
|
722
|
-
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
922
|
+
numbering.tables,
|
|
923
|
+
'tables',
|
|
781
924
|
);
|
|
782
925
|
const { ctx: figureCtx, byNode: figureByNode } = makeFloatCtx(
|
|
783
926
|
'Figure',
|
|
784
|
-
|
|
927
|
+
numbering.figures,
|
|
928
|
+
'figures',
|
|
785
929
|
);
|
|
786
930
|
|
|
787
931
|
const resolve = (key, kind, node) => {
|
package/docs/authoring/README.md
CHANGED
|
@@ -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 `‹
|
|
151
|
-
|
|
152
|
-
equations
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
"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
|
+
}
|
package/src/compiler/cli.ts
CHANGED
|
@@ -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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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: [
|