explico 0.2.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 +173 -0
- package/README.md +67 -7
- package/dist/compiler/astro.d.ts +30 -0
- package/dist/compiler/astro.js +79 -0
- package/dist/compiler/cli.d.ts +16 -0
- package/dist/compiler/cli.js +43 -9
- package/dist/compiler/collections.js +4 -1
- package/dist/compiler/course-config.d.ts +80 -4
- package/dist/compiler/course-config.js +12 -0
- package/dist/compiler/knowledge-version.d.ts +58 -0
- package/dist/compiler/knowledge-version.js +80 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +98 -2
- package/dist/compiler/manifest.js +153 -24
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-operators.d.mts +38 -0
- package/dist/reference/base-operators.mjs +103 -0
- package/dist/reference/consistency.d.ts +7 -2
- package/dist/reference/consistency.js +51 -2
- package/dist/reference/diagram-layout.d.ts +63 -0
- package/dist/reference/diagram-layout.js +122 -0
- package/dist/reference/equations-validate.d.ts +10 -0
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +80 -13
- package/dist/reference/equations.mjs +109 -19
- package/dist/reference/figures-validate.d.ts +21 -0
- package/dist/reference/figures-validate.js +13 -0
- package/dist/reference/keyed-float-validate.d.ts +73 -0
- package/dist/reference/keyed-float-validate.js +110 -0
- package/dist/reference/keyed-jsx-floats.d.mts +64 -0
- package/dist/reference/keyed-jsx-floats.mjs +191 -0
- package/dist/reference/math-glyphs.d.mts +18 -0
- package/dist/reference/math-glyphs.mjs +92 -0
- package/dist/reference/remark-notation.d.mts +61 -0
- package/dist/reference/remark-notation.mjs +344 -10
- package/dist/reference/tables-validate.d.ts +21 -0
- package/dist/reference/tables-validate.js +13 -0
- package/dist/reference/units-vocab.js +19 -1
- package/docs/VERSIONING.md +66 -0
- package/docs/authoring/README.md +100 -0
- package/docs/authoring/agents.md +63 -0
- package/docs/authoring/migrating.md +169 -0
- package/docs/authoring/notation.md +297 -0
- package/docs/authoring/structure.md +139 -0
- package/docs/authoring/versioning.md +116 -0
- package/docs/authoring/visualizations.md +195 -0
- package/package.json +20 -5
- package/src/compiler/astro.ts +127 -0
- package/src/compiler/cli.ts +52 -7
- package/src/compiler/collections.ts +12 -3
- package/src/compiler/course-config.ts +93 -3
- package/src/compiler/knowledge-version.ts +157 -0
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +320 -26
- package/src/components/CurriculumMap.astro +27 -0
- package/src/components/diagram/Diagram.astro +317 -0
- package/src/components/examples/CompactExamples.astro +1 -1
- package/src/components/figure/Figure.astro +104 -0
- package/src/components/notation/NotationLayer.astro +11 -0
- package/src/components/notation/render-notation-math.ts +13 -2
- package/src/components/reference/FloatRefLayer.astro +671 -0
- package/src/components/starlight/AboutPanel.astro +255 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
- package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
- package/src/components/starlight/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LayoutSocialIcons.astro +12 -0
- package/src/components/starlight/LessonFooter.astro +15 -2
- package/src/components/table/Table.astro +105 -0
- package/src/reference/base-operators.mjs +103 -0
- package/src/reference/consistency.ts +83 -2
- package/src/reference/diagram-layout.ts +246 -0
- package/src/reference/equations-validate.ts +8 -1
- package/src/reference/equations.mjs +109 -19
- package/src/reference/figures-validate.ts +40 -0
- package/src/reference/keyed-float-validate.ts +210 -0
- package/src/reference/keyed-jsx-floats.mjs +191 -0
- package/src/reference/math-glyphs.mjs +92 -0
- package/src/reference/remark-notation.mjs +344 -10
- package/src/reference/tables-validate.ts +40 -0
- package/src/reference/units-vocab.ts +18 -0
- package/src/styles/global.css +244 -7
- package/src/components/starlight/EquationEnhancer.astro +0 -87
package/src/compiler/cli.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from '@astrojs/markdown-remark';
|
|
36
36
|
import rehypeKatex from 'rehype-katex';
|
|
37
37
|
import remarkMath from 'remark-math';
|
|
38
|
+
import remarkMdx from 'remark-mdx';
|
|
38
39
|
|
|
39
40
|
import { createNotationKatexOptions } from '../reference/katex-options.mjs';
|
|
40
41
|
import rehypeFailKatexErrors from '../reference/rehype-fail-katex-errors.mjs';
|
|
@@ -169,6 +170,25 @@ export function lessonDiagnostics(
|
|
|
169
170
|
});
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
for (const diagnostic of [
|
|
174
|
+
...manifest.diagnostics.tables,
|
|
175
|
+
...manifest.diagnostics.figures,
|
|
176
|
+
]) {
|
|
177
|
+
if (
|
|
178
|
+
diagnostic.lessonId !== id &&
|
|
179
|
+
diagnostic.file !== file &&
|
|
180
|
+
!mentions(diagnostic.message, id, file)
|
|
181
|
+
) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
out.push({
|
|
185
|
+
code: diagnostic.code,
|
|
186
|
+
severity: diagnostic.severity === 'warning' ? 'warning' : 'error',
|
|
187
|
+
message: diagnostic.message,
|
|
188
|
+
...withFile(diagnostic.file),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
172
192
|
for (const diagnostic of manifest.diagnostics.consistency) {
|
|
173
193
|
if (diagnostic.file !== file && diagnostic.lessonId !== id) continue;
|
|
174
194
|
out.push({
|
|
@@ -195,14 +215,17 @@ function processorFor(manifest: Manifest) {
|
|
|
195
215
|
syntaxHighlight: false,
|
|
196
216
|
smartypants: false,
|
|
197
217
|
remarkPlugins: [
|
|
218
|
+
// Syntax only (no JSX evaluation) — teaches the parser `<Table>` /
|
|
219
|
+
// `<Figure>` / `<CompactExample>` are `mdxJsxFlowElement` nodes, same
|
|
220
|
+
// as the real Astro/MDX build, so `remarkNotation` sees the same tree
|
|
221
|
+
// shape here that it does there (D7/F-tables-figures parity).
|
|
222
|
+
remarkMdx,
|
|
198
223
|
remarkMath,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
},
|
|
205
|
-
],
|
|
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 }],
|
|
206
229
|
[remarkCitation, { sources: manifest.sources }],
|
|
207
230
|
],
|
|
208
231
|
rehypePlugins: [
|
|
@@ -441,6 +464,8 @@ function domainSignaturesForSection(section: string): DomainFileSignatures[] {
|
|
|
441
464
|
// --- content status ------------------------------------------------------
|
|
442
465
|
|
|
443
466
|
export interface StatusReport {
|
|
467
|
+
/** The course's published knowledge version (`about.version`). */
|
|
468
|
+
readonly knowledgeVersion: string;
|
|
444
469
|
readonly counts: {
|
|
445
470
|
readonly lessons: number;
|
|
446
471
|
readonly competencies: number;
|
|
@@ -449,6 +474,8 @@ export interface StatusReport {
|
|
|
449
474
|
readonly tracks: number;
|
|
450
475
|
readonly notationDefinitions: number;
|
|
451
476
|
readonly keyedEquations: number;
|
|
477
|
+
readonly keyedTables: number;
|
|
478
|
+
readonly keyedFigures: number;
|
|
452
479
|
};
|
|
453
480
|
readonly draftDebt: {
|
|
454
481
|
readonly lessons: readonly string[];
|
|
@@ -492,18 +519,30 @@ export function contentStatus(manifest: Manifest): StatusReport {
|
|
|
492
519
|
...manifest.diagnostics.alignment.map((d) => d.severity),
|
|
493
520
|
...manifest.diagnostics.math.map((d) => d.severity),
|
|
494
521
|
...manifest.diagnostics.equations.map((d) => d.severity),
|
|
522
|
+
...manifest.diagnostics.tables.map((d) => d.severity),
|
|
523
|
+
...manifest.diagnostics.figures.map((d) => d.severity),
|
|
495
524
|
...manifest.diagnostics.consistency.map(() => 'warning' as const),
|
|
525
|
+
...manifest.diagnostics.knowledge.map((d) => d.severity),
|
|
496
526
|
];
|
|
497
527
|
|
|
498
528
|
const keyedEquations = manifest.lessons.reduce(
|
|
499
529
|
(count, lesson) => count + lesson.equations.length,
|
|
500
530
|
0,
|
|
501
531
|
);
|
|
532
|
+
const keyedTables = manifest.lessons.reduce(
|
|
533
|
+
(count, lesson) => count + lesson.tables.length,
|
|
534
|
+
0,
|
|
535
|
+
);
|
|
536
|
+
const keyedFigures = manifest.lessons.reduce(
|
|
537
|
+
(count, lesson) => count + lesson.figures.length,
|
|
538
|
+
0,
|
|
539
|
+
);
|
|
502
540
|
const assessments = new Set(
|
|
503
541
|
manifest.lessons.flatMap((lesson) => lesson.assessments),
|
|
504
542
|
).size;
|
|
505
543
|
|
|
506
544
|
return {
|
|
545
|
+
knowledgeVersion: manifest.about.knowledge.version,
|
|
507
546
|
counts: {
|
|
508
547
|
lessons: manifest.lessons.length,
|
|
509
548
|
competencies: manifest.competencies.length,
|
|
@@ -512,6 +551,8 @@ export function contentStatus(manifest: Manifest): StatusReport {
|
|
|
512
551
|
tracks: manifest.tracks.length,
|
|
513
552
|
notationDefinitions: manifest.notation.definitions.length,
|
|
514
553
|
keyedEquations,
|
|
554
|
+
keyedTables,
|
|
555
|
+
keyedFigures,
|
|
515
556
|
},
|
|
516
557
|
draftDebt: { lessons: draftLessons, notation: draftNotation },
|
|
517
558
|
orphans,
|
|
@@ -550,6 +591,8 @@ export interface ContextReport {
|
|
|
550
591
|
formula?: string;
|
|
551
592
|
}[];
|
|
552
593
|
readonly equations: readonly { key: string; number: string }[];
|
|
594
|
+
readonly tables: readonly { key: string; number: string }[];
|
|
595
|
+
readonly figures: readonly { key: string; number: string }[];
|
|
553
596
|
readonly sources: readonly {
|
|
554
597
|
id: string;
|
|
555
598
|
title: string;
|
|
@@ -618,6 +661,8 @@ export function contentContext(
|
|
|
618
661
|
: { formula: definition.formula }),
|
|
619
662
|
})),
|
|
620
663
|
equations: lesson.equations.map((equation) => ({ ...equation })),
|
|
664
|
+
tables: lesson.tables.map((table) => ({ ...table })),
|
|
665
|
+
figures: lesson.figures.map((figure) => ({ ...figure })),
|
|
621
666
|
sources: lesson.sources.map((id) => {
|
|
622
667
|
const record = sourceById.get(id) as Record<string, unknown> | undefined;
|
|
623
668
|
return {
|
|
@@ -36,6 +36,7 @@ import type {
|
|
|
36
36
|
} from '../reference/types';
|
|
37
37
|
import { isSubstantiveMeaning } from '../reference/prose';
|
|
38
38
|
import { parseEquationRef } from '../reference/equations.mjs';
|
|
39
|
+
import { parseKeyedFloatRef } from '../reference/keyed-jsx-floats.mjs';
|
|
39
40
|
import { resolveLabel } from '../reference/label';
|
|
40
41
|
import { resolveGlosses } from '../reference/gloss';
|
|
41
42
|
import {
|
|
@@ -229,9 +230,17 @@ export function extractNotationReferences(
|
|
|
229
230
|
for (const [pattern, referenceKind] of patterns) {
|
|
230
231
|
for (const match of searchable.matchAll(pattern)) {
|
|
231
232
|
const key = (match[1] ?? '').trim();
|
|
232
|
-
// `[[eq
|
|
233
|
-
//
|
|
234
|
-
|
|
233
|
+
// `[[eq-key]]` / `[[slug#eq-key]]` are equation references (D7), and
|
|
234
|
+
// `[[tab-key]]`/`[[fig-key]]` (and their cross-page forms) are
|
|
235
|
+
// `<Table>`/`<Figure>` references (F-tables-figures) — all resolved by
|
|
236
|
+
// the remark pass and the manifest, not notation keys.
|
|
237
|
+
if (
|
|
238
|
+
parseEquationRef(key) ||
|
|
239
|
+
parseKeyedFloatRef(key, { prefix: 'tab' }) ||
|
|
240
|
+
parseKeyedFloatRef(key, { prefix: 'fig' })
|
|
241
|
+
) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
235
244
|
references.push({
|
|
236
245
|
key,
|
|
237
246
|
kind: referenceKind,
|
|
@@ -7,13 +7,25 @@
|
|
|
7
7
|
* supplies its own value of this shape.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* One **part** — a sidebar group, holding the chapters (lessons) whose slugs
|
|
12
|
+
* start with `dir`. A part's 1-based position in `CourseConfig.parts` is the
|
|
13
|
+
* number before the dot in every equation, table, and figure number inside it.
|
|
14
|
+
* See `docs/authoring/structure.md` for the full level taxonomy.
|
|
15
|
+
*/
|
|
16
|
+
export interface CoursePart {
|
|
11
17
|
/** Lesson-slug prefix, e.g. `bonds` for `bonds/yield-to-maturity`. */
|
|
12
18
|
readonly dir: string;
|
|
13
19
|
/** Sidebar group heading. */
|
|
14
20
|
readonly label: string;
|
|
15
21
|
}
|
|
16
22
|
|
|
23
|
+
/**
|
|
24
|
+
* @deprecated Renamed to {@link CoursePart}. "Section" now only ever means an
|
|
25
|
+
* `##` heading inside a lesson. Removed in the next MINOR release.
|
|
26
|
+
*/
|
|
27
|
+
export type CourseSection = CoursePart;
|
|
28
|
+
|
|
17
29
|
export interface CourseConventions {
|
|
18
30
|
/** Notation key a lesson's stated sign convention must resolve to. */
|
|
19
31
|
readonly signConventionKey: string;
|
|
@@ -21,12 +33,90 @@ export interface CourseConventions {
|
|
|
21
33
|
readonly cashFlowPerspectiveCompetency: string;
|
|
22
34
|
}
|
|
23
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A course's edits to the shipped named-operator baseline (`BASE_OPERATORS`),
|
|
38
|
+
* consumed by the `operator-explained` consistency check (rule N8). An operator
|
|
39
|
+
* a lesson uses is fine when it is on the baseline plus `allow` minus `deny`;
|
|
40
|
+
* otherwise it needs a notation entry the reader can hover.
|
|
41
|
+
*
|
|
42
|
+
* Names are the KaTeX command without its backslash (`erf`, not `\erf`) for a
|
|
43
|
+
* built-in, and the verbatim body for an `\operatorname{…}` operator (`Var`).
|
|
44
|
+
*/
|
|
45
|
+
export interface CourseOperators {
|
|
46
|
+
/** Operators this course also treats as universal (`Var`, `Cov`, `sgn`, …). */
|
|
47
|
+
readonly allow?: readonly string[];
|
|
48
|
+
/** Baseline operators this course wants explained anyway. */
|
|
49
|
+
readonly deny?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One person or team credited for the study material. */
|
|
53
|
+
export interface CourseAuthor {
|
|
54
|
+
readonly name: string;
|
|
55
|
+
/**
|
|
56
|
+
* Email, URL, or handle. Rendered in the About panel as a link when it looks
|
|
57
|
+
* like a `mailto:` or `https:` target, plain text otherwise.
|
|
58
|
+
*/
|
|
59
|
+
readonly contact?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The reader-facing "about this material" block, surfaced by the `[?]` panel in
|
|
64
|
+
* the header (`components/starlight/AboutPanel.astro`) on every page.
|
|
65
|
+
*
|
|
66
|
+
* `version` is the course's **knowledge version** — semver over the
|
|
67
|
+
* learning-facing content, independent of the `explico` engine's own version.
|
|
68
|
+
* It must equal the newest entry in `content/CHANGELOG.md`; a mismatch is a
|
|
69
|
+
* Tier-3 `knowledge-version-mismatch` warning (rule C3, `docs/authoring/versioning.md`).
|
|
70
|
+
*/
|
|
71
|
+
export interface CourseAbout {
|
|
72
|
+
/** Display name of the material. Defaults to {@link CourseConfig.title}. */
|
|
73
|
+
readonly materialName?: string;
|
|
74
|
+
/** One or two sentences on what the material covers. Defaults to {@link CourseConfig.description}. */
|
|
75
|
+
readonly summary?: string;
|
|
76
|
+
/** Knowledge version — semver; equals the latest `content/CHANGELOG.md` heading. */
|
|
77
|
+
readonly version: string;
|
|
78
|
+
/** Who authored / maintains the material. At least one entry. */
|
|
79
|
+
readonly authors: readonly CourseAuthor[];
|
|
80
|
+
/** Shown verbatim in the About panel — scope, warranty, AI-assistance note. */
|
|
81
|
+
readonly disclaimer: string;
|
|
82
|
+
/** Link to the published content changelog (repo blob, docs page, …). */
|
|
83
|
+
readonly changelogHref?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
24
86
|
export interface CourseConfig {
|
|
25
87
|
readonly title: string;
|
|
26
88
|
readonly description: string;
|
|
27
|
-
/**
|
|
28
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The course's parts (sidebar groups), in order. Exactly one of `parts` or
|
|
91
|
+
* the deprecated `sections` must be given.
|
|
92
|
+
*/
|
|
93
|
+
readonly parts?: readonly CoursePart[];
|
|
94
|
+
/**
|
|
95
|
+
* @deprecated Renamed to {@link CourseConfig.parts}. Still honoured, so a
|
|
96
|
+
* course on the old name keeps building; removed in the next MINOR release.
|
|
97
|
+
*/
|
|
98
|
+
readonly sections?: readonly CoursePart[];
|
|
29
99
|
/** Namespaces used by competency / notation `domain` fields. */
|
|
30
100
|
readonly domains: readonly string[];
|
|
31
101
|
readonly conventions: CourseConventions;
|
|
102
|
+
/** Reader-facing identity + knowledge version (the `[?]` About panel). */
|
|
103
|
+
readonly about: CourseAbout;
|
|
104
|
+
/** Edits to the shipped named-operator baseline (rule N8). Optional. */
|
|
105
|
+
readonly operators?: CourseOperators;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The course's parts, from whichever field the config uses. Throws rather than
|
|
110
|
+
* silently producing a course with no sidebar groups — a config with neither
|
|
111
|
+
* field would otherwise number every float by the `##`-section fallback.
|
|
112
|
+
*/
|
|
113
|
+
export function coursePartsOf(config: CourseConfig): readonly CoursePart[] {
|
|
114
|
+
const parts = config.parts ?? config.sections;
|
|
115
|
+
if (!parts || parts.length === 0) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
'CourseConfig needs at least one entry in `parts` (a sidebar group: ' +
|
|
118
|
+
'{ dir, label }). The old name `sections` is still accepted.',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return parts;
|
|
32
122
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The course's **knowledge version** — semver over the learning-facing content,
|
|
3
|
+
* independent of the `explico` engine's own package version.
|
|
4
|
+
*
|
|
5
|
+
* The declared value lives in `content/course.config.ts` (`about.version`); the
|
|
6
|
+
* history and the human-readable notes live in `content/CHANGELOG.md`
|
|
7
|
+
* (Keep a Changelog shape). This module parses that file and checks the two
|
|
8
|
+
* agree — rule C3 in `docs/authoring/versioning.md`. Every finding is a Tier-3
|
|
9
|
+
* warning: `pnpm validate:content` prints it, `manifest.diagnostics.knowledge`
|
|
10
|
+
* records it, and the build still passes. The resolved entry is what the `[?]`
|
|
11
|
+
* About panel publishes to readers (`manifest.about.knowledge`).
|
|
12
|
+
*
|
|
13
|
+
* Pure: `parseKnowledgeChangelog` takes text, `resolveKnowledgeVersion` takes
|
|
14
|
+
* text + the declared version. The caller (`compileManifest`) does the file IO.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A released entry from `content/CHANGELOG.md`, newest first in file order. */
|
|
18
|
+
export interface KnowledgeChangelogEntry {
|
|
19
|
+
readonly version: string;
|
|
20
|
+
/** The `- YYYY-MM-DD` part of the heading, if present. */
|
|
21
|
+
readonly date?: string;
|
|
22
|
+
/** Bullet lines under the heading, marker stripped, in file order. */
|
|
23
|
+
readonly notes: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type KnowledgeVersionCode =
|
|
27
|
+
| 'knowledge-changelog-missing'
|
|
28
|
+
| 'knowledge-version-invalid'
|
|
29
|
+
| 'knowledge-version-mismatch';
|
|
30
|
+
|
|
31
|
+
export interface KnowledgeVersionDiagnostic {
|
|
32
|
+
readonly code: KnowledgeVersionCode;
|
|
33
|
+
/** Tier 3 — a knowledge-version drift never fails the build. */
|
|
34
|
+
readonly severity: 'warning';
|
|
35
|
+
readonly message: string;
|
|
36
|
+
readonly file?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface KnowledgeVersion {
|
|
40
|
+
/** The declared `about.version`, echoed for consumers. */
|
|
41
|
+
readonly version: string;
|
|
42
|
+
/** The newest released `## [x.y.z]` entry, when the changelog has one. */
|
|
43
|
+
readonly latest?: KnowledgeChangelogEntry;
|
|
44
|
+
readonly diagnostics: readonly KnowledgeVersionDiagnostic[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `1.2.3`, optionally `-rc.1` / `+build.5` — the shape `about.version` and a
|
|
48
|
+
// `## [x.y.z]` heading must both take.
|
|
49
|
+
const SEMVER_RE =
|
|
50
|
+
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
51
|
+
const HEADING_RE = /^##\s+\[([^\]]+)\]\s*(?:[-–—]\s*(\S.*?))?\s*$/;
|
|
52
|
+
const BULLET_RE = /^[-*]\s+(.*\S)\s*$/;
|
|
53
|
+
|
|
54
|
+
export function isKnowledgeSemver(value: string): boolean {
|
|
55
|
+
return SEMVER_RE.test(value.trim());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every released entry in a `content/CHANGELOG.md` body, in file order (newest
|
|
60
|
+
* first, by Keep a Changelog convention). `## [Unreleased]` is skipped.
|
|
61
|
+
*/
|
|
62
|
+
export function parseKnowledgeChangelog(
|
|
63
|
+
text: string,
|
|
64
|
+
): KnowledgeChangelogEntry[] {
|
|
65
|
+
const lines = text.split(/\r?\n/);
|
|
66
|
+
const entries: {
|
|
67
|
+
version: string;
|
|
68
|
+
date?: string;
|
|
69
|
+
notes: string[];
|
|
70
|
+
}[] = [];
|
|
71
|
+
let current: { version: string; date?: string; notes: string[] } | null =
|
|
72
|
+
null;
|
|
73
|
+
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
const heading = HEADING_RE.exec(line);
|
|
76
|
+
if (heading) {
|
|
77
|
+
const token = (heading[1] ?? '').trim();
|
|
78
|
+
if (/^unreleased$/i.test(token)) {
|
|
79
|
+
current = null;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
current = { version: token, notes: [] };
|
|
83
|
+
const date = (heading[2] ?? '').trim();
|
|
84
|
+
if (date) current.date = date;
|
|
85
|
+
entries.push(current);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// A top-level `#` or a deeper `###` heading ends the current entry's notes.
|
|
89
|
+
if (/^#{1,3}\s/.test(line) && !line.startsWith('## [')) {
|
|
90
|
+
current = null;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!current) continue;
|
|
94
|
+
const bullet = BULLET_RE.exec(line);
|
|
95
|
+
if (bullet) current.notes.push(bullet[1] ?? '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return entries.map((entry) => ({
|
|
99
|
+
version: entry.version,
|
|
100
|
+
...(entry.date === undefined ? {} : { date: entry.date }),
|
|
101
|
+
notes: [...entry.notes],
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check the declared knowledge version against `content/CHANGELOG.md`.
|
|
107
|
+
*
|
|
108
|
+
* @param options.changelogText the file body, or `undefined` when it is absent
|
|
109
|
+
* @param options.declaredVersion `courseConfig.about.version`
|
|
110
|
+
* @param options.file repo-relative path, for diagnostic messages
|
|
111
|
+
*/
|
|
112
|
+
export function resolveKnowledgeVersion(options: {
|
|
113
|
+
readonly changelogText: string | undefined;
|
|
114
|
+
readonly declaredVersion: string;
|
|
115
|
+
readonly file?: string;
|
|
116
|
+
}): KnowledgeVersion {
|
|
117
|
+
const { changelogText, declaredVersion } = options;
|
|
118
|
+
const file = options.file ?? 'content/CHANGELOG.md';
|
|
119
|
+
const diagnostics: KnowledgeVersionDiagnostic[] = [];
|
|
120
|
+
const warn = (code: KnowledgeVersionCode, message: string): void => {
|
|
121
|
+
diagnostics.push({ code, severity: 'warning', message, file });
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (!isKnowledgeSemver(declaredVersion)) {
|
|
125
|
+
warn(
|
|
126
|
+
'knowledge-version-invalid',
|
|
127
|
+
`about.version ${JSON.stringify(declaredVersion)} in content/course.config.ts is not a semver \`x.y.z\` string.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (changelogText === undefined) {
|
|
132
|
+
warn(
|
|
133
|
+
'knowledge-changelog-missing',
|
|
134
|
+
`No ${file}. Add a Keep a Changelog file whose newest \`## [x.y.z]\` heading is ${JSON.stringify(declaredVersion)} (rule C1).`,
|
|
135
|
+
);
|
|
136
|
+
return { version: declaredVersion, diagnostics };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entries = parseKnowledgeChangelog(changelogText);
|
|
140
|
+
const latest = entries[0];
|
|
141
|
+
if (!latest) {
|
|
142
|
+
warn(
|
|
143
|
+
'knowledge-changelog-missing',
|
|
144
|
+
`${file} has no released \`## [x.y.z]\` entry — only \`## [Unreleased]\`. Cut a release: move it to \`## [${declaredVersion}] - <date>\` (rule C4).`,
|
|
145
|
+
);
|
|
146
|
+
return { version: declaredVersion, diagnostics };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (isKnowledgeSemver(declaredVersion) && latest.version !== declaredVersion) {
|
|
150
|
+
warn(
|
|
151
|
+
'knowledge-version-mismatch',
|
|
152
|
+
`about.version is ${JSON.stringify(declaredVersion)} but the newest ${file} entry is ${JSON.stringify(latest.version)}. Bump one to match the other (rule C3).`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { version: declaredVersion, latest, diagnostics };
|
|
157
|
+
}
|
|
@@ -156,7 +156,7 @@ export type SidebarGroup =
|
|
|
156
156
|
export function buildSidebar(
|
|
157
157
|
tracks: readonly TrackLike[],
|
|
158
158
|
lessonSlugs: readonly string[],
|
|
159
|
-
|
|
159
|
+
parts: readonly SidebarSection[],
|
|
160
160
|
): SidebarGroup[] {
|
|
161
161
|
const rank = new Map(
|
|
162
162
|
mergeTrackOrder(tracks).map((lessonId, index) => [lessonId, index]),
|
|
@@ -164,7 +164,7 @@ export function buildSidebar(
|
|
|
164
164
|
const positionOf = (slug: string) =>
|
|
165
165
|
rank.get(lessonIdFromSlug(slug)) ?? Number.POSITIVE_INFINITY;
|
|
166
166
|
|
|
167
|
-
const groups: SidebarGroup[] =
|
|
167
|
+
const groups: SidebarGroup[] = parts.map(({ dir, label }) => ({
|
|
168
168
|
label,
|
|
169
169
|
items: lessonSlugs
|
|
170
170
|
.filter((slug) => slug.startsWith(`${dir}/`))
|