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