explico 0.13.1 → 0.15.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 +990 -1
- package/README.md +200 -30
- package/dist/compiler/astro.d.ts +15 -2
- package/dist/compiler/astro.js +40 -11
- package/dist/compiler/cli-main.d.ts +100 -0
- package/dist/compiler/cli-main.js +433 -0
- package/dist/compiler/cli-reference.d.ts +50 -0
- package/dist/compiler/cli-reference.js +213 -0
- package/dist/compiler/cli-verbs.d.ts +45 -0
- package/dist/compiler/cli-verbs.js +224 -0
- package/dist/compiler/cli-verify.d.ts +51 -0
- package/dist/compiler/cli-verify.js +151 -0
- package/dist/compiler/cli.d.ts +408 -24
- package/dist/compiler/cli.js +1328 -8
- package/dist/compiler/collections.d.ts +88 -1
- package/dist/compiler/collections.js +170 -25
- package/dist/compiler/content-release.d.ts +49 -0
- package/dist/compiler/content-release.js +63 -0
- package/dist/compiler/content-schemas.d.ts +2 -2
- package/dist/compiler/course-config.d.ts +1 -1
- package/dist/compiler/course-config.js +105 -1
- package/dist/compiler/decks.d.ts +161 -0
- package/dist/compiler/decks.js +235 -0
- package/dist/compiler/exports/combined-html.d.ts +5 -1
- package/dist/compiler/exports/deck-fit.d.ts +55 -0
- package/dist/compiler/exports/deck-fit.js +90 -0
- package/dist/compiler/exports/export-chapters.d.ts +37 -6
- package/dist/compiler/exports/export-chapters.js +64 -5
- package/dist/compiler/exports/export-pages.d.ts +30 -3
- package/dist/compiler/exports/export-pages.js +24 -1
- package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
- package/dist/compiler/exports/latex/cache.d.ts +78 -0
- package/dist/compiler/exports/latex/cache.js +129 -0
- package/dist/compiler/exports/latex/capture.d.ts +10 -1
- package/dist/compiler/exports/latex/capture.js +3 -1
- package/dist/compiler/exports/latex/compile.d.ts +13 -2
- package/dist/compiler/exports/latex/compile.js +12 -1
- package/dist/compiler/exports/latex/deck.d.ts +92 -0
- package/dist/compiler/exports/latex/deck.js +349 -0
- package/dist/compiler/exports/latex/document.d.ts +51 -6
- package/dist/compiler/exports/latex/document.js +71 -12
- package/dist/compiler/exports/latex/explico-common.sty +198 -0
- package/dist/compiler/exports/latex/explico.cls +13 -183
- package/dist/compiler/exports/latex/serialize.d.ts +60 -4
- package/dist/compiler/exports/latex/serialize.js +4 -2
- package/dist/compiler/exports/latex-export.d.ts +24 -2
- package/dist/compiler/exports/latex-export.js +107 -26
- package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
- package/dist/compiler/exports/markdown/serialize.js +85 -8
- package/dist/compiler/exports/markdown-export.d.ts +6 -1
- package/dist/compiler/exports/markdown-export.js +50 -11
- package/dist/compiler/exports/standalone-html.d.ts +10 -5
- package/dist/compiler/exports/standalone-html.js +3 -3
- package/dist/compiler/exports/static-tree.d.ts +38 -1
- package/dist/compiler/exports/static-tree.js +120 -4
- package/dist/compiler/knowledge-version.d.ts +26 -2
- package/dist/compiler/knowledge-version.js +27 -17
- package/dist/compiler/lesson-derivation.d.ts +143 -6
- package/dist/compiler/lesson-derivation.js +145 -13
- package/dist/compiler/manifest.d.ts +133 -4
- package/dist/compiler/manifest.js +364 -58
- package/dist/compiler/page-versions.d.ts +184 -0
- package/dist/compiler/page-versions.js +217 -0
- package/dist/compiler/render-diagnostics.d.ts +5 -1
- package/dist/compiler/resolution-report.d.ts +24 -6
- package/dist/compiler/resolution-report.js +22 -4
- package/dist/compiler/validate-content.d.ts +7 -0
- package/dist/compiler/validate-content.js +160 -0
- package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
- package/dist/curriculum/competency-graph.d.ts +54 -0
- package/dist/curriculum/competency-graph.js +41 -0
- package/dist/reference/base-library.d.mts +8 -0
- package/dist/reference/base-library.mjs +19 -0
- package/dist/reference/consistency.d.ts +8 -4
- package/dist/reference/consistency.js +82 -26
- package/dist/reference/deck-remark.d.mts +123 -0
- package/dist/reference/deck-remark.mjs +600 -0
- package/dist/reference/deck-source.d.ts +107 -0
- package/dist/reference/deck-source.js +140 -0
- package/dist/reference/decks-validate.d.ts +133 -0
- package/dist/reference/decks-validate.js +359 -0
- package/dist/reference/derived-entries.d.ts +5 -1
- package/dist/reference/diagram-fit.d.ts +6 -2
- package/dist/reference/diagram-fit.js +5 -1
- package/dist/reference/figure-symbols.d.ts +23 -6
- package/dist/reference/figure-symbols.js +51 -15
- package/dist/reference/float-name.d.ts +43 -0
- package/dist/reference/float-name.js +58 -0
- package/dist/reference/folds-validate.d.ts +1 -1
- package/dist/reference/gate-abbreviations.d.ts +1 -1
- package/dist/reference/gate-indices.js +5 -4
- package/dist/reference/gate-math.d.ts +29 -2
- package/dist/reference/gate-math.js +99 -18
- package/dist/reference/gate-part-pages.d.ts +1 -1
- package/dist/reference/lab-katex-validate.d.ts +10 -6
- package/dist/reference/lab-katex-validate.js +17 -9
- package/dist/reference/lab-math-scope.js +29 -4
- package/dist/reference/math-glyphs.d.mts +25 -0
- package/dist/reference/math-glyphs.mjs +189 -25
- package/dist/reference/page-notation.d.ts +22 -3
- package/dist/reference/page-notation.js +32 -10
- package/dist/reference/references.d.ts +14 -1
- package/dist/reference/references.js +13 -0
- package/dist/reference/registry-scope.d.mts +33 -0
- package/dist/reference/registry-scope.mjs +34 -0
- package/dist/reference/registry.js +29 -10
- package/dist/reference/rehype-deck.d.mts +6 -0
- package/dist/reference/rehype-deck.mjs +286 -0
- package/dist/reference/rehype-heading-anchors.d.mts +20 -0
- package/dist/reference/rehype-heading-anchors.mjs +48 -6
- package/dist/reference/remark-notation.mjs +249 -40
- package/dist/reference/source-lines.d.mts +12 -0
- package/dist/reference/source-lines.mjs +65 -0
- package/dist/reference/types.d.ts +28 -0
- package/dist/rules/catalog.d.ts +21 -4
- package/dist/rules/catalog.js +237 -2
- package/dist/rules/course-rules.d.ts +5 -1
- package/dist/rules/course-rules.js +17 -3
- package/dist/rules/course-rules.schema.json +12 -0
- package/dist/rules/in-effect.d.ts +13 -3
- package/dist/rules/in-effect.js +34 -10
- package/dist/rules/rules.schema.json +280 -1
- package/dist/rules/settings.d.ts +1 -1
- package/dist/session/PreferenceStore.d.ts +6 -0
- package/dist/session/PreferenceStore.js +18 -1
- package/dist/session/deck-memory.d.ts +116 -0
- package/dist/session/deck-memory.js +143 -0
- package/dist/session/reading-memory.d.ts +96 -0
- package/dist/session/reading-memory.js +133 -0
- package/docs/VERSIONING.md +21 -18
- package/docs/authoring/README.md +31 -9
- package/docs/authoring/agents.md +72 -18
- package/docs/authoring/cli-reference.json +2172 -0
- package/docs/authoring/cli.md +887 -0
- package/docs/authoring/components.md +15 -0
- package/docs/authoring/deck.md +612 -0
- package/docs/authoring/migrating.md +1195 -16
- package/docs/authoring/notation.md +49 -6
- package/docs/authoring/structure.md +80 -1
- package/docs/authoring/versioning.md +56 -3
- package/docs/authoring/visualizations.md +7 -2
- package/docs/course-agent/AGENTS.md +40 -0
- package/docs/course-agent/agents/rules-reviewer.md +33 -0
- package/docs/course-agent/skills/draft-lesson/SKILL.md +38 -0
- package/docs/course-agent/skills/migrate-course/SKILL.md +32 -0
- package/docs/course-agent/skills/release-content/SKILL.md +26 -0
- package/docs/diagram-scaling.md +148 -0
- package/docs/privacy.md +59 -0
- package/package.json +8 -2
- package/src/compiler/astro.ts +60 -21
- package/src/compiler/cli-main.ts +557 -0
- package/src/compiler/cli-reference.ts +285 -0
- package/src/compiler/cli-verbs.ts +270 -0
- package/src/compiler/cli-verify.ts +241 -0
- package/src/compiler/cli.ts +2119 -170
- package/src/compiler/collections.ts +284 -25
- package/src/compiler/content-release.ts +91 -0
- package/src/compiler/course-config.ts +267 -0
- package/src/compiler/decks.ts +455 -0
- package/src/compiler/exports/deck-fit.ts +127 -0
- package/src/compiler/exports/export-chapters.ts +105 -6
- package/src/compiler/exports/export-pages.ts +46 -2
- package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
- package/src/compiler/exports/latex/cache.ts +183 -0
- package/src/compiler/exports/latex/capture.ts +5 -2
- package/src/compiler/exports/latex/compile.ts +18 -0
- package/src/compiler/exports/latex/deck.ts +419 -0
- package/src/compiler/exports/latex/document.ts +117 -21
- package/src/compiler/exports/latex/explico-common.sty +198 -0
- package/src/compiler/exports/latex/explico.cls +13 -183
- package/src/compiler/exports/latex/serialize.ts +25 -21
- package/src/compiler/exports/latex/texlive-packages.txt +6 -2
- package/src/compiler/exports/latex-export.ts +187 -45
- package/src/compiler/exports/markdown/serialize.ts +118 -9
- package/src/compiler/exports/markdown-export.ts +56 -11
- package/src/compiler/exports/standalone-html.ts +8 -7
- package/src/compiler/exports/static-tree.ts +175 -3
- package/src/compiler/knowledge-version.ts +56 -21
- package/src/compiler/lesson-derivation.ts +282 -16
- package/src/compiler/manifest.ts +568 -63
- package/src/compiler/page-versions.ts +401 -0
- package/src/compiler/resolution-report.ts +38 -7
- package/src/compiler/validate-content.ts +215 -0
- package/src/components/CourseContents.astro +124 -6
- package/src/components/CurriculumMap.astro +49 -1
- package/src/components/FloatIndex.astro +1 -1
- package/src/components/PartOverview.astro +51 -5
- package/src/components/assessments/AssessmentSet.astro +41 -15
- package/src/components/block-heading.ts +15 -6
- package/src/components/card-carry.ts +7 -4
- package/src/components/card-manager.ts +14 -2
- package/src/components/card-memory.ts +13 -9
- package/src/components/deck/DeckEnhancer.astro +13 -0
- package/src/components/deck/deck-context.ts +104 -0
- package/src/components/deck/deck-controller.ts +1287 -0
- package/src/components/deck/deck-map.ts +91 -0
- package/src/components/deck-lane.ts +163 -0
- package/src/components/diagram/Diagram.astro +131 -86
- package/src/components/disclosure/persisted-disclosure.ts +9 -1
- package/src/components/figure/Figure.astro +7 -2
- package/src/components/figure/FloatCaption.astro +5 -3
- package/src/components/fold/Fold.astro +2 -2
- package/src/components/notation/NotationFormula.astro +81 -11
- package/src/components/notation/NotationGlossary.astro +132 -1
- package/src/components/notation/NotationLayer.astro +15 -7
- package/src/components/starlight/AboutPanel.astro +186 -3
- package/src/components/starlight/CollapsibleSections.astro +24 -3
- package/src/components/starlight/DiagramEnhancer.astro +5 -1
- package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
- package/src/components/starlight/LayoutHeader.astro +17 -0
- package/src/components/starlight/LayoutPageTitle.astro +110 -0
- package/src/components/starlight/LayoutSidebar.astro +807 -21
- package/src/components/starlight/LayoutSocialIcons.astro +62 -6
- package/src/components/starlight/LessonExportActions.astro +27 -9
- package/src/components/starlight/LessonFooter.astro +59 -0
- package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
- package/src/components/starlight/TextSizeControl.astro +24 -13
- package/src/components/starlight/page-marks.ts +139 -0
- package/src/components/starlight/section-summary.ts +1 -1
- package/src/components/starlight/sidebar-editions.ts +245 -0
- package/src/components/table/Table.astro +15 -7
- package/src/content-config.ts +87 -1
- package/src/curriculum/competency-graph.ts +95 -0
- package/src/reference/base-library.mjs +19 -0
- package/src/reference/consistency.ts +137 -39
- package/src/reference/deck-remark.mjs +600 -0
- package/src/reference/deck-source.ts +273 -0
- package/src/reference/decks-validate.ts +604 -0
- package/src/reference/diagram-fit.ts +18 -4
- package/src/reference/diagram-layout.ts +3 -1
- package/src/reference/figure-symbols.ts +108 -31
- package/src/reference/folds-validate.ts +1 -1
- package/src/reference/gate-indices.ts +5 -3
- package/src/reference/gate-math.ts +169 -25
- package/src/reference/lab-katex-validate.ts +30 -15
- package/src/reference/lab-math-scope.ts +34 -3
- package/src/reference/math-glyphs.mjs +189 -25
- package/src/reference/page-notation.ts +69 -15
- package/src/reference/references.ts +28 -0
- package/src/reference/registry-scope.mjs +34 -0
- package/src/reference/registry.ts +41 -11
- package/src/reference/rehype-deck.mjs +286 -0
- package/src/reference/rehype-heading-anchors.mjs +48 -6
- package/src/reference/remark-notation.mjs +249 -40
- package/src/reference/source-lines.mjs +65 -0
- package/src/reference/types.ts +28 -0
- package/src/rules/catalog.ts +274 -4
- package/src/rules/course-rules.ts +21 -1
- package/src/rules/in-effect.ts +58 -11
- package/src/session/PreferenceStore.ts +23 -0
- package/src/session/deck-memory.ts +263 -0
- package/src/session/reading-memory.ts +243 -0
- package/src/styles/deck.css +1150 -0
- package/src/styles/global.css +958 -27
- package/src/styles/zones.css +277 -0
- /package/src/{components → reference}/float-name.ts +0 -0
package/src/compiler/cli.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The `content` CLI core (Phase G1).
|
|
3
3
|
*
|
|
4
|
-
* `
|
|
4
|
+
* `./cli-main` is the router (`runContentCli`), a course's `scripts/content.ts`
|
|
5
|
+
* a three-line shim over it; every command's logic lives here
|
|
5
6
|
* as a pure-ish function that takes a compiled {@link Manifest} and returns a
|
|
6
7
|
* plain data object. The router is **read-only** — only `content new` touches
|
|
7
8
|
* the filesystem, and even then `scaffoldLesson` / `scaffoldTerm` only return
|
|
@@ -31,9 +32,9 @@
|
|
|
31
32
|
*/
|
|
32
33
|
|
|
33
34
|
import { execFileSync } from 'node:child_process';
|
|
34
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
35
|
-
import { join, relative } from 'node:path';
|
|
36
|
-
import { pathToFileURL } from 'node:url';
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
36
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
37
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
37
38
|
|
|
38
39
|
import {
|
|
39
40
|
createMarkdownProcessor,
|
|
@@ -53,11 +54,27 @@ import remarkNotation, {
|
|
|
53
54
|
} from '../reference/remark-notation.mjs';
|
|
54
55
|
import remarkShortNote from '../reference/remark-short-note.mjs';
|
|
55
56
|
import type { DiagnosticSeverity } from '../reference/types';
|
|
56
|
-
import
|
|
57
|
-
|
|
57
|
+
import {
|
|
58
|
+
explainCalls,
|
|
59
|
+
mathSpans,
|
|
60
|
+
redundantExplainWraps,
|
|
61
|
+
type EquationLink,
|
|
62
|
+
type ScopeEntry,
|
|
63
|
+
} from '../reference/gate-math';
|
|
64
|
+
import { resolveMathGlyphs } from '../reference/math-glyphs.mjs';
|
|
65
|
+
import { stripEquationLabels } from '../reference/equations.mjs';
|
|
66
|
+
import type { Manifest, ManifestLesson, ManifestLessonFloat } from './manifest';
|
|
58
67
|
import { readRenderDiagnostics, type RenderDiagnostic } from './render-diagnostics';
|
|
59
|
-
import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
|
|
60
|
-
import {
|
|
68
|
+
import { isLessonSlug, lessonIdFromSlug, mergeTrackOrder } from './lesson-derivation';
|
|
69
|
+
import {
|
|
70
|
+
isCourseRuleId,
|
|
71
|
+
ownerOf,
|
|
72
|
+
retiredRule,
|
|
73
|
+
ELEMENTS,
|
|
74
|
+
type Element,
|
|
75
|
+
type Enforcement,
|
|
76
|
+
type RetiredRule,
|
|
77
|
+
} from '../rules/catalog';
|
|
61
78
|
import {
|
|
62
79
|
renderRulesInEffect,
|
|
63
80
|
rulesInEffect,
|
|
@@ -65,6 +82,7 @@ import {
|
|
|
65
82
|
type RulesFilter,
|
|
66
83
|
} from '../rules/in-effect';
|
|
67
84
|
import { RESERVED_KEY_PREFIXES, reservedKeyPrefix } from '../reference/references';
|
|
85
|
+
import { glyphMatcher } from '../reference/consistency';
|
|
68
86
|
|
|
69
87
|
const REPOSITORY_ROOT = process.cwd();
|
|
70
88
|
// Content and its course math are the top-level `content/` folder (Phase F).
|
|
@@ -82,7 +100,11 @@ export type ContentCliCode =
|
|
|
82
100
|
| 'invalid-id'
|
|
83
101
|
| 'exists'
|
|
84
102
|
| 'not-a-lesson'
|
|
85
|
-
| 'no-git'
|
|
103
|
+
| 'no-git'
|
|
104
|
+
| 'unknown-version'
|
|
105
|
+
| 'migration-gap'
|
|
106
|
+
| 'unknown-key'
|
|
107
|
+
| 'no-changelog';
|
|
86
108
|
|
|
87
109
|
/** A user-facing failure with a stable code and no stack in `--json` output. */
|
|
88
110
|
export class ContentCliError extends Error {
|
|
@@ -105,6 +127,8 @@ export interface CliDiagnostic {
|
|
|
105
127
|
* which `tests/rules/catalog.test.ts` treats as a bug.
|
|
106
128
|
*/
|
|
107
129
|
readonly rule?: string;
|
|
130
|
+
/** The owner's page and anchor under `docs/authoring/`, `notation.md#n4`. */
|
|
131
|
+
readonly doc?: string;
|
|
108
132
|
readonly severity: DiagnosticSeverity;
|
|
109
133
|
readonly message: string;
|
|
110
134
|
readonly file?: string;
|
|
@@ -127,13 +151,33 @@ function diagnosticSeverity(value: unknown): DiagnosticSeverity {
|
|
|
127
151
|
* `{ rule }` for a `<source>:<code>` (or bare) code the catalog owns — or for a
|
|
128
152
|
* course rule, whose code is its own ID (`X1`).
|
|
129
153
|
*/
|
|
130
|
-
function ownerFields(code: string): { readonly rule?: string } {
|
|
154
|
+
function ownerFields(code: string): { readonly rule?: string; readonly doc?: string } {
|
|
131
155
|
const bare = code.slice(code.indexOf(':') + 1);
|
|
132
156
|
const owner = ownerOf(bare);
|
|
133
|
-
if (owner !== undefined) return { rule: owner.id };
|
|
157
|
+
if (owner !== undefined) return { rule: owner.id, doc: owner.doc };
|
|
134
158
|
return isCourseRuleId(bare) ? { rule: bare } : {};
|
|
135
159
|
}
|
|
136
160
|
|
|
161
|
+
/** ` [N4 · notation.md#n4]` for a diagnostic the catalog owns; empty otherwise. */
|
|
162
|
+
export function ownerTag(diagnostic: Pick<CliDiagnostic, 'rule' | 'doc'>): string {
|
|
163
|
+
if (!diagnostic.rule) return '';
|
|
164
|
+
return ` [${diagnostic.rule}${diagnostic.doc ? ` · ${diagnostic.doc}` : ''}]`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One text line for a diagnostic, the shape every verb prints:
|
|
169
|
+
* `file:line:column: severity code [rule · doc]: message`. The location is
|
|
170
|
+
* left out when the diagnostic has no file, and reads `1:1` when the file is
|
|
171
|
+
* known but the position is not, so an editor opens the file. The rule and its page are on the
|
|
172
|
+
* line so a reader needs no second lookup to find what the code answers to.
|
|
173
|
+
*/
|
|
174
|
+
export function formatCliDiagnostic(diagnostic: CliDiagnostic): string {
|
|
175
|
+
const location = diagnostic.file
|
|
176
|
+
? `${diagnostic.file}:${diagnostic.line ?? 1}:${diagnostic.column ?? 1}: `
|
|
177
|
+
: '';
|
|
178
|
+
return `${location}${diagnostic.severity} ${diagnostic.code}${ownerTag(diagnostic)}: ${diagnostic.message}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
137
181
|
function withOwner(diagnostic: CliDiagnostic): CliDiagnostic {
|
|
138
182
|
return { ...diagnostic, ...ownerFields(diagnostic.code) };
|
|
139
183
|
}
|
|
@@ -165,7 +209,7 @@ function mentions(message: string, lessonId: string, file: string): boolean {
|
|
|
165
209
|
*/
|
|
166
210
|
export function lessonDiagnostics(
|
|
167
211
|
manifest: Manifest,
|
|
168
|
-
lesson:
|
|
212
|
+
lesson: CheckTarget,
|
|
169
213
|
rendered: readonly RenderDiagnostic[] = readRenderDiagnostics(),
|
|
170
214
|
): CliDiagnostic[] {
|
|
171
215
|
const out: CliDiagnostic[] = [];
|
|
@@ -523,7 +567,7 @@ export function findLesson(manifest: Manifest, rawId: string): ManifestLesson {
|
|
|
523
567
|
return lesson;
|
|
524
568
|
}
|
|
525
569
|
|
|
526
|
-
function lessonAbsolutePath(lesson: ManifestLesson): string {
|
|
570
|
+
function lessonAbsolutePath(lesson: Pick<ManifestLesson, 'file'>): string {
|
|
527
571
|
return join(REPOSITORY_ROOT, lesson.file);
|
|
528
572
|
}
|
|
529
573
|
|
|
@@ -628,6 +672,31 @@ function domainSignaturesForSection(section: string): DomainFileSignatures[] {
|
|
|
628
672
|
export interface StatusReport {
|
|
629
673
|
/** The course's published knowledge version (`about.version`). */
|
|
630
674
|
readonly knowledgeVersion: string;
|
|
675
|
+
/**
|
|
676
|
+
* What an agent is told to read before working, in estimated tokens: the
|
|
677
|
+
* course's `AGENTS.md`, the repository's one level up when the course is a
|
|
678
|
+
* workspace package, and the rules-in-effect page. A budget to watch; the
|
|
679
|
+
* estimate is bytes over four.
|
|
680
|
+
*/
|
|
681
|
+
readonly agentSurfaces: readonly { readonly name: string; readonly tokens: number }[];
|
|
682
|
+
/**
|
|
683
|
+
* Whether the course's own `AGENTS.md` points an agent at the engine's
|
|
684
|
+
* guide (`docs/authoring/agents.md`), and whether the procedures
|
|
685
|
+
* `content init-agent` installs are in `.claude/`.
|
|
686
|
+
*/
|
|
687
|
+
readonly agentGuide: {
|
|
688
|
+
readonly agentsFile: string | null;
|
|
689
|
+
readonly linksGuide: boolean;
|
|
690
|
+
readonly skillsInstalled: boolean;
|
|
691
|
+
};
|
|
692
|
+
/**
|
|
693
|
+
* Rule C5: how many chapters and part pages carry a version of their own,
|
|
694
|
+
* and the slugs of those with a change `## [Unreleased]` has not released.
|
|
695
|
+
*/
|
|
696
|
+
readonly pageVersions: {
|
|
697
|
+
readonly tracked: number;
|
|
698
|
+
readonly unreleased: readonly string[];
|
|
699
|
+
};
|
|
631
700
|
readonly counts: {
|
|
632
701
|
readonly lessons: number;
|
|
633
702
|
readonly competencies: number;
|
|
@@ -649,6 +718,40 @@ export interface StatusReport {
|
|
|
649
718
|
};
|
|
650
719
|
}
|
|
651
720
|
|
|
721
|
+
/** The engine guide's file name, which a course's `AGENTS.md` names to point at it. */
|
|
722
|
+
const AGENT_GUIDE_MENTION = /docs\/authoring\/agents\.md/;
|
|
723
|
+
|
|
724
|
+
function agentGuideState(): StatusReport['agentGuide'] {
|
|
725
|
+
const agentsFile = ['AGENTS.md', 'CLAUDE.md'].find((name) => existsSync(join(REPOSITORY_ROOT, name))) ?? null;
|
|
726
|
+
const linksGuide =
|
|
727
|
+
agentsFile !== null && AGENT_GUIDE_MENTION.test(readFileSync(join(REPOSITORY_ROOT, agentsFile), 'utf8'));
|
|
728
|
+
const skillsInstalled = existsSync(join(REPOSITORY_ROOT, '.claude', 'skills', 'draft-lesson', 'SKILL.md'));
|
|
729
|
+
return { agentsFile, linksGuide, skillsInstalled };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function agentSurfaces(manifest: Manifest): { readonly name: string; readonly tokens: number }[] {
|
|
733
|
+
const surfaces: { name: string; tokens: number }[] = [];
|
|
734
|
+
for (const rel of ['AGENTS.md', join('..', 'AGENTS.md')]) {
|
|
735
|
+
const path = join(REPOSITORY_ROOT, rel);
|
|
736
|
+
if (!existsSync(path)) continue;
|
|
737
|
+
surfaces.push({ name: rel, tokens: estimateTokens(readFileSync(path, 'utf8')) });
|
|
738
|
+
}
|
|
739
|
+
surfaces.push({
|
|
740
|
+
name: 'rules in effect',
|
|
741
|
+
tokens: estimateTokens(renderRulesInEffect(manifest)),
|
|
742
|
+
});
|
|
743
|
+
// The migration an upgrade would read, when the pin and the install differ.
|
|
744
|
+
try {
|
|
745
|
+
const migration = contentMigrate();
|
|
746
|
+
if (migration.sections.length > 0) {
|
|
747
|
+
surfaces.push({ name: `migration ${migration.from} → ${migration.to}`, tokens: migration.tokens });
|
|
748
|
+
}
|
|
749
|
+
} catch {
|
|
750
|
+
// no pin, no install, or no guide: nothing to size
|
|
751
|
+
}
|
|
752
|
+
return surfaces;
|
|
753
|
+
}
|
|
754
|
+
|
|
652
755
|
export function contentStatus(
|
|
653
756
|
manifest: Manifest,
|
|
654
757
|
rendered: readonly RenderDiagnostic[] = readRenderDiagnostics(),
|
|
@@ -710,8 +813,24 @@ export function contentStatus(
|
|
|
710
813
|
manifest.lessons.flatMap((lesson) => lesson.assessments),
|
|
711
814
|
).size;
|
|
712
815
|
|
|
816
|
+
const trackedPages = [
|
|
817
|
+
...manifest.lessons.map((lesson) => ({ slug: lesson.slug, since: lesson.since })),
|
|
818
|
+
...manifest.parts.flatMap((part) =>
|
|
819
|
+
part.overview ? [{ slug: `${part.dir}/index`, since: part.overview.since }] : [],
|
|
820
|
+
),
|
|
821
|
+
];
|
|
822
|
+
|
|
713
823
|
return {
|
|
714
824
|
knowledgeVersion: manifest.about.knowledge.version,
|
|
825
|
+
agentSurfaces: agentSurfaces(manifest),
|
|
826
|
+
agentGuide: agentGuideState(),
|
|
827
|
+
pageVersions: {
|
|
828
|
+
tracked: manifest.pageVersions.enabled ? trackedPages.length : 0,
|
|
829
|
+
unreleased: trackedPages
|
|
830
|
+
.filter((page) => page.since === null)
|
|
831
|
+
.map((page) => page.slug)
|
|
832
|
+
.sort(),
|
|
833
|
+
},
|
|
715
834
|
counts: {
|
|
716
835
|
lessons: manifest.lessons.length,
|
|
717
836
|
competencies: manifest.competencies.length,
|
|
@@ -745,6 +864,125 @@ export interface DiagnosticsReport {
|
|
|
745
864
|
readonly diagnostics: readonly CliDiagnostic[];
|
|
746
865
|
}
|
|
747
866
|
|
|
867
|
+
/**
|
|
868
|
+
* The diagnostics list folded to what an agent decides from: how many, of
|
|
869
|
+
* what, and where. The default text output, since a course can have hundreds
|
|
870
|
+
* of lines of two codes and the list is what `--all` is for.
|
|
871
|
+
*/
|
|
872
|
+
export interface DiagnosticsSummary {
|
|
873
|
+
readonly minimumSeverity: DiagnosticThreshold;
|
|
874
|
+
readonly count: number;
|
|
875
|
+
readonly bySeverity: { readonly errors: number; readonly warnings: number; readonly infos: number };
|
|
876
|
+
/** One row per code and severity, most frequent first. */
|
|
877
|
+
readonly byCode: readonly {
|
|
878
|
+
readonly code: string;
|
|
879
|
+
readonly rule?: string;
|
|
880
|
+
readonly doc?: string;
|
|
881
|
+
readonly severity: DiagnosticSeverity;
|
|
882
|
+
readonly count: number;
|
|
883
|
+
}[];
|
|
884
|
+
/** One row per file, most diagnostics first; `(no file)` for the rest. */
|
|
885
|
+
readonly byFile: readonly {
|
|
886
|
+
readonly file: string;
|
|
887
|
+
readonly count: number;
|
|
888
|
+
readonly errors: number;
|
|
889
|
+
readonly warnings: number;
|
|
890
|
+
readonly infos: number;
|
|
891
|
+
}[];
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function severityCounts(diagnostics: readonly CliDiagnostic[]) {
|
|
895
|
+
const counts = { errors: 0, warnings: 0, infos: 0 };
|
|
896
|
+
for (const diagnostic of diagnostics) {
|
|
897
|
+
if (diagnostic.severity === 'error') counts.errors += 1;
|
|
898
|
+
else if (diagnostic.severity === 'warning') counts.warnings += 1;
|
|
899
|
+
else counts.infos += 1;
|
|
900
|
+
}
|
|
901
|
+
return counts;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export function summarizeDiagnostics(report: DiagnosticsReport): DiagnosticsSummary {
|
|
905
|
+
const byCode = new Map<string, DiagnosticsSummary['byCode'][number]>();
|
|
906
|
+
const byFile = new Map<string, CliDiagnostic[]>();
|
|
907
|
+
for (const diagnostic of report.diagnostics) {
|
|
908
|
+
const codeKey = `${diagnostic.code}\u0000${diagnostic.severity}`;
|
|
909
|
+
const row = byCode.get(codeKey);
|
|
910
|
+
if (row) byCode.set(codeKey, { ...row, count: row.count + 1 });
|
|
911
|
+
else {
|
|
912
|
+
byCode.set(codeKey, {
|
|
913
|
+
code: diagnostic.code,
|
|
914
|
+
...(diagnostic.rule === undefined ? {} : { rule: diagnostic.rule }),
|
|
915
|
+
...(diagnostic.doc === undefined ? {} : { doc: diagnostic.doc }),
|
|
916
|
+
severity: diagnostic.severity,
|
|
917
|
+
count: 1,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
const file = diagnostic.file ?? '(no file)';
|
|
921
|
+
byFile.set(file, [...(byFile.get(file) ?? []), diagnostic]);
|
|
922
|
+
}
|
|
923
|
+
const byCount = <T extends { count: number }>(key: (row: T) => string) => (a: T, b: T) =>
|
|
924
|
+
b.count - a.count || key(a).localeCompare(key(b));
|
|
925
|
+
return {
|
|
926
|
+
minimumSeverity: report.minimumSeverity,
|
|
927
|
+
count: report.count,
|
|
928
|
+
bySeverity: severityCounts(report.diagnostics),
|
|
929
|
+
byCode: [...byCode.values()].sort(byCount((row) => row.code)),
|
|
930
|
+
byFile: [...byFile]
|
|
931
|
+
.map(([file, list]) => ({ file, count: list.length, ...severityCounts(list) }))
|
|
932
|
+
.sort(byCount((row) => row.file)),
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** The diagnostics of one code (with or without its `source:` prefix) or of some files. */
|
|
937
|
+
export function filterDiagnostics(
|
|
938
|
+
report: DiagnosticsReport,
|
|
939
|
+
filter: { readonly code?: string; readonly files?: readonly string[] },
|
|
940
|
+
): DiagnosticsReport {
|
|
941
|
+
const files = filter.files === undefined ? undefined : new Set(filter.files);
|
|
942
|
+
const diagnostics = report.diagnostics.filter((diagnostic) => {
|
|
943
|
+
if (
|
|
944
|
+
filter.code !== undefined &&
|
|
945
|
+
diagnostic.code !== filter.code &&
|
|
946
|
+
diagnostic.code.slice(diagnostic.code.indexOf(':') + 1) !== filter.code
|
|
947
|
+
) {
|
|
948
|
+
return false;
|
|
949
|
+
}
|
|
950
|
+
return files === undefined || (diagnostic.file !== undefined && files.has(diagnostic.file));
|
|
951
|
+
});
|
|
952
|
+
return { minimumSeverity: report.minimumSeverity, count: diagnostics.length, diagnostics };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** The summary as text: a severity line, then counts by code and by file, the file list capped. */
|
|
956
|
+
export function formatDiagnosticsSummary(
|
|
957
|
+
summary: DiagnosticsSummary,
|
|
958
|
+
options: { readonly fileCap?: number } = {},
|
|
959
|
+
): string {
|
|
960
|
+
const fileCap = options.fileCap ?? 25;
|
|
961
|
+
const s = summary.bySeverity;
|
|
962
|
+
const lines = [
|
|
963
|
+
`${summary.count} diagnostic(s) at ${summary.minimumSeverity} or higher: ${s.errors} error(s), ${s.warnings} warning(s), ${s.infos} info`,
|
|
964
|
+
];
|
|
965
|
+
if (summary.count === 0) return `${lines[0]}\n`;
|
|
966
|
+
lines.push('by code');
|
|
967
|
+
for (const row of summary.byCode) {
|
|
968
|
+
lines.push(` ${String(row.count).padStart(5)} ${row.severity.padEnd(7)} ${row.code}${ownerTag(row)}`);
|
|
969
|
+
}
|
|
970
|
+
lines.push('by file');
|
|
971
|
+
for (const row of summary.byFile.slice(0, fileCap)) {
|
|
972
|
+
const parts = [
|
|
973
|
+
row.errors > 0 ? `${row.errors} error(s)` : '',
|
|
974
|
+
row.warnings > 0 ? `${row.warnings} warning(s)` : '',
|
|
975
|
+
row.infos > 0 ? `${row.infos} info` : '',
|
|
976
|
+
].filter((part) => part !== '');
|
|
977
|
+
lines.push(` ${String(row.count).padStart(5)} ${row.file} (${parts.join(', ')})`);
|
|
978
|
+
}
|
|
979
|
+
if (summary.byFile.length > fileCap) {
|
|
980
|
+
lines.push(` … and ${summary.byFile.length - fileCap} more file(s)`);
|
|
981
|
+
}
|
|
982
|
+
lines.push('List them: --all for every line; --code <code>, --lesson <id> or --changed for a part.');
|
|
983
|
+
return `${lines.join('\n')}\n`;
|
|
984
|
+
}
|
|
985
|
+
|
|
748
986
|
export interface WarningsReport {
|
|
749
987
|
readonly count: number;
|
|
750
988
|
readonly warnings: readonly CliDiagnostic[];
|
|
@@ -899,8 +1137,20 @@ export interface RulesReport {
|
|
|
899
1137
|
export function parseRulesFilter(input: {
|
|
900
1138
|
readonly element?: string;
|
|
901
1139
|
readonly enforcement?: string;
|
|
1140
|
+
/** `chapter` (the default page) or `deck` (`<lesson>.deck.mdx`). */
|
|
1141
|
+
readonly edition?: string;
|
|
902
1142
|
}): RulesFilter {
|
|
903
|
-
const filter: {
|
|
1143
|
+
const filter: {
|
|
1144
|
+
elements?: Element[];
|
|
1145
|
+
enforcement?: Enforcement | 'locked';
|
|
1146
|
+
edition?: 'chapter' | 'deck';
|
|
1147
|
+
} = {};
|
|
1148
|
+
if (input.edition !== undefined) {
|
|
1149
|
+
if (input.edition !== 'chapter' && input.edition !== 'deck') {
|
|
1150
|
+
throw new ContentCliError('usage', `--edition ${input.edition}: one of chapter, deck.`);
|
|
1151
|
+
}
|
|
1152
|
+
filter.edition = input.edition;
|
|
1153
|
+
}
|
|
904
1154
|
if (input.element !== undefined) {
|
|
905
1155
|
const elements = input.element.split(',').map((e) => e.trim()).filter(Boolean);
|
|
906
1156
|
for (const element of elements) {
|
|
@@ -949,6 +1199,8 @@ export interface ContextReport {
|
|
|
949
1199
|
readonly teaches: readonly string[];
|
|
950
1200
|
readonly requires: readonly string[];
|
|
951
1201
|
readonly assumptions: readonly string[];
|
|
1202
|
+
/** The version the page's content last changed in; `null` for a change not yet released (rule C5). */
|
|
1203
|
+
readonly since: string | null;
|
|
952
1204
|
readonly notationLocal: unknown;
|
|
953
1205
|
readonly body: string;
|
|
954
1206
|
};
|
|
@@ -962,10 +1214,10 @@ export interface ContextReport {
|
|
|
962
1214
|
units?: string;
|
|
963
1215
|
formula?: string;
|
|
964
1216
|
}[];
|
|
965
|
-
readonly equations: readonly
|
|
966
|
-
readonly tables: readonly
|
|
967
|
-
readonly figures: readonly
|
|
968
|
-
readonly diagrams: readonly
|
|
1217
|
+
readonly equations: readonly ManifestLessonFloat[];
|
|
1218
|
+
readonly tables: readonly ManifestLessonFloat[];
|
|
1219
|
+
readonly figures: readonly ManifestLessonFloat[];
|
|
1220
|
+
readonly diagrams: readonly ManifestLessonFloat[];
|
|
969
1221
|
/** Worked-example sets: `short` is the title `[[ex-key]]` names. */
|
|
970
1222
|
readonly examples: readonly { key: string; number: string; short?: string }[];
|
|
971
1223
|
readonly knowledgeChecks: readonly { key: string; number: string; title?: string }[];
|
|
@@ -988,179 +1240,1602 @@ export interface ContextReport {
|
|
|
988
1240
|
readonly tokens: TokenCounts;
|
|
989
1241
|
}
|
|
990
1242
|
|
|
991
|
-
|
|
992
|
-
manifest: Manifest,
|
|
993
|
-
rawId: string,
|
|
994
|
-
): ContextReport {
|
|
995
|
-
const lesson = findLesson(manifest, rawId);
|
|
996
|
-
const { frontmatter, body } = lessonRaw(lesson);
|
|
997
|
-
|
|
998
|
-
const outcomeById = new Map(
|
|
999
|
-
manifest.competencies.map((competency) => [
|
|
1000
|
-
String(competency.id),
|
|
1001
|
-
String(competency.outcome ?? ''),
|
|
1002
|
-
]),
|
|
1003
|
-
);
|
|
1004
|
-
const outcomesFor = (ids: readonly string[]) =>
|
|
1005
|
-
ids
|
|
1006
|
-
.filter((id) => outcomeById.has(id))
|
|
1007
|
-
.map((id) => ({ id, outcome: outcomeById.get(id) ?? '' }));
|
|
1243
|
+
// --- content migrate -------------------------------------------------
|
|
1008
1244
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1245
|
+
/*
|
|
1246
|
+
* The migration guide, cut to the sections an upgrade needs: from the
|
|
1247
|
+
* version a course pins to the version it has installed, oldest first. The
|
|
1248
|
+
* guide is one newest-first file of every release; an upgrade reads one or
|
|
1249
|
+
* two sections of it, and the heading grammar `## <from> → <to>` (a `0.4.x`
|
|
1250
|
+
* range on the left, `Unreleased` allowed on the right) says which.
|
|
1251
|
+
*/
|
|
1252
|
+
export interface MigrationSection {
|
|
1253
|
+
readonly from: string;
|
|
1254
|
+
readonly to: string;
|
|
1255
|
+
readonly heading: string;
|
|
1256
|
+
readonly markdown: string;
|
|
1257
|
+
readonly tokens: number;
|
|
1258
|
+
}
|
|
1018
1259
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
teaches: lesson.teaches,
|
|
1027
|
-
requires: lesson.requires,
|
|
1028
|
-
assumptions: lesson.assumptions,
|
|
1029
|
-
notationLocal:
|
|
1030
|
-
(frontmatter.notation as { local?: unknown } | undefined)?.local ?? [],
|
|
1031
|
-
body,
|
|
1032
|
-
},
|
|
1033
|
-
taughtOutcomes: outcomesFor(lesson.teaches),
|
|
1034
|
-
prerequisiteOutcomes: outcomesFor(lesson.requires),
|
|
1035
|
-
notation: lesson.notation.definitions.map((definition) => ({
|
|
1036
|
-
key: definition.key,
|
|
1037
|
-
latex: definition.notation,
|
|
1038
|
-
meaning: definition.summary,
|
|
1039
|
-
scope: definition.scope,
|
|
1040
|
-
...(definition.units === undefined ? {} : { units: definition.units }),
|
|
1041
|
-
...(definition.formula === undefined
|
|
1042
|
-
? {}
|
|
1043
|
-
: { formula: definition.formula }),
|
|
1044
|
-
})),
|
|
1045
|
-
equations: lesson.equations.map((equation) => ({ ...equation })),
|
|
1046
|
-
tables: lesson.tables.map((table) => ({ ...table })),
|
|
1047
|
-
figures: lesson.figures.map((figure) => ({ ...figure })),
|
|
1048
|
-
diagrams: lesson.diagrams.map((diagram) => ({ ...diagram })),
|
|
1049
|
-
examples: lesson.examples.map((example) => ({ ...example })),
|
|
1050
|
-
knowledgeChecks: lesson.knowledgeChecks.map((check) => ({ ...check })),
|
|
1051
|
-
sections: lesson.headings.map((heading) => ({ ...heading })),
|
|
1052
|
-
sources: lesson.sources.map((id) => {
|
|
1053
|
-
const record = sourceById.get(id) as Record<string, unknown> | undefined;
|
|
1054
|
-
return {
|
|
1055
|
-
id,
|
|
1056
|
-
title: String(record?.title ?? id),
|
|
1057
|
-
type: String(record?.type ?? 'unknown'),
|
|
1058
|
-
...(typeof record?.locator === 'string'
|
|
1059
|
-
? { recordLocator: record.locator }
|
|
1060
|
-
: {}),
|
|
1061
|
-
citedLocators: citedLocatorsById.get(id) ?? [],
|
|
1062
|
-
};
|
|
1063
|
-
}),
|
|
1064
|
-
domainSignatures: domainSignaturesForSection(
|
|
1065
|
-
lesson.slug.split('/')[0] ?? '',
|
|
1066
|
-
),
|
|
1067
|
-
diagnostics: lessonDiagnostics(manifest, lesson),
|
|
1068
|
-
};
|
|
1260
|
+
export interface MigrateReport {
|
|
1261
|
+
readonly from: string;
|
|
1262
|
+
readonly to: string;
|
|
1263
|
+
readonly file: string;
|
|
1264
|
+
readonly sections: readonly MigrationSection[];
|
|
1265
|
+
readonly tokens: number;
|
|
1266
|
+
}
|
|
1069
1267
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1268
|
+
const MIGRATION_HEADING = /^## ([^\n→]+?) → ([^\n]+?)[\t ]*$/;
|
|
1269
|
+
|
|
1270
|
+
/** Every `## <from> → <to>` section of the guide, in file order (newest first). */
|
|
1271
|
+
export function parseMigrationSections(markdown: string): MigrationSection[] {
|
|
1272
|
+
const lines = markdown.split('\n');
|
|
1273
|
+
const starts = lines
|
|
1274
|
+
.map((line, index) => ({ index, match: MIGRATION_HEADING.exec(line) }))
|
|
1275
|
+
.filter((entry): entry is { index: number; match: RegExpExecArray } => entry.match !== null);
|
|
1276
|
+
return starts.map(({ index, match }, position) => {
|
|
1277
|
+
// A section runs to the next `## ` heading of any kind, so the trailing
|
|
1278
|
+
// changelog-style block ends the last one.
|
|
1279
|
+
let end = lines.findIndex((line, i) => i > index && line.startsWith('## '));
|
|
1280
|
+
if (end < 0) end = lines.length;
|
|
1281
|
+
const body = lines.slice(index, end);
|
|
1282
|
+
while (body.length > 0 && /^(---)?\s*$/.test(body[body.length - 1]!)) body.pop();
|
|
1283
|
+
const text = body.join('\n');
|
|
1284
|
+
return {
|
|
1285
|
+
from: match[1]!.trim(),
|
|
1286
|
+
to: match[2]!.trim(),
|
|
1287
|
+
heading: lines[index]!,
|
|
1288
|
+
markdown: text,
|
|
1289
|
+
tokens: estimateTokens(text),
|
|
1290
|
+
...(position === starts.length ? {} : {}),
|
|
1291
|
+
};
|
|
1292
|
+
});
|
|
1074
1293
|
}
|
|
1075
1294
|
|
|
1076
|
-
|
|
1295
|
+
function versionParts(version: string): number[] | undefined {
|
|
1296
|
+
if (version === 'Unreleased') return undefined;
|
|
1297
|
+
const parts = version.split('.').map((part) => (part === 'x' ? -1 : Number(part)));
|
|
1298
|
+
return parts.every((part) => Number.isInteger(part)) ? parts : undefined;
|
|
1299
|
+
}
|
|
1077
1300
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1301
|
+
/** `0.4.x` matches any `0.4.*`; otherwise the versions are equal. */
|
|
1302
|
+
function versionMatches(pattern: string, version: string): boolean {
|
|
1303
|
+
if (pattern === version) return true;
|
|
1304
|
+
const p = versionParts(pattern);
|
|
1305
|
+
const v = versionParts(version);
|
|
1306
|
+
if (!p || !v) return false;
|
|
1307
|
+
return p.every((part, index) => part === -1 || part === v[index]);
|
|
1084
1308
|
}
|
|
1085
1309
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1310
|
+
function compareVersions(a: string, b: string): number {
|
|
1311
|
+
if (a === b) return 0;
|
|
1312
|
+
if (a === 'Unreleased') return 1;
|
|
1313
|
+
if (b === 'Unreleased') return -1;
|
|
1314
|
+
const pa = versionParts(a) ?? [];
|
|
1315
|
+
const pb = versionParts(b) ?? [];
|
|
1316
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
1317
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1318
|
+
if (d !== 0) return d;
|
|
1319
|
+
}
|
|
1320
|
+
return 0;
|
|
1089
1321
|
}
|
|
1090
1322
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1323
|
+
/**
|
|
1324
|
+
* The sections that take a course from `from` to `to`, oldest first: each
|
|
1325
|
+
* section's left side matches where the previous one ended. A hole in the
|
|
1326
|
+
* chain is an error naming the version no section continues from, since a
|
|
1327
|
+
* skipped section is a skipped migration.
|
|
1328
|
+
*/
|
|
1329
|
+
export function selectMigrationPath(
|
|
1330
|
+
sections: readonly MigrationSection[],
|
|
1331
|
+
from: string,
|
|
1332
|
+
to: string,
|
|
1333
|
+
): MigrationSection[] {
|
|
1334
|
+
if (compareVersions(from, to) > 0) {
|
|
1335
|
+
throw new ContentCliError(
|
|
1336
|
+
'unknown-version',
|
|
1337
|
+
`--from ${from} is newer than --to ${to}; the guide only goes forward.`,
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
const path: MigrationSection[] = [];
|
|
1341
|
+
let cursor = from;
|
|
1342
|
+
while (compareVersions(cursor, to) < 0) {
|
|
1343
|
+
const next = sections
|
|
1344
|
+
.filter((section) => versionMatches(section.from, cursor) && compareVersions(section.to, to) <= 0)
|
|
1345
|
+
.sort((a, b) => compareVersions(a.to, b.to))[0];
|
|
1346
|
+
if (!next) {
|
|
1347
|
+
throw new ContentCliError(
|
|
1348
|
+
'migration-gap',
|
|
1349
|
+
`No section continues from ${cursor} towards ${to}. Sections known: ${sections
|
|
1350
|
+
.map((section) => `${section.from} → ${section.to}`)
|
|
1351
|
+
.join(', ')}.`,
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
path.push(next);
|
|
1355
|
+
cursor = next.to;
|
|
1356
|
+
}
|
|
1357
|
+
return path;
|
|
1358
|
+
}
|
|
1104
1359
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1360
|
+
/** The engine version the course has installed: the package beside it, or this one. */
|
|
1361
|
+
export function installedEngineVersion(): string | undefined {
|
|
1362
|
+
for (const path of [
|
|
1363
|
+
join(REPOSITORY_ROOT, 'node_modules', 'explico', 'package.json'),
|
|
1364
|
+
join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'),
|
|
1365
|
+
join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
|
|
1366
|
+
]) {
|
|
1367
|
+
if (!existsSync(path)) continue;
|
|
1368
|
+
try {
|
|
1369
|
+
const pkg = JSON.parse(readFileSync(path, 'utf8')) as { name?: string; version?: string };
|
|
1370
|
+
if (pkg.name === 'explico' && typeof pkg.version === 'string') return pkg.version;
|
|
1371
|
+
} catch {
|
|
1372
|
+
// not this one
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return undefined;
|
|
1116
1376
|
}
|
|
1117
1377
|
|
|
1118
|
-
/**
|
|
1119
|
-
|
|
1120
|
-
|
|
1378
|
+
/**
|
|
1379
|
+
* The engine version `package.json` pins at HEAD — what the course last
|
|
1380
|
+
* migrated to, before the working tree bumped it. `workspace:*` or a range
|
|
1381
|
+
* is no version, so the caller asks for `--from`.
|
|
1382
|
+
*/
|
|
1383
|
+
export function pinnedEngineVersion(): string | undefined {
|
|
1384
|
+
let text: string;
|
|
1121
1385
|
try {
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
);
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
)
|
|
1132
|
-
|
|
1386
|
+
text = execFileSync('git', ['show', 'HEAD:package.json'], {
|
|
1387
|
+
cwd: REPOSITORY_ROOT,
|
|
1388
|
+
encoding: 'utf8',
|
|
1389
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1390
|
+
});
|
|
1391
|
+
} catch {
|
|
1392
|
+
return undefined;
|
|
1393
|
+
}
|
|
1394
|
+
try {
|
|
1395
|
+
const pkg = JSON.parse(text) as Record<string, Record<string, string> | undefined>;
|
|
1396
|
+
const pinned = pkg.dependencies?.explico ?? pkg.devDependencies?.explico;
|
|
1397
|
+
if (typeof pinned !== 'string') return undefined;
|
|
1398
|
+
const exact = pinned.replace(/^[\^~=v]/, '');
|
|
1399
|
+
return versionParts(exact) ? exact : undefined;
|
|
1133
1400
|
} catch {
|
|
1401
|
+
return undefined;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
export function contentMigrate(options: { readonly from?: string; readonly to?: string } = {}): MigrateReport {
|
|
1406
|
+
const dir = authoringDocsDir();
|
|
1407
|
+
const file = dir === undefined ? undefined : join(dir, 'migrating.md');
|
|
1408
|
+
if (file === undefined || !existsSync(file)) {
|
|
1409
|
+
throw new ContentCliError('unknown-version', 'No migrating.md beside the engine (node_modules/explico/docs/authoring).');
|
|
1410
|
+
}
|
|
1411
|
+
const to = options.to ?? installedEngineVersion();
|
|
1412
|
+
if (to === undefined) {
|
|
1413
|
+
throw new ContentCliError('unknown-version', 'Could not read the installed engine version; pass --to <version>.');
|
|
1414
|
+
}
|
|
1415
|
+
const from = options.from ?? pinnedEngineVersion();
|
|
1416
|
+
if (from === undefined) {
|
|
1134
1417
|
throw new ContentCliError(
|
|
1135
|
-
'
|
|
1136
|
-
'
|
|
1418
|
+
'unknown-version',
|
|
1419
|
+
'package.json at HEAD pins no exact explico version (a workspace link or a range); pass --from <version>.',
|
|
1137
1420
|
);
|
|
1138
1421
|
}
|
|
1422
|
+
const sections = selectMigrationPath(parseMigrationSections(readFileSync(file, 'utf8')), from, to);
|
|
1423
|
+
return {
|
|
1424
|
+
from,
|
|
1425
|
+
to,
|
|
1426
|
+
file: join('docs', 'authoring', 'migrating.md'),
|
|
1427
|
+
sections,
|
|
1428
|
+
tokens: sections.reduce((n, section) => n + section.tokens, 0),
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1139
1431
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
const slug = path
|
|
1145
|
-
.replace(/^content\/docs\//, '')
|
|
1146
|
-
.replace(/\.checks\.yml$/, '.mdx')
|
|
1147
|
-
.replace(/\.mdx?$/, '');
|
|
1148
|
-
if (!isLessonSlug(slug)) continue;
|
|
1149
|
-
ids.add(slug.replace(/\//g, '.'));
|
|
1432
|
+
/** The sections, oldest first, under one line that says how much is coming. */
|
|
1433
|
+
export function formatMigrateReport(report: MigrateReport): string {
|
|
1434
|
+
if (report.sections.length === 0) {
|
|
1435
|
+
return `Already at ${report.to}: nothing to migrate from ${report.from}.\n`;
|
|
1150
1436
|
}
|
|
1151
|
-
|
|
1437
|
+
const head =
|
|
1438
|
+
`Migrating ${report.from} → ${report.to}: ${report.sections.length} section(s) of ${report.file}, ` +
|
|
1439
|
+
`≈${report.tokens} tokens, oldest first. Apply every one; each ends with the checks to run.\n`;
|
|
1440
|
+
return `${head}\n${report.sections.map((section) => section.markdown).join('\n\n---\n\n')}\n`;
|
|
1152
1441
|
}
|
|
1153
1442
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1443
|
+
// --- content bindings ------------------------------------------------
|
|
1444
|
+
|
|
1445
|
+
/*
|
|
1446
|
+
* What a lesson's math binds, span by span, before a build says so: each
|
|
1447
|
+
* glyph and the card it resolves to, how (`canonical`, `family`, `derived`,
|
|
1448
|
+
* `base`, `operator`, or an explicit `\explain` wrap), the wraps the bare
|
|
1449
|
+
* symbol would have resolved without, and every unresolved glyph with where
|
|
1450
|
+
* `content symbol` finds it defined. The same resolver and scope the
|
|
1451
|
+
* completeness gate uses, so what it prints is what the build will do.
|
|
1452
|
+
*/
|
|
1453
|
+
export interface SpanBinding {
|
|
1454
|
+
readonly token: string;
|
|
1455
|
+
readonly key: string;
|
|
1456
|
+
/** The resolver's match kind; `explicit` for an `\explain` wrap. */
|
|
1457
|
+
readonly how: string;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
export interface ExplicitWrap {
|
|
1461
|
+
readonly key: string;
|
|
1462
|
+
readonly inner: string;
|
|
1463
|
+
/** The bare `inner` resolves to exactly this card: the wrap costs tokens and binds nothing new. */
|
|
1464
|
+
readonly redundant: boolean;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
export interface UnresolvedGlyph {
|
|
1468
|
+
readonly token: string;
|
|
1469
|
+
readonly suspended?: string;
|
|
1470
|
+
/** Rule N23: indexed entries whose base this bare letter is; none lends it a card. */
|
|
1471
|
+
readonly lenders?: readonly string[];
|
|
1472
|
+
/** Where `content symbol` finds the token defined: exact hits, then near misses. */
|
|
1473
|
+
readonly defined: readonly { readonly tier: SymbolTier; readonly key: string; readonly where: string }[];
|
|
1474
|
+
readonly near: readonly { readonly latex: string; readonly key: string }[];
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
export interface SpanBindings {
|
|
1478
|
+
readonly bindings: readonly SpanBinding[];
|
|
1479
|
+
readonly wraps: readonly ExplicitWrap[];
|
|
1480
|
+
readonly unresolved: readonly Omit<UnresolvedGlyph, 'defined' | 'near'>[];
|
|
1481
|
+
/** The resolver rejected the span (a KaTeX parse error or a rule it enforces, N13 say); its message. */
|
|
1482
|
+
readonly error?: string;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
export interface BindingsSpan extends Omit<SpanBindings, 'unresolved'> {
|
|
1486
|
+
readonly line: number;
|
|
1487
|
+
readonly column: number;
|
|
1488
|
+
readonly display: boolean;
|
|
1489
|
+
readonly latex: string;
|
|
1490
|
+
readonly unresolved: readonly UnresolvedGlyph[];
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
export interface BindingsReport {
|
|
1494
|
+
readonly lesson: string;
|
|
1495
|
+
readonly file: string;
|
|
1496
|
+
readonly scope: readonly { readonly key: string; readonly notation: string; readonly scope: 'shared' | 'local' }[];
|
|
1497
|
+
readonly spans: readonly BindingsSpan[];
|
|
1498
|
+
readonly counts: {
|
|
1499
|
+
readonly spans: number;
|
|
1500
|
+
readonly bindings: number;
|
|
1501
|
+
readonly unresolved: number;
|
|
1502
|
+
readonly wraps: number;
|
|
1503
|
+
readonly redundantWraps: number;
|
|
1504
|
+
readonly errors: number;
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* One span against a scope. Exported for tests and for a caller with a span
|
|
1510
|
+
* of its own (`content equation`).
|
|
1511
|
+
*/
|
|
1512
|
+
export function spanBindings(
|
|
1513
|
+
latex: string,
|
|
1514
|
+
scope: readonly ScopeEntry[],
|
|
1515
|
+
options: { readonly suspended?: readonly string[]; readonly derive?: boolean } = {},
|
|
1516
|
+
): SpanBindings {
|
|
1517
|
+
// D7: `\label{eq:…}` and `\caption{…}` are identity markers, not math KaTeX
|
|
1518
|
+
// parses; the gate strips them the same way.
|
|
1519
|
+
const source = stripEquationLabels(latex);
|
|
1520
|
+
const resolve = (expression: string) =>
|
|
1521
|
+
resolveMathGlyphs(expression, [...scope], {
|
|
1522
|
+
suspended: options.suspended ?? [],
|
|
1523
|
+
derive: options.derive ?? true,
|
|
1524
|
+
}) as {
|
|
1525
|
+
bindings: readonly { key: string; level: string; match: string; token: string }[];
|
|
1526
|
+
unresolved: readonly { token: string; suspended?: string; lenders?: readonly string[] }[];
|
|
1527
|
+
/** Rule N22: a decoration with neither a whole-form card nor an operator entry is unexplained. */
|
|
1528
|
+
decorations: readonly { wholeForm: string | null; operator: string | null }[];
|
|
1529
|
+
};
|
|
1530
|
+
let result: ReturnType<typeof resolve>;
|
|
1531
|
+
try {
|
|
1532
|
+
result = resolve(source);
|
|
1533
|
+
} catch (error) {
|
|
1534
|
+
return { bindings: [], wraps: [], unresolved: [], error: error instanceof Error ? error.message : String(error) };
|
|
1535
|
+
}
|
|
1536
|
+
// The gate's own rule for a wrap that binds nothing new (`explain-redundant`).
|
|
1537
|
+
const redundant = new Set(
|
|
1538
|
+
redundantExplainWraps(source, scope, { suspended: options.suspended ?? [], derive: options.derive ?? true }).map(
|
|
1539
|
+
(wrap) => wrap.index,
|
|
1540
|
+
),
|
|
1541
|
+
);
|
|
1542
|
+
const wraps: ExplicitWrap[] = explainCalls(source).map((call) => ({
|
|
1543
|
+
key: call.key,
|
|
1544
|
+
inner: call.inner,
|
|
1545
|
+
redundant: redundant.has(call.index),
|
|
1546
|
+
}));
|
|
1547
|
+
return {
|
|
1548
|
+
bindings: result.bindings.map((binding) => ({
|
|
1549
|
+
token: binding.token,
|
|
1550
|
+
key: binding.key,
|
|
1551
|
+
how: binding.level === 'explicit' ? 'explicit' : binding.match,
|
|
1552
|
+
})),
|
|
1553
|
+
wraps,
|
|
1554
|
+
unresolved: result.unresolved.map((item) => ({
|
|
1555
|
+
token: item.token,
|
|
1556
|
+
...(item.suspended === undefined ? {} : { suspended: item.suspended }),
|
|
1557
|
+
...(item.lenders === undefined || item.lenders.length === 0 ? {} : { lenders: [...item.lenders] }),
|
|
1558
|
+
})),
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* The glyph table a lesson's math resolves against — its bundle: the
|
|
1564
|
+
* `notation.local` entries and the shared cards it introduces — and the
|
|
1565
|
+
* decorations it suspends (rule N22). The same table the completeness gate
|
|
1566
|
+
* builds, read from the manifest instead of rebuilt.
|
|
1567
|
+
*/
|
|
1568
|
+
function lessonScope(
|
|
1569
|
+
manifest: Manifest,
|
|
1570
|
+
lesson: ManifestLesson,
|
|
1571
|
+
frontmatter: Record<string, unknown>,
|
|
1572
|
+
): { readonly records: readonly DefinitionRecord[]; readonly scope: ScopeEntry[]; readonly suspended: string[] } {
|
|
1573
|
+
const ids = new Set(lesson.notation.definitionIds);
|
|
1574
|
+
const records = manifest.notation.definitions.filter((definition) => ids.has(definition.id));
|
|
1575
|
+
const scope: ScopeEntry[] = records.map((definition) => ({
|
|
1576
|
+
key: definition.key,
|
|
1577
|
+
notation: definition.notation,
|
|
1578
|
+
...(definition.fixedIndex ? { fixedIndex: true as const } : {}),
|
|
1579
|
+
}));
|
|
1580
|
+
const suspend = (frontmatter.notation as { suspend?: { operator?: unknown }[] } | undefined)?.suspend ?? [];
|
|
1581
|
+
const suspended = suspend
|
|
1582
|
+
.map((entry) => entry.operator)
|
|
1583
|
+
.filter((operator): operator is string => typeof operator === 'string');
|
|
1584
|
+
return { records, scope, suspended };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/** Where `content symbol` finds a token defined, for an unresolved glyph; one lookup per token. */
|
|
1588
|
+
function symbolLookup(manifest: Manifest) {
|
|
1589
|
+
const lookups = new Map<string, Pick<UnresolvedGlyph, 'defined' | 'near'>>();
|
|
1590
|
+
return (token: string) => {
|
|
1591
|
+
const cached = lookups.get(token);
|
|
1592
|
+
if (cached) return cached;
|
|
1593
|
+
const report = contentSymbol(manifest, token);
|
|
1594
|
+
const found = {
|
|
1595
|
+
defined: [...report.shared, ...report.local, ...report.glosses].map((hit) => ({
|
|
1596
|
+
tier: hit.tier,
|
|
1597
|
+
key: hit.key,
|
|
1598
|
+
where: hit.where,
|
|
1599
|
+
})),
|
|
1600
|
+
near: report.contains.slice(0, 3).map((hit) => ({ latex: hit.latex, key: hit.key })),
|
|
1601
|
+
};
|
|
1602
|
+
lookups.set(token, found);
|
|
1603
|
+
return found;
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
export function contentBindings(manifest: Manifest, rawId: string): BindingsReport {
|
|
1608
|
+
const lesson = findLesson(manifest, rawId);
|
|
1609
|
+
const { frontmatter, body } = lessonRaw(lesson);
|
|
1610
|
+
const text = readFileSync(lessonAbsolutePath(lesson), 'utf8');
|
|
1611
|
+
const bodyStart = Math.max(0, text.indexOf(body));
|
|
1612
|
+
const bodyLine = text.slice(0, bodyStart).split('\n').length;
|
|
1613
|
+
|
|
1614
|
+
const { records: scopeRecords, scope, suspended } = lessonScope(manifest, lesson, frontmatter);
|
|
1615
|
+
const lookup = symbolLookup(manifest);
|
|
1616
|
+
|
|
1617
|
+
const spans: BindingsSpan[] = mathSpans(body).map((span) => {
|
|
1618
|
+
const resolved = spanBindings(span.latex, scope, { suspended, derive: true });
|
|
1619
|
+
return {
|
|
1620
|
+
line: bodyLine + span.line - 1,
|
|
1621
|
+
column: span.column,
|
|
1622
|
+
display: body.slice(Math.max(0, span.offset - 2), span.offset) === '$$',
|
|
1623
|
+
latex: span.latex,
|
|
1624
|
+
bindings: resolved.bindings,
|
|
1625
|
+
wraps: resolved.wraps,
|
|
1626
|
+
unresolved: resolved.unresolved.map((item) => ({ ...item, ...lookup(item.token) })),
|
|
1627
|
+
...(resolved.error === undefined ? {} : { error: resolved.error }),
|
|
1628
|
+
};
|
|
1629
|
+
});
|
|
1630
|
+
spans.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
1631
|
+
|
|
1632
|
+
return {
|
|
1633
|
+
lesson: lesson.id,
|
|
1634
|
+
file: lesson.file,
|
|
1635
|
+
scope: scopeRecords
|
|
1636
|
+
.map((definition) => ({ key: definition.key, notation: definition.notation, scope: definition.kind }))
|
|
1637
|
+
.sort((a, b) => a.key.localeCompare(b.key)),
|
|
1638
|
+
spans,
|
|
1639
|
+
counts: {
|
|
1640
|
+
spans: spans.length,
|
|
1641
|
+
bindings: spans.reduce((n, span) => n + span.bindings.length, 0),
|
|
1642
|
+
unresolved: spans.reduce((n, span) => n + span.unresolved.length, 0),
|
|
1643
|
+
wraps: spans.reduce((n, span) => n + span.wraps.length, 0),
|
|
1644
|
+
redundantWraps: spans.reduce((n, span) => n + span.wraps.filter((wrap) => wrap.redundant).length, 0),
|
|
1645
|
+
errors: spans.filter((span) => span.error !== undefined).length,
|
|
1646
|
+
},
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function latexPreview(latex: string, width = 44): string {
|
|
1651
|
+
const flat = latex.replace(/\s+/g, ' ').trim();
|
|
1652
|
+
return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** The text form: a counts line, the scope, then one line per span. */
|
|
1656
|
+
export function formatBindingsReport(report: BindingsReport): string {
|
|
1657
|
+
const c = report.counts;
|
|
1658
|
+
const lines = [
|
|
1659
|
+
`${report.lesson} — ${report.file}`,
|
|
1660
|
+
`${c.spans} math span(s) · ${c.bindings} binding(s) · ${c.unresolved} unresolved · ${c.wraps} explicit wrap(s)` +
|
|
1661
|
+
(c.wraps > 0 ? `, ${c.redundantWraps} redundant (the bare symbol resolves to the same card)` : '') +
|
|
1662
|
+
(c.errors > 0 ? ` · ${c.errors} span(s) the resolver rejects` : ''),
|
|
1663
|
+
`scope (${report.scope.length}): ${report.scope
|
|
1664
|
+
.map((entry) => `${entry.key} ${entry.notation}${entry.scope === 'local' ? ' (local)' : ''}`)
|
|
1665
|
+
.join(', ') || '(none)'}`,
|
|
1666
|
+
];
|
|
1667
|
+
for (const span of report.spans) {
|
|
1668
|
+
const where = `L${span.line}:${span.column}`.padEnd(9);
|
|
1669
|
+
const preview = (span.display ? '$$' : '$') + latexPreview(span.latex) + (span.display ? '$$' : '$');
|
|
1670
|
+
const parts: string[] = [];
|
|
1671
|
+
if (span.error !== undefined) parts.push(`REJECTED ${span.error}`);
|
|
1672
|
+
for (const binding of span.bindings) {
|
|
1673
|
+
const explicit = binding.how === 'explicit';
|
|
1674
|
+
const wrap = explicit ? span.wraps.find((w) => w.key === binding.key) : undefined;
|
|
1675
|
+
const note = explicit
|
|
1676
|
+
? ` [wrap${wrap?.redundant ? ', redundant' : ''}]`
|
|
1677
|
+
: binding.how === 'canonical'
|
|
1678
|
+
? ''
|
|
1679
|
+
: ` [${binding.how}]`;
|
|
1680
|
+
parts.push(`${binding.token}${explicit ? '⇒' : '→'}${binding.key}${note}`);
|
|
1681
|
+
}
|
|
1682
|
+
for (const item of span.unresolved) {
|
|
1683
|
+
const defined =
|
|
1684
|
+
item.defined.length > 0
|
|
1685
|
+
? ` — defined: ${item.defined.map((d) => `${d.key} (${d.tier}, ${d.where})`).join('; ')}`
|
|
1686
|
+
: item.near.length > 0
|
|
1687
|
+
? ` — not defined; near: ${item.near.map((n) => `${n.latex} ${n.key}`).join(', ')}`
|
|
1688
|
+
: ' — not defined anywhere';
|
|
1689
|
+
const why = item.suspended ? ` (suspended ${item.suspended})` : item.lenders ? ` (indexed entries ${item.lenders.join(', ')} lend no card to the bare letter)` : '';
|
|
1690
|
+
parts.push(`${item.token} UNRESOLVED${why}${defined}`);
|
|
1691
|
+
}
|
|
1692
|
+
lines.push(`${where} ${preview.padEnd(48)} ${parts.join(' · ') || '(nothing to bind)'}`);
|
|
1693
|
+
}
|
|
1694
|
+
if (report.spans.length === 0) lines.push('(no math)');
|
|
1695
|
+
return `${lines.join('\n')}\n`;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// --- content equation ------------------------------------------------
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* One LaTeX string, resolved the way the gate will: against a lesson's
|
|
1702
|
+
* scope, or against every shared card when no lesson is named (the table a
|
|
1703
|
+
* notation entry's body and formula use). The inner loop while drafting:
|
|
1704
|
+
* a few lines out, non-zero exit when the build would refuse the span.
|
|
1705
|
+
*/
|
|
1706
|
+
export interface EquationReport extends Omit<SpanBindings, 'unresolved'> {
|
|
1707
|
+
readonly latex: string;
|
|
1708
|
+
readonly against: { readonly kind: 'lesson'; readonly lesson: string; readonly cards: number } | { readonly kind: 'shared'; readonly cards: number };
|
|
1709
|
+
readonly unresolved: readonly UnresolvedGlyph[];
|
|
1710
|
+
readonly ok: boolean;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
export function contentEquation(
|
|
1714
|
+
manifest: Manifest,
|
|
1715
|
+
latex: string,
|
|
1716
|
+
options: { readonly lesson?: string } = {},
|
|
1717
|
+
): EquationReport {
|
|
1718
|
+
const lookup = symbolLookup(manifest);
|
|
1719
|
+
let resolved: SpanBindings;
|
|
1720
|
+
let against: EquationReport['against'];
|
|
1721
|
+
if (options.lesson !== undefined) {
|
|
1722
|
+
const lesson = findLesson(manifest, options.lesson);
|
|
1723
|
+
const { frontmatter } = lessonRaw(lesson);
|
|
1724
|
+
const { scope, suspended } = lessonScope(manifest, lesson, frontmatter);
|
|
1725
|
+
resolved = spanBindings(latex, scope, { suspended, derive: true });
|
|
1726
|
+
against = { kind: 'lesson', lesson: lesson.id, cards: scope.length };
|
|
1727
|
+
} else {
|
|
1728
|
+
const scope: ScopeEntry[] = manifest.notation.definitions
|
|
1729
|
+
.filter((definition) => definition.kind === 'shared')
|
|
1730
|
+
.map((definition) => ({
|
|
1731
|
+
key: definition.key,
|
|
1732
|
+
notation: definition.notation,
|
|
1733
|
+
...(definition.fixedIndex ? { fixedIndex: true as const } : {}),
|
|
1734
|
+
}));
|
|
1735
|
+
resolved = spanBindings(latex, scope, { suspended: [], derive: false });
|
|
1736
|
+
against = { kind: 'shared', cards: scope.length };
|
|
1737
|
+
}
|
|
1738
|
+
const unresolved = resolved.unresolved.map((item) => ({ ...item, ...lookup(item.token) }));
|
|
1739
|
+
return {
|
|
1740
|
+
latex,
|
|
1741
|
+
against,
|
|
1742
|
+
bindings: resolved.bindings,
|
|
1743
|
+
wraps: resolved.wraps,
|
|
1744
|
+
unresolved,
|
|
1745
|
+
...(resolved.error === undefined ? {} : { error: resolved.error }),
|
|
1746
|
+
ok: resolved.error === undefined && unresolved.length === 0,
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
export function formatEquationReport(report: EquationReport): string {
|
|
1751
|
+
const against =
|
|
1752
|
+
report.against.kind === 'lesson'
|
|
1753
|
+
? `${report.against.lesson}'s scope (${report.against.cards} cards)`
|
|
1754
|
+
: `every shared card (${report.against.cards})`;
|
|
1755
|
+
const lines = [`$${latexPreview(report.latex, 80)}$ against ${against}`];
|
|
1756
|
+
if (report.error !== undefined) lines.push(` REJECTED ${report.error}`);
|
|
1757
|
+
if (report.bindings.length > 0) {
|
|
1758
|
+
lines.push(
|
|
1759
|
+
` ${report.bindings
|
|
1760
|
+
.map((binding) => {
|
|
1761
|
+
const explicit = binding.how === 'explicit';
|
|
1762
|
+
const wrap = explicit ? report.wraps.find((w) => w.key === binding.key) : undefined;
|
|
1763
|
+
const note = explicit ? ` [wrap${wrap?.redundant ? ', redundant' : ''}]` : binding.how === 'canonical' ? '' : ` [${binding.how}]`;
|
|
1764
|
+
return `${binding.token}${explicit ? '⇒' : '→'}${binding.key}${note}`;
|
|
1765
|
+
})
|
|
1766
|
+
.join(' · ')}`,
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
for (const item of report.unresolved) {
|
|
1770
|
+
const defined =
|
|
1771
|
+
item.defined.length > 0
|
|
1772
|
+
? ` — defined: ${item.defined.map((d) => `${d.key} (${d.tier}, ${d.where})`).join('; ')}`
|
|
1773
|
+
: item.near.length > 0
|
|
1774
|
+
? ` — not defined; near: ${item.near.map((n) => `${n.latex} ${n.key}`).join(', ')}`
|
|
1775
|
+
: ' — not defined anywhere';
|
|
1776
|
+
const why = item.suspended ? ` (suspended ${item.suspended})` : item.lenders ? ` (indexed entries ${item.lenders.join(', ')} lend no card to the bare letter)` : '';
|
|
1777
|
+
lines.push(` ${item.token} UNRESOLVED${why}${defined}`);
|
|
1778
|
+
}
|
|
1779
|
+
lines.push(
|
|
1780
|
+
report.ok
|
|
1781
|
+
? 'ok: every glyph binds.'
|
|
1782
|
+
: report.error !== undefined
|
|
1783
|
+
? 'The build would reject this span.'
|
|
1784
|
+
: `${report.unresolved.length} unresolved glyph(s): the build would fail on this span.`,
|
|
1785
|
+
);
|
|
1786
|
+
return `${lines.join('\n')}\n`;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// --- content move / rename / fix: edits as a plan ----------------------
|
|
1790
|
+
|
|
1791
|
+
/*
|
|
1792
|
+
* The refactors a model gets wrong most and reads most for, as deterministic
|
|
1793
|
+
* edits: every one is planned first (`FileEdit`s with the whole text before
|
|
1794
|
+
* and after), shown as a unified diff, and written only on `--write`.
|
|
1795
|
+
*/
|
|
1796
|
+
export interface FileEdit {
|
|
1797
|
+
/** Course-relative path. */
|
|
1798
|
+
readonly file: string;
|
|
1799
|
+
/** `null` when the file is created. */
|
|
1800
|
+
readonly before: string | null;
|
|
1801
|
+
/** `null` when the file is deleted. */
|
|
1802
|
+
readonly after: string | null;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
export interface EditPlan {
|
|
1806
|
+
readonly summary: readonly string[];
|
|
1807
|
+
readonly edits: readonly FileEdit[];
|
|
1808
|
+
/** What to run after writing. */
|
|
1809
|
+
readonly next: readonly string[];
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
const KEY_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
1813
|
+
|
|
1814
|
+
function assertNotationKey(key: string): void {
|
|
1815
|
+
if (!KEY_PATTERN.test(key)) {
|
|
1816
|
+
throw new ContentCliError('invalid-id', `A notation key is lower-case, dot/hyphen separated, got ${JSON.stringify(key)}.`);
|
|
1817
|
+
}
|
|
1818
|
+
const reserved = reservedKeyPrefix(key);
|
|
1819
|
+
if (reserved !== undefined) {
|
|
1820
|
+
throw new ContentCliError(
|
|
1821
|
+
'invalid-id',
|
|
1822
|
+
`A notation key may not start with ${RESERVED_KEY_PREFIXES.join(', ')}: [[${key}]] would be read as a reference to that kind of float.`,
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
function escapeRegExp(text: string): string {
|
|
1828
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/** `[[old]]` and `\explain{old}` become `new`; nothing else is touched. Returns the count. */
|
|
1832
|
+
export function rewriteKeyReferences(text: string, oldKey: string, newKey: string): { text: string; count: number } {
|
|
1833
|
+
const k = escapeRegExp(oldKey);
|
|
1834
|
+
let count = 0;
|
|
1835
|
+
const out = text
|
|
1836
|
+
.replace(new RegExp(`\\[\\[\\s*${k}\\s*\\]\\]`, 'g'), () => {
|
|
1837
|
+
count += 1;
|
|
1838
|
+
return `[[${newKey}]]`;
|
|
1839
|
+
})
|
|
1840
|
+
.replace(new RegExp(`\\\\explain(\\s*)\\{${k}\\}`, 'g'), (_m, space: string) => {
|
|
1841
|
+
count += 1;
|
|
1842
|
+
return `\\explain${space}{${newKey}}`;
|
|
1843
|
+
});
|
|
1844
|
+
return { text: out, count };
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/** A `seeAlso:` item or a lint-ignore detail naming the key, in a notation file or lint-ignore.yml. */
|
|
1848
|
+
function rewriteKeyMentions(text: string, oldKey: string, newKey: string): { text: string; count: number } {
|
|
1849
|
+
const k = escapeRegExp(oldKey);
|
|
1850
|
+
let count = 0;
|
|
1851
|
+
const out = text
|
|
1852
|
+
.replace(new RegExp(`^(\\s*-\\s*)${k}[\\t ]*$`, 'gm'), (_m, lead: string) => {
|
|
1853
|
+
count += 1;
|
|
1854
|
+
return `${lead}${newKey}`;
|
|
1855
|
+
})
|
|
1856
|
+
.replace(new RegExp(`^(\\s*-\\s*[a-z-]+:)${k}(\\s+—)`, 'gm'), (_m, lead: string, tail: string) => {
|
|
1857
|
+
count += 1;
|
|
1858
|
+
return `${lead}${newKey}${tail}`;
|
|
1859
|
+
});
|
|
1860
|
+
return { text: out, count };
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* Remove one `notation.local` item from a lesson file's text, by key, keeping
|
|
1865
|
+
* the rest of the frontmatter byte for byte. An item runs from its `- key:`
|
|
1866
|
+
* line to the next line indented no deeper than the dash; an emptied list
|
|
1867
|
+
* becomes `local: []`.
|
|
1868
|
+
*/
|
|
1869
|
+
export function removeLocalItem(text: string, key: string): { text: string; removed: boolean } {
|
|
1870
|
+
const lines = text.split('\n');
|
|
1871
|
+
const start = lines.findIndex((line) => new RegExp(`^(\\s*)-\\s*key:\\s*${escapeRegExp(key)}\\s*$`).test(line));
|
|
1872
|
+
if (start < 0) return { text, removed: false };
|
|
1873
|
+
const indent = /^(\s*)-/.exec(lines[start]!)![1]!.length;
|
|
1874
|
+
let end = start + 1;
|
|
1875
|
+
while (end < lines.length) {
|
|
1876
|
+
const line = lines[end]!;
|
|
1877
|
+
if (line.trim() !== '' && (line.length - line.trimStart().length) <= indent) break;
|
|
1878
|
+
if (line === '---') break;
|
|
1879
|
+
end += 1;
|
|
1880
|
+
}
|
|
1881
|
+
// Trailing blank lines inside the item belong to whatever follows.
|
|
1882
|
+
while (end > start + 1 && lines[end - 1]!.trim() === '') end -= 1;
|
|
1883
|
+
lines.splice(start, end - start);
|
|
1884
|
+
// The list the item sat in: the nearest `local:` above with a smaller indent.
|
|
1885
|
+
let listLine = start - 1;
|
|
1886
|
+
while (listLine >= 0 && !/^\s*local:\s*$/.test(lines[listLine]!)) listLine -= 1;
|
|
1887
|
+
if (listLine >= 0) {
|
|
1888
|
+
const listIndent = lines[listLine]!.length - lines[listLine]!.trimStart().length;
|
|
1889
|
+
let probe = listLine + 1;
|
|
1890
|
+
while (probe < lines.length && lines[probe]!.trim() === '') probe += 1;
|
|
1891
|
+
const nextIndent = probe < lines.length ? lines[probe]!.length - lines[probe]!.trimStart().length : 0;
|
|
1892
|
+
if (probe >= lines.length || lines[probe] === '---' || nextIndent <= listIndent) {
|
|
1893
|
+
lines[listLine] = `${' '.repeat(listIndent)}local: []`;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return { text: lines.join('\n'), removed: true };
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
function yamlScalar(value: unknown): string {
|
|
1900
|
+
if (typeof value === 'string') {
|
|
1901
|
+
return /^[A-Za-z0-9][A-Za-z0-9 ,.()/%^_-]*$/.test(value) && !/^(true|false|null|yes|no|on|off)$/i.test(value) && !/^\d/.test(value)
|
|
1902
|
+
? value
|
|
1903
|
+
: JSON.stringify(value);
|
|
1904
|
+
}
|
|
1905
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
1906
|
+
return JSON.stringify(value);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
function yamlBlock(value: unknown, indent: number): string[] {
|
|
1910
|
+
const pad = ' '.repeat(indent);
|
|
1911
|
+
if (Array.isArray(value)) {
|
|
1912
|
+
if (value.length === 0) return [`${pad}[]`];
|
|
1913
|
+
return value.flatMap((item) => {
|
|
1914
|
+
if (item !== null && typeof item === 'object') {
|
|
1915
|
+
const inner = yamlBlock(item, indent + 2);
|
|
1916
|
+
return [`${pad}- ${inner[0]!.trimStart()}`, ...inner.slice(1)];
|
|
1917
|
+
}
|
|
1918
|
+
return [`${pad}- ${yamlScalar(item)}`];
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
if (value !== null && typeof value === 'object') {
|
|
1922
|
+
return Object.entries(value as Record<string, unknown>).flatMap(([k, v]) => {
|
|
1923
|
+
if (v === undefined) return [];
|
|
1924
|
+
if (Array.isArray(v) && v.length === 0) return [`${pad}${k}: []`];
|
|
1925
|
+
if (v !== null && typeof v === 'object') return [`${pad}${k}:`, ...yamlBlock(v, indent + 2)];
|
|
1926
|
+
return [`${pad}${k}: ${yamlScalar(v)}`];
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
return [`${pad}${yamlScalar(value)}`];
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/**
|
|
1933
|
+
* A shared card's file from a page-local entry's fields, nothing invented: no
|
|
1934
|
+
* body (the glossary shows the meaning; what a reader needs beyond it is the
|
|
1935
|
+
* author's to write, and a placeholder would publish), and the label only
|
|
1936
|
+
* when the entry has one. `domain` is the caller's pick, else the key's
|
|
1937
|
+
* prefix, else `general`.
|
|
1938
|
+
*/
|
|
1939
|
+
export function sharedCardFromLocal(
|
|
1940
|
+
local: Record<string, unknown>,
|
|
1941
|
+
options: { readonly key: string; readonly fromLesson: string; readonly domain?: string },
|
|
1942
|
+
): string {
|
|
1943
|
+
const key = options.key;
|
|
1944
|
+
const domain = options.domain ?? (key.includes('.') ? key.slice(0, key.indexOf('.')) : 'general');
|
|
1945
|
+
const front: Record<string, unknown> = {
|
|
1946
|
+
key,
|
|
1947
|
+
latex: local.latex,
|
|
1948
|
+
...(local.label === undefined ? {} : { label: local.label }),
|
|
1949
|
+
meaning: local.meaning,
|
|
1950
|
+
...(local.formula === undefined ? {} : { formula: local.formula }),
|
|
1951
|
+
domain,
|
|
1952
|
+
...(local.units === undefined ? {} : { units: local.units }),
|
|
1953
|
+
...(local.dimensionless === undefined ? {} : { dimensionless: local.dimensionless }),
|
|
1954
|
+
...(local.range === undefined ? {} : { range: local.range }),
|
|
1955
|
+
...(local.fixedIndex === undefined ? {} : { fixedIndex: local.fixedIndex }),
|
|
1956
|
+
...(local.reads === undefined ? {} : { reads: local.reads }),
|
|
1957
|
+
sources: local.sources ?? [],
|
|
1958
|
+
seeAlso: local.seeAlso ?? [],
|
|
1959
|
+
...(local.glosses === undefined ? {} : { glosses: local.glosses }),
|
|
1960
|
+
alignment: local.alignment ?? { kind: 'general', rationale: `Promoted from ${options.fromLesson}'s page-local entry.` },
|
|
1961
|
+
aiAssisted: true,
|
|
1962
|
+
};
|
|
1963
|
+
return `---\n${yamlBlock(front, 0).join('\n')}\n---\n`;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/**
|
|
1967
|
+
* The domain a card promoted under `key` belongs to: the key's prefix when the
|
|
1968
|
+
* course already has that domain, else the one most of the lesson's shared
|
|
1969
|
+
* cards have, else the course's most common, else the prefix or `general`.
|
|
1970
|
+
*/
|
|
1971
|
+
function promotedDomain(manifest: Manifest, lesson: ManifestLesson, key: string): string {
|
|
1972
|
+
type Shared = Extract<Manifest['notation']['definitions'][number], { kind: 'shared' }>;
|
|
1973
|
+
const shared = manifest.notation.definitions.filter((definition): definition is Shared => definition.kind === 'shared');
|
|
1974
|
+
const prefix = key.includes('.') ? key.slice(0, key.indexOf('.')) : undefined;
|
|
1975
|
+
if (prefix !== undefined && shared.some((definition) => definition.domain === prefix)) return prefix;
|
|
1976
|
+
const mostCommon = (definitions: typeof shared): string | undefined => {
|
|
1977
|
+
const counts = new Map<string, number>();
|
|
1978
|
+
for (const definition of definitions) {
|
|
1979
|
+
if (definition.domain) counts.set(definition.domain, (counts.get(definition.domain) ?? 0) + 1);
|
|
1980
|
+
}
|
|
1981
|
+
return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
|
|
1982
|
+
};
|
|
1983
|
+
const bound = new Set(lesson.notation.definitionIds);
|
|
1984
|
+
return mostCommon(shared.filter((definition) => bound.has(definition.id))) ?? mostCommon(shared) ?? prefix ?? 'general';
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
function courseTextFiles(): string[] {
|
|
1988
|
+
const out: string[] = [];
|
|
1989
|
+
const walk = (dir: string) => {
|
|
1990
|
+
for (const entry of readdirSync(join(REPOSITORY_ROOT, dir), { withFileTypes: true })) {
|
|
1991
|
+
const rel = `${dir}/${entry.name}`;
|
|
1992
|
+
if (entry.isDirectory()) walk(rel);
|
|
1993
|
+
else if (/\.(mdx?|ya?ml)$/.test(entry.name)) out.push(rel);
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1996
|
+
if (existsSync(join(REPOSITORY_ROOT, 'content'))) walk('content');
|
|
1997
|
+
if (existsSync(join(REPOSITORY_ROOT, 'lint-ignore.yml'))) out.push('lint-ignore.yml');
|
|
1998
|
+
return out.sort();
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function referenceEdits(oldKey: string, newKey: string, skip: ReadonlySet<string>): { edits: FileEdit[]; count: number } {
|
|
2002
|
+
const edits: FileEdit[] = [];
|
|
2003
|
+
let count = 0;
|
|
2004
|
+
for (const file of courseTextFiles()) {
|
|
2005
|
+
if (skip.has(file)) continue;
|
|
2006
|
+
const before = readFileSync(join(REPOSITORY_ROOT, file), 'utf8');
|
|
2007
|
+
const refs = rewriteKeyReferences(before, oldKey, newKey);
|
|
2008
|
+
const mentions = /^content\/notation\/|^lint-ignore\.yml$/.test(file)
|
|
2009
|
+
? rewriteKeyMentions(refs.text, oldKey, newKey)
|
|
2010
|
+
: { text: refs.text, count: 0 };
|
|
2011
|
+
const n = refs.count + mentions.count;
|
|
2012
|
+
if (n > 0) {
|
|
2013
|
+
edits.push({ file, before, after: mentions.text });
|
|
2014
|
+
count += n;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
return { edits, count };
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* `content move <lesson>:<key> [--to <key>]`: a page-local entry becomes a
|
|
2022
|
+
* shared card. Every lesson that declares the key loses its copy (the card
|
|
2023
|
+
* serves them all; copies that differ are reported, the named lesson's text
|
|
2024
|
+
* wins), and with `--to` every `[[key]]` and `\explain{key}` follows.
|
|
2025
|
+
*/
|
|
2026
|
+
export function planMove(manifest: Manifest, lessonId: string, key: string, options: { readonly to?: string } = {}): EditPlan {
|
|
2027
|
+
const lesson = findLesson(manifest, lessonId);
|
|
2028
|
+
const newKey = options.to ?? key;
|
|
2029
|
+
assertNotationKey(newKey);
|
|
2030
|
+
const target = `content/notation/${newKey}.md`;
|
|
2031
|
+
if (existsSync(join(REPOSITORY_ROOT, target))) {
|
|
2032
|
+
throw new ContentCliError('exists', `${target} already exists; reference it with [[${newKey}]] instead, or move with --to another key.`);
|
|
2033
|
+
}
|
|
2034
|
+
const local = (lesson: ManifestLesson): Record<string, unknown> | undefined => {
|
|
2035
|
+
const { frontmatter } = lessonRaw(lesson);
|
|
2036
|
+
const list = (frontmatter.notation as { local?: Record<string, unknown>[] } | undefined)?.local ?? [];
|
|
2037
|
+
return list.find((entry) => entry.key === key);
|
|
2038
|
+
};
|
|
2039
|
+
const source = local(lesson);
|
|
2040
|
+
if (!source) {
|
|
2041
|
+
throw new ContentCliError('unknown-key', `${lesson.id} declares no notation.local entry ${JSON.stringify(key)}.`);
|
|
2042
|
+
}
|
|
2043
|
+
const summary: string[] = [];
|
|
2044
|
+
const edits: FileEdit[] = [];
|
|
2045
|
+
const domain = promotedDomain(manifest, lesson, newKey);
|
|
2046
|
+
const glyph = typeof source.latex === 'string' ? source.latex.trim() : '';
|
|
2047
|
+
const owner = manifest.notation.definitions.find(
|
|
2048
|
+
(definition) => definition.kind === 'shared' && definition.key !== newKey && definition.notation.trim() === glyph,
|
|
2049
|
+
);
|
|
2050
|
+
if (owner !== undefined) {
|
|
2051
|
+
summary.push(
|
|
2052
|
+
`CAUTION: ${glyph} is already shared card ${owner.key}'s — a second shared card on one glyph is a glyph-unique-in-corpus finding; ` +
|
|
2053
|
+
'a reused letter stays local (rule N4). Write this only if the two cards are meant to share the glyph, and acknowledge the pair.',
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
edits.push({ file: target, before: null, after: sharedCardFromLocal(source, { key: newKey, fromLesson: lesson.id, domain }) });
|
|
2057
|
+
summary.push(
|
|
2058
|
+
`${target}: new shared card from ${lesson.id}'s entry${newKey === key ? '' : ` (key ${key} → ${newKey})`}, domain ${domain}` +
|
|
2059
|
+
`; no body yet — add what a glossary reader needs beyond the meaning` +
|
|
2060
|
+
(source.label === undefined ? '; no label in the entry — the card shows one made from the key until you add `label:`' : ''),
|
|
2061
|
+
);
|
|
2062
|
+
const touched = new Set<string>();
|
|
2063
|
+
for (const other of manifest.lessons) {
|
|
2064
|
+
const copy = local(other);
|
|
2065
|
+
if (!copy) continue;
|
|
2066
|
+
const before = readFileSync(lessonAbsolutePath(other), 'utf8');
|
|
2067
|
+
const removed = removeLocalItem(before, key);
|
|
2068
|
+
if (!removed.removed) continue;
|
|
2069
|
+
let after = removed.text;
|
|
2070
|
+
if (newKey !== key) after = rewriteKeyReferences(after, key, newKey).text;
|
|
2071
|
+
edits.push({ file: other.file, before, after });
|
|
2072
|
+
touched.add(other.file);
|
|
2073
|
+
const same = ['latex', 'label', 'meaning', 'units'].every((field) => JSON.stringify(copy[field] ?? null) === JSON.stringify(source[field] ?? null));
|
|
2074
|
+
summary.push(
|
|
2075
|
+
`${other.file}: local entry ${key} removed` +
|
|
2076
|
+
(other.id === lesson.id ? '' : same ? ' (a copy that agreed)' : ' (a copy that DIFFERED — the named lesson\'s text is the card\'s; check it)'),
|
|
2077
|
+
);
|
|
2078
|
+
}
|
|
2079
|
+
if (newKey !== key) {
|
|
2080
|
+
const refs = referenceEdits(key, newKey, touched);
|
|
2081
|
+
edits.push(...refs.edits);
|
|
2082
|
+
if (refs.count > 0) summary.push(`${refs.count} reference(s) to ${key} in ${refs.edits.length} other file(s) now name ${newKey}`);
|
|
2083
|
+
}
|
|
2084
|
+
return { summary, edits, next: ['pnpm validate:content', `pnpm content symbol ${newKey}`] };
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/** `content rename <old> <new>`: a shared card's key, everywhere it is named. */
|
|
2088
|
+
export function planRename(manifest: Manifest, oldKey: string, newKey: string): EditPlan {
|
|
2089
|
+
assertNotationKey(newKey);
|
|
2090
|
+
const oldFile = `content/notation/${oldKey}.md`;
|
|
2091
|
+
const newFile = `content/notation/${newKey}.md`;
|
|
2092
|
+
if (!existsSync(join(REPOSITORY_ROOT, oldFile))) {
|
|
2093
|
+
const local = manifest.lessons.find((lesson) => {
|
|
2094
|
+
const { frontmatter } = lessonRaw(lesson);
|
|
2095
|
+
return ((frontmatter.notation as { local?: { key?: string }[] } | undefined)?.local ?? []).some((e) => e.key === oldKey);
|
|
2096
|
+
});
|
|
2097
|
+
throw new ContentCliError(
|
|
2098
|
+
'unknown-key',
|
|
2099
|
+
local
|
|
2100
|
+
? `${oldKey} is a notation.local entry of ${local.id}, not a shared card; promote it with content move ${local.id}:${oldKey} --to ${newKey}.`
|
|
2101
|
+
: `No shared card ${oldKey} (${oldFile}).`,
|
|
2102
|
+
);
|
|
2103
|
+
}
|
|
2104
|
+
if (existsSync(join(REPOSITORY_ROOT, newFile))) throw new ContentCliError('exists', `${newFile} already exists.`);
|
|
2105
|
+
const before = readFileSync(join(REPOSITORY_ROOT, oldFile), 'utf8');
|
|
2106
|
+
const keyLine = /^key:\s*.*$/m.exec(before);
|
|
2107
|
+
if (!keyLine) throw new ContentCliError('unknown-key', `${oldFile} has no key: line.`);
|
|
2108
|
+
const moved = before.slice(0, keyLine.index) + `key: ${newKey}` + before.slice(keyLine.index + keyLine[0].length);
|
|
2109
|
+
const edits: FileEdit[] = [
|
|
2110
|
+
{ file: oldFile, before, after: null },
|
|
2111
|
+
{ file: newFile, before: null, after: rewriteKeyReferences(moved, oldKey, newKey).text },
|
|
2112
|
+
];
|
|
2113
|
+
const refs = referenceEdits(oldKey, newKey, new Set([oldFile]));
|
|
2114
|
+
edits.push(...refs.edits);
|
|
2115
|
+
return {
|
|
2116
|
+
summary: [
|
|
2117
|
+
`${oldFile} → ${newFile} (key: ${newKey})`,
|
|
2118
|
+
`${refs.count} reference(s) in ${refs.edits.length} file(s) now name ${newKey}`,
|
|
2119
|
+
],
|
|
2120
|
+
edits,
|
|
2121
|
+
next: ['pnpm validate:content', `pnpm content symbol ${newKey}`],
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/**
|
|
2126
|
+
* `content fix <lesson>`: the diagnostics with one mechanical repair, applied.
|
|
2127
|
+
* Today: every `\explain{key}{inner}` the gate reports as `explain-redundant`
|
|
2128
|
+
* becomes `inner`.
|
|
2129
|
+
*/
|
|
2130
|
+
export function planFix(manifest: Manifest, lessonId: string): EditPlan {
|
|
2131
|
+
const lesson = findLesson(manifest, lessonId);
|
|
2132
|
+
const { frontmatter, body } = lessonRaw(lesson);
|
|
2133
|
+
const text = readFileSync(lessonAbsolutePath(lesson), 'utf8');
|
|
2134
|
+
const bodyStart = Math.max(0, text.indexOf(body));
|
|
2135
|
+
const { scope, suspended } = lessonScope(manifest, lesson, frontmatter);
|
|
2136
|
+
const replacements: { start: number; end: number; inner: string; key: string }[] = [];
|
|
2137
|
+
for (const span of mathSpans(body)) {
|
|
2138
|
+
const source = stripEquationLabels(span.latex);
|
|
2139
|
+
// The stripped span keeps every offset of the original only when nothing
|
|
2140
|
+
// was stripped before a wrap; locate each wrap in the original instead.
|
|
2141
|
+
for (const wrap of redundantExplainWraps(source, scope, { suspended, derive: true })) {
|
|
2142
|
+
const needle = `\\explain{${wrap.key}}{${wrap.inner}}`;
|
|
2143
|
+
let at = span.latex.indexOf(needle);
|
|
2144
|
+
while (at >= 0) {
|
|
2145
|
+
replacements.push({ start: bodyStart + span.offset + at, end: bodyStart + span.offset + at + needle.length, inner: wrap.inner, key: wrap.key });
|
|
2146
|
+
at = span.latex.indexOf(needle, at + needle.length);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
const unique = [...new Map(replacements.map((r) => [r.start, r])).values()].sort((a, b) => b.start - a.start);
|
|
2151
|
+
let after = text;
|
|
2152
|
+
for (const r of unique) after = after.slice(0, r.start) + r.inner + after.slice(r.end);
|
|
2153
|
+
const byKey = new Map<string, number>();
|
|
2154
|
+
for (const r of unique) byKey.set(r.key, (byKey.get(r.key) ?? 0) + 1);
|
|
2155
|
+
return {
|
|
2156
|
+
summary:
|
|
2157
|
+
unique.length === 0
|
|
2158
|
+
? [`${lesson.file}: nothing to fix`]
|
|
2159
|
+
: [
|
|
2160
|
+
`${lesson.file}: ${unique.length} redundant \\explain wrap(s) unwrapped — ${[...byKey]
|
|
2161
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
2162
|
+
.map(([key, n]) => `${key} ×${n}`)
|
|
2163
|
+
.join(', ')}`,
|
|
2164
|
+
],
|
|
2165
|
+
edits: unique.length === 0 ? [] : [{ file: lesson.file, before: text, after }],
|
|
2166
|
+
next: [`pnpm content check ${lesson.id}`],
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/** `content fix` over several lessons (or every one, `--all`) as one plan: the lessons with nothing to fix are counted, not listed. */
|
|
2171
|
+
export function planFixMany(manifest: Manifest, lessonIds: readonly string[]): EditPlan {
|
|
2172
|
+
if (lessonIds.length === 1) return planFix(manifest, lessonIds[0]!);
|
|
2173
|
+
const plans = lessonIds.map((id) => planFix(manifest, id));
|
|
2174
|
+
const fixing = plans.filter((plan) => plan.edits.length > 0);
|
|
2175
|
+
const clean = plans.length - fixing.length;
|
|
2176
|
+
return {
|
|
2177
|
+
summary: [
|
|
2178
|
+
...fixing.flatMap((plan) => plan.summary),
|
|
2179
|
+
...(clean > 0 ? [`${clean} lesson(s) with nothing to fix`] : []),
|
|
2180
|
+
],
|
|
2181
|
+
edits: fixing.flatMap((plan) => plan.edits),
|
|
2182
|
+
next: ['pnpm content check --changed'],
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/** The plan as a unified diff (git's), file by file, under its summary lines. */
|
|
2187
|
+
export function renderEditPlan(plan: EditPlan): string {
|
|
2188
|
+
const lines = plan.summary.map((line) => ` ${line}`);
|
|
2189
|
+
const scratch = join(REPOSITORY_ROOT, '.agent', 'content-edits');
|
|
2190
|
+
mkdirSync(scratch, { recursive: true });
|
|
2191
|
+
plan.edits.forEach((edit, index) => {
|
|
2192
|
+
const a = join(scratch, `${index}.before`);
|
|
2193
|
+
const b = join(scratch, `${index}.after`);
|
|
2194
|
+
writeFileSync(a, edit.before ?? '');
|
|
2195
|
+
writeFileSync(b, edit.after ?? '');
|
|
2196
|
+
let diff = '';
|
|
2197
|
+
try {
|
|
2198
|
+
diff = execFileSync('git', ['diff', '--no-index', '--no-color', '--unified=2', '--', a, b], { encoding: 'utf8' });
|
|
2199
|
+
} catch (error) {
|
|
2200
|
+
diff = typeof (error as { stdout?: unknown }).stdout === 'string' ? ((error as { stdout: string }).stdout) : '';
|
|
2201
|
+
}
|
|
2202
|
+
const label = edit.before === null ? `${edit.file} (new file)` : edit.after === null ? `${edit.file} (deleted)` : edit.file;
|
|
2203
|
+
const hunks = diff.split('\n').filter((line) => !/^(diff --git|index |--- |\+\+\+ )/.test(line)).join('\n').trim();
|
|
2204
|
+
lines.push('', `--- ${label}`, hunks || '(no textual change)');
|
|
2205
|
+
unlinkSync(a);
|
|
2206
|
+
unlinkSync(b);
|
|
2207
|
+
});
|
|
2208
|
+
return `${lines.join('\n')}\n`;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
/** Write a plan's edits. Returns the files touched. */
|
|
2212
|
+
export function applyEditPlan(plan: EditPlan): string[] {
|
|
2213
|
+
const touched: string[] = [];
|
|
2214
|
+
for (const edit of plan.edits) {
|
|
2215
|
+
const path = join(REPOSITORY_ROOT, edit.file);
|
|
2216
|
+
if (edit.after === null) {
|
|
2217
|
+
if (existsSync(path)) unlinkSync(path);
|
|
2218
|
+
} else {
|
|
2219
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2220
|
+
writeFileSync(path, edit.after);
|
|
2221
|
+
}
|
|
2222
|
+
touched.push(edit.file);
|
|
2223
|
+
}
|
|
2224
|
+
return touched;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// --- content init-agent ----------------------------------------------
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* The engine's course-facing agent procedures (`docs/course-agent/`): skills,
|
|
2231
|
+
* the `rules-reviewer` agent and the `AGENTS.md` baseline, beside the
|
|
2232
|
+
* installed package or above this module.
|
|
2233
|
+
*/
|
|
2234
|
+
export function courseAgentDir(): string | undefined {
|
|
2235
|
+
const candidates = [join(REPOSITORY_ROOT, 'node_modules', 'explico', 'docs', 'course-agent')];
|
|
2236
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
2237
|
+
for (let level = 0; level < 5; level += 1) {
|
|
2238
|
+
candidates.push(join(dir, 'docs', 'course-agent'));
|
|
2239
|
+
dir = dirname(dir);
|
|
2240
|
+
}
|
|
2241
|
+
return candidates.find((candidate) => existsSync(join(candidate, 'AGENTS.md')));
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function filesUnder(root: string, rel = ''): string[] {
|
|
2245
|
+
const out: string[] = [];
|
|
2246
|
+
for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
|
|
2247
|
+
const path = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
2248
|
+
if (entry.isDirectory()) out.push(...filesUnder(root, path));
|
|
2249
|
+
else out.push(path);
|
|
2250
|
+
}
|
|
2251
|
+
return out.sort();
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/**
|
|
2255
|
+
* `content init-agent [--write]`: install or refresh the engine's agent
|
|
2256
|
+
* procedures in this course — every skill under `.claude/skills/`, the
|
|
2257
|
+
* reviewer under `.claude/agents/` — and, only when the course has no
|
|
2258
|
+
* `AGENTS.md`, the baseline one. An existing `AGENTS.md` is never edited;
|
|
2259
|
+
* when it does not point at the engine's guide, the plan says what to add.
|
|
2260
|
+
*/
|
|
2261
|
+
export function planInitAgent(): EditPlan {
|
|
2262
|
+
const source = courseAgentDir();
|
|
2263
|
+
if (source === undefined) {
|
|
2264
|
+
throw new ContentCliError('unknown-key', 'No docs/course-agent/ beside the engine (node_modules/explico/docs/course-agent).');
|
|
2265
|
+
}
|
|
2266
|
+
const edits: FileEdit[] = [];
|
|
2267
|
+
const summary: string[] = [];
|
|
2268
|
+
const install = (from: string, to: string) => {
|
|
2269
|
+
const after = readFileSync(join(source, from), 'utf8');
|
|
2270
|
+
const target = join(REPOSITORY_ROOT, to);
|
|
2271
|
+
const before = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
|
2272
|
+
if (before === after) return;
|
|
2273
|
+
edits.push({ file: to, before, after });
|
|
2274
|
+
summary.push(`${to}: ${before === null ? 'new' : "differs from the engine's copy"}`);
|
|
2275
|
+
};
|
|
2276
|
+
for (const file of filesUnder(join(source, 'skills'))) install(`skills/${file}`, `.claude/skills/${file}`);
|
|
2277
|
+
for (const file of filesUnder(join(source, 'agents'))) install(`agents/${file}`, `.claude/agents/${file}`);
|
|
2278
|
+
const agents = agentGuideState();
|
|
2279
|
+
if (agents.agentsFile === null) {
|
|
2280
|
+
install('AGENTS.md', 'AGENTS.md');
|
|
2281
|
+
} else if (!agents.linksGuide) {
|
|
2282
|
+
summary.push(
|
|
2283
|
+
`${agents.agentsFile}: left as it is; it does not point at the engine's guide — add a first line such as ` +
|
|
2284
|
+
'"Read node_modules/explico/docs/authoring/agents.md first; this file holds only what is particular to this course." ' +
|
|
2285
|
+
`(the baseline is ${relative(REPOSITORY_ROOT, join(source, 'AGENTS.md'))})`,
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
if (edits.length === 0 && summary.length === 0) summary.push('the agent procedures are installed and current');
|
|
2289
|
+
return { summary, edits, next: ['pnpm content status'] };
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// --- content rule ----------------------------------------------------
|
|
2293
|
+
|
|
2294
|
+
/**
|
|
2295
|
+
* One rule, whole: the catalog entry as this course has it (enforcement,
|
|
2296
|
+
* codes, settings in effect, whether it is on) and the rule page's own
|
|
2297
|
+
* section, example included. About half a thousand tokens where opening the
|
|
2298
|
+
* page costs twenty thousand.
|
|
2299
|
+
*/
|
|
2300
|
+
export interface RuleReport {
|
|
2301
|
+
readonly id: string;
|
|
2302
|
+
readonly status: 'in-effect' | 'retired' | 'unknown';
|
|
2303
|
+
readonly rule?: RuleInEffect;
|
|
2304
|
+
readonly retired?: RetiredRule;
|
|
2305
|
+
/** The rule page's section for this rule, when the page is at hand. */
|
|
2306
|
+
readonly section?: {
|
|
2307
|
+
readonly file: string;
|
|
2308
|
+
readonly markdown: string;
|
|
2309
|
+
readonly tokens: number;
|
|
2310
|
+
};
|
|
2311
|
+
/** For `unknown`: the IDs this course has, so the caller can pick one. */
|
|
2312
|
+
readonly known: readonly string[];
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/**
|
|
2316
|
+
* The engine's `docs/authoring/`: beside the installed package in a course
|
|
2317
|
+
* (`node_modules/explico/docs/authoring`, the path the rules-in-effect page
|
|
2318
|
+
* links), or above this module in the repository and in the package itself.
|
|
2319
|
+
*/
|
|
2320
|
+
export function authoringDocsDir(): string | undefined {
|
|
2321
|
+
const candidates = [join(REPOSITORY_ROOT, 'node_modules', 'explico', 'docs', 'authoring')];
|
|
2322
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
2323
|
+
for (let level = 0; level < 5; level += 1) {
|
|
2324
|
+
candidates.push(join(dir, 'docs', 'authoring'));
|
|
2325
|
+
dir = dirname(dir);
|
|
2326
|
+
}
|
|
2327
|
+
return candidates.find((candidate) => existsSync(join(candidate, 'README.md')));
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
function headingSlug(text: string): string {
|
|
2331
|
+
return text
|
|
2332
|
+
.toLowerCase()
|
|
2333
|
+
.replace(/`/g, '')
|
|
2334
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
2335
|
+
.replace(/^-|-$/g, '');
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
/**
|
|
2339
|
+
* The section of a rule page that `anchor` names: from the heading whose
|
|
2340
|
+
* slug is the anchor (`## N4` → `n4`, `## Reference systems` →
|
|
2341
|
+
* `reference-systems`) to the next heading of the same or a higher level,
|
|
2342
|
+
* trailing rules and blank lines dropped.
|
|
2343
|
+
*/
|
|
2344
|
+
export function ruleSection(page: string, anchor: string): string | undefined {
|
|
2345
|
+
const lines = page.split('\n');
|
|
2346
|
+
const want = anchor.toLowerCase();
|
|
2347
|
+
const start = lines.findIndex((line) => {
|
|
2348
|
+
const match = /^(#{1,6})\s+(.*\S)\s*$/.exec(line);
|
|
2349
|
+
return match !== null && headingSlug(match[2]!) === want;
|
|
2350
|
+
});
|
|
2351
|
+
if (start < 0) return undefined;
|
|
2352
|
+
const level = /^#+/.exec(lines[start]!)![0].length;
|
|
2353
|
+
let end = lines.findIndex((line, index) => {
|
|
2354
|
+
if (index <= start) return false;
|
|
2355
|
+
const match = /^(#{1,6})\s/.exec(line);
|
|
2356
|
+
return match !== null && match[1]!.length <= level;
|
|
2357
|
+
});
|
|
2358
|
+
if (end < 0) end = lines.length;
|
|
2359
|
+
const body = lines.slice(start, end);
|
|
2360
|
+
while (body.length > 0 && /^(---)?\s*$/.test(body[body.length - 1]!)) body.pop();
|
|
2361
|
+
return body.join('\n');
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function ruleSectionFor(rule: RuleInEffect): RuleReport['section'] | undefined {
|
|
2365
|
+
if (rule.source === 'course') return undefined;
|
|
2366
|
+
const dir = authoringDocsDir();
|
|
2367
|
+
if (dir === undefined) return undefined;
|
|
2368
|
+
// `doc` is the link the rules-in-effect page prints (`../node_modules/…/notation.md#n4`);
|
|
2369
|
+
// the pages are flat under docs/authoring, so the basename names the file.
|
|
2370
|
+
const [link, anchor] = rule.doc.split('#');
|
|
2371
|
+
if (!link || !anchor) return undefined;
|
|
2372
|
+
const page = basename(link);
|
|
2373
|
+
const file = join(dir, page);
|
|
2374
|
+
if (!existsSync(file)) return undefined;
|
|
2375
|
+
const markdown = ruleSection(readFileSync(file, 'utf8'), anchor);
|
|
2376
|
+
if (markdown === undefined) return undefined;
|
|
2377
|
+
return { file: join('docs', 'authoring', page), markdown, tokens: estimateTokens(markdown) };
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
export function contentRule(manifest: Manifest, rawId: string): RuleReport {
|
|
2381
|
+
const id = rawId.trim();
|
|
2382
|
+
const inEffect = rulesInEffect(manifest);
|
|
2383
|
+
const rule = inEffect.find((entry) => entry.id.toLowerCase() === id.toLowerCase());
|
|
2384
|
+
if (rule) {
|
|
2385
|
+
const section = ruleSectionFor(rule);
|
|
2386
|
+
return { id: rule.id, status: 'in-effect', rule, ...(section ? { section } : {}), known: [] };
|
|
2387
|
+
}
|
|
2388
|
+
const retired = retiredRule(id.toUpperCase());
|
|
2389
|
+
if (retired) return { id: retired.id, status: 'retired', retired, known: [] };
|
|
2390
|
+
return { id, status: 'unknown', known: inEffect.map((entry) => entry.id) };
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/** The text form of a rule report: the entry's facts, then the page section. */
|
|
2394
|
+
export function formatRuleReport(report: RuleReport): string {
|
|
2395
|
+
if (report.status === 'unknown') {
|
|
2396
|
+
return `No rule ${JSON.stringify(report.id)} in this course. Rules in effect: ${report.known.join(', ')}.\n`;
|
|
2397
|
+
}
|
|
2398
|
+
if (report.status === 'retired') {
|
|
2399
|
+
const r = report.retired!;
|
|
2400
|
+
return (
|
|
2401
|
+
`${r.id} · retired ${r.date}${r.into ? `, folded into ${r.into}` : ''}\n` +
|
|
2402
|
+
` was: ${r.title}\n ${r.why}\n`
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
const rule = report.rule!;
|
|
2406
|
+
const lines = [
|
|
2407
|
+
`${rule.id} · ${rule.title}`,
|
|
2408
|
+
` ${rule.kind} · elements ${rule.elements.join(', ')} · enforcement ${rule.enforcement} · editions ${String(rule.editions)} · ${rule.enabled ? 'on' : 'off (course.settings.json rules)'}${rule.waivable ? ' · waivable with {/* explico-allow ' + rule.id + ': reason */}' : ''}`,
|
|
2409
|
+
` codes: ${rule.codes.length > 0 ? rule.codes.join(', ') : '(none)'}`,
|
|
2410
|
+
];
|
|
2411
|
+
if (Object.keys(rule.settings).length > 0) lines.push(` settings: ${JSON.stringify(rule.settings)}`);
|
|
2412
|
+
lines.push(` summary: ${rule.summary}`);
|
|
2413
|
+
if (report.section) {
|
|
2414
|
+
lines.push(` page: ${report.section.file} (section below, ≈${report.section.tokens} tokens)`, '', report.section.markdown);
|
|
2415
|
+
} else {
|
|
2416
|
+
lines.push(` page: ${rule.doc}${rule.source === 'course' ? ' (the course\'s own link)' : ' (page not found beside the engine)'}`);
|
|
2417
|
+
}
|
|
2418
|
+
return `${lines.join('\n')}\n`;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
export function contentContext(
|
|
2422
|
+
manifest: Manifest,
|
|
2423
|
+
rawId: string,
|
|
2424
|
+
): ContextReport {
|
|
2425
|
+
const lesson = findLesson(manifest, rawId);
|
|
2426
|
+
const { frontmatter, body } = lessonRaw(lesson);
|
|
2427
|
+
|
|
2428
|
+
const outcomeById = new Map(
|
|
2429
|
+
manifest.competencies.map((competency) => [
|
|
2430
|
+
String(competency.id),
|
|
2431
|
+
String(competency.outcome ?? ''),
|
|
2432
|
+
]),
|
|
2433
|
+
);
|
|
2434
|
+
const outcomesFor = (ids: readonly string[]) =>
|
|
2435
|
+
ids
|
|
2436
|
+
.filter((id) => outcomeById.has(id))
|
|
2437
|
+
.map((id) => ({ id, outcome: outcomeById.get(id) ?? '' }));
|
|
2438
|
+
|
|
2439
|
+
const sourceById = new Map(
|
|
2440
|
+
manifest.sources.map((source) => [String(source.id), source]),
|
|
2441
|
+
);
|
|
2442
|
+
const citedLocatorsById = new Map<string, string[]>();
|
|
2443
|
+
for (const citation of lesson.citations) {
|
|
2444
|
+
const list = citedLocatorsById.get(citation.id) ?? [];
|
|
2445
|
+
if (citation.locator) list.push(citation.locator);
|
|
2446
|
+
citedLocatorsById.set(citation.id, list);
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
const contextWithoutTokens = {
|
|
2450
|
+
lesson: {
|
|
2451
|
+
id: lesson.id,
|
|
2452
|
+
slug: lesson.slug,
|
|
2453
|
+
file: lesson.file,
|
|
2454
|
+
title: lesson.title,
|
|
2455
|
+
description: lesson.description,
|
|
2456
|
+
teaches: lesson.teaches,
|
|
2457
|
+
requires: lesson.requires,
|
|
2458
|
+
assumptions: lesson.assumptions,
|
|
2459
|
+
since: lesson.since,
|
|
2460
|
+
notationLocal:
|
|
2461
|
+
(frontmatter.notation as { local?: unknown } | undefined)?.local ?? [],
|
|
2462
|
+
body,
|
|
2463
|
+
},
|
|
2464
|
+
taughtOutcomes: outcomesFor(lesson.teaches),
|
|
2465
|
+
prerequisiteOutcomes: outcomesFor(lesson.requires),
|
|
2466
|
+
notation: lesson.notation.definitions.map((definition) => ({
|
|
2467
|
+
key: definition.key,
|
|
2468
|
+
latex: definition.notation,
|
|
2469
|
+
meaning: definition.summary,
|
|
2470
|
+
scope: definition.scope,
|
|
2471
|
+
...(definition.units === undefined ? {} : { units: definition.units }),
|
|
2472
|
+
...(definition.formula === undefined
|
|
2473
|
+
? {}
|
|
2474
|
+
: { formula: definition.formula }),
|
|
2475
|
+
})),
|
|
2476
|
+
equations: lesson.equations.map((equation) => ({ ...equation })),
|
|
2477
|
+
tables: lesson.tables.map((table) => ({ ...table })),
|
|
2478
|
+
figures: lesson.figures.map((figure) => ({ ...figure })),
|
|
2479
|
+
diagrams: lesson.diagrams.map((diagram) => ({ ...diagram })),
|
|
2480
|
+
examples: lesson.examples.map((example) => ({ ...example })),
|
|
2481
|
+
knowledgeChecks: lesson.knowledgeChecks.map((check) => ({ ...check })),
|
|
2482
|
+
sections: lesson.headings.map((heading) => ({ ...heading })),
|
|
2483
|
+
sources: lesson.sources.map((id) => {
|
|
2484
|
+
const record = sourceById.get(id) as Record<string, unknown> | undefined;
|
|
2485
|
+
return {
|
|
2486
|
+
id,
|
|
2487
|
+
title: String(record?.title ?? id),
|
|
2488
|
+
type: String(record?.type ?? 'unknown'),
|
|
2489
|
+
...(typeof record?.locator === 'string'
|
|
2490
|
+
? { recordLocator: record.locator }
|
|
2491
|
+
: {}),
|
|
2492
|
+
citedLocators: citedLocatorsById.get(id) ?? [],
|
|
2493
|
+
};
|
|
2494
|
+
}),
|
|
2495
|
+
domainSignatures: domainSignaturesForSection(
|
|
2496
|
+
lesson.slug.split('/')[0] ?? '',
|
|
2497
|
+
),
|
|
2498
|
+
diagnostics: lessonDiagnostics(manifest, lesson),
|
|
2499
|
+
};
|
|
2500
|
+
|
|
2501
|
+
return {
|
|
2502
|
+
...contextWithoutTokens,
|
|
2503
|
+
tokens: tokenCounts(contextWithoutTokens),
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// --- content symbol --------------------------------------------------
|
|
2508
|
+
|
|
2509
|
+
/*
|
|
2510
|
+
* Where a symbol is already defined, asked before an author declares it
|
|
2511
|
+
* again. Reads the registry the manifest holds: shared cards, page-local
|
|
2512
|
+
* entries (`notation.local`) and glosses (a card's `glosses:`), matched on
|
|
2513
|
+
* the latex as written, a shared card's `aliases`, or the key. A `contains`
|
|
2514
|
+
* list follows for a letter that sits inside a longer notation (`v` in
|
|
2515
|
+
* `v_0`), so a near miss is visible too. Read-only; every list is sorted,
|
|
2516
|
+
* so two runs print the same report.
|
|
2517
|
+
*/
|
|
2518
|
+
/** Where a hit is defined: a shared card, a lesson's `notation.local`, or a card's gloss. */
|
|
2519
|
+
export type SymbolTier = 'shared' | 'local' | 'gloss';
|
|
2520
|
+
|
|
2521
|
+
export interface SymbolHit {
|
|
2522
|
+
readonly tier: SymbolTier;
|
|
2523
|
+
readonly key: string;
|
|
2524
|
+
readonly latex: string;
|
|
2525
|
+
readonly label: string;
|
|
2526
|
+
readonly meaning?: string;
|
|
2527
|
+
readonly units?: string;
|
|
2528
|
+
/** How the query matched: the latex as written, an alias, or the key. */
|
|
2529
|
+
readonly matchedBy: 'latex' | 'alias' | 'key';
|
|
2530
|
+
/** shared: the entry file; local: the lesson id; gloss: the owning card. */
|
|
2531
|
+
readonly where: string;
|
|
2532
|
+
/** shared only: the pages (lessons and overview pages) whose bundle binds the card. */
|
|
2533
|
+
readonly boundIn?: readonly string[];
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
export interface SymbolReport {
|
|
2537
|
+
readonly query: string;
|
|
2538
|
+
readonly shared: readonly SymbolHit[];
|
|
2539
|
+
readonly local: readonly SymbolHit[];
|
|
2540
|
+
readonly glosses: readonly SymbolHit[];
|
|
2541
|
+
/** Entries whose latex contains the query as a glyph, not matched above. */
|
|
2542
|
+
readonly contains: readonly SymbolHit[];
|
|
2543
|
+
/** How many `contains` candidates there were before the list was capped. */
|
|
2544
|
+
readonly containsTotal: number;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
const SYMBOL_CONTAINS_CAP = 12;
|
|
2548
|
+
|
|
2549
|
+
type DefinitionRecord = Manifest['notation']['definitions'][number];
|
|
2550
|
+
|
|
2551
|
+
function byKeyThenWhere(left: SymbolHit, right: SymbolHit): number {
|
|
2552
|
+
return left.key.localeCompare(right.key) || left.where.localeCompare(right.where);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
export function contentSymbol(manifest: Manifest, rawQuery: string): SymbolReport {
|
|
2556
|
+
const query = rawQuery.trim();
|
|
2557
|
+
const boundIn = new Map<string, string[]>();
|
|
2558
|
+
for (const bundle of manifest.notation.bundles) {
|
|
2559
|
+
for (const binding of bundle.bindings) {
|
|
2560
|
+
const list = boundIn.get(binding.definitionId) ?? [];
|
|
2561
|
+
if (!list.includes(bundle.lessonId)) list.push(bundle.lessonId);
|
|
2562
|
+
boundIn.set(binding.definitionId, list);
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
// Every candidate, each with the way it would match, so the exact pass and
|
|
2567
|
+
// the `contains` pass read one list.
|
|
2568
|
+
interface Candidate {
|
|
2569
|
+
readonly hit: Omit<SymbolHit, 'matchedBy'>;
|
|
2570
|
+
readonly aliases: readonly string[];
|
|
2571
|
+
}
|
|
2572
|
+
const candidates: Candidate[] = [];
|
|
2573
|
+
const push = (definition: DefinitionRecord): void => {
|
|
2574
|
+
if (definition.kind === 'shared') {
|
|
2575
|
+
candidates.push({
|
|
2576
|
+
hit: {
|
|
2577
|
+
tier: 'shared',
|
|
2578
|
+
key: definition.key,
|
|
2579
|
+
latex: definition.notation,
|
|
2580
|
+
label: definition.label,
|
|
2581
|
+
meaning: definition.summary,
|
|
2582
|
+
...(definition.units === undefined ? {} : { units: definition.units }),
|
|
2583
|
+
where: definition.source.file,
|
|
2584
|
+
boundIn: [...(boundIn.get(definition.id) ?? [])].sort(),
|
|
2585
|
+
},
|
|
2586
|
+
aliases: definition.aliases,
|
|
2587
|
+
});
|
|
2588
|
+
for (const gloss of definition.glosses) {
|
|
2589
|
+
candidates.push({
|
|
2590
|
+
hit: {
|
|
2591
|
+
tier: 'gloss',
|
|
2592
|
+
key: gloss.key,
|
|
2593
|
+
latex: gloss.notation,
|
|
2594
|
+
label: gloss.label,
|
|
2595
|
+
...(gloss.units === undefined ? {} : { units: gloss.units }),
|
|
2596
|
+
where: gloss.ownerKey,
|
|
2597
|
+
},
|
|
2598
|
+
aliases: [],
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
candidates.push({
|
|
2604
|
+
hit: {
|
|
2605
|
+
tier: 'local',
|
|
2606
|
+
key: definition.key,
|
|
2607
|
+
latex: definition.notation,
|
|
2608
|
+
label: definition.label,
|
|
2609
|
+
meaning: definition.summary,
|
|
2610
|
+
...(definition.units === undefined ? {} : { units: definition.units }),
|
|
2611
|
+
where: definition.lessonId,
|
|
2612
|
+
},
|
|
2613
|
+
aliases: [],
|
|
2614
|
+
});
|
|
2615
|
+
};
|
|
2616
|
+
for (const definition of manifest.notation.definitions) push(definition);
|
|
2617
|
+
|
|
2618
|
+
const exact: SymbolHit[] = [];
|
|
2619
|
+
const rest: Candidate[] = [];
|
|
2620
|
+
for (const candidate of candidates) {
|
|
2621
|
+
const { hit, aliases } = candidate;
|
|
2622
|
+
const matchedBy =
|
|
2623
|
+
hit.latex.trim() === query
|
|
2624
|
+
? 'latex'
|
|
2625
|
+
: aliases.some((alias) => alias.trim() === query)
|
|
2626
|
+
? 'alias'
|
|
2627
|
+
: hit.key === query
|
|
2628
|
+
? 'key'
|
|
2629
|
+
: undefined;
|
|
2630
|
+
if (matchedBy) exact.push({ ...hit, matchedBy });
|
|
2631
|
+
else rest.push(candidate);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
const matcher = glyphMatcher(query);
|
|
2635
|
+
const containsAll = matcher
|
|
2636
|
+
? rest
|
|
2637
|
+
.filter(({ hit }) => matcher.test(hit.latex))
|
|
2638
|
+
.map(({ hit }): SymbolHit => ({ ...hit, matchedBy: 'latex' }))
|
|
2639
|
+
.sort(byKeyThenWhere)
|
|
2640
|
+
: [];
|
|
2641
|
+
|
|
2642
|
+
const tier = (name: SymbolTier) =>
|
|
2643
|
+
exact.filter((hit) => hit.tier === name).sort(byKeyThenWhere);
|
|
2644
|
+
return {
|
|
2645
|
+
query,
|
|
2646
|
+
shared: tier('shared'),
|
|
2647
|
+
local: tier('local'),
|
|
2648
|
+
glosses: tier('gloss'),
|
|
2649
|
+
contains: containsAll.slice(0, SYMBOL_CONTAINS_CAP),
|
|
2650
|
+
containsTotal: containsAll.length,
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
function firstSentence(text: string, limit = 140): string {
|
|
2655
|
+
const sentence = text.trim().split(/(?<=[.!?])\s/)[0] ?? '';
|
|
2656
|
+
return sentence.length > limit ? `${sentence.slice(0, limit - 1)}…` : sentence;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
function symbolHitLine(hit: SymbolHit): string {
|
|
2660
|
+
const units = hit.units === undefined ? '' : ` (${hit.units})`;
|
|
2661
|
+
const where =
|
|
2662
|
+
hit.tier === 'shared'
|
|
2663
|
+
? `${hit.where}; bound on ${hit.boundIn?.length ?? 0} page(s)` +
|
|
2664
|
+
(hit.boundIn && hit.boundIn.length > 0 ? `: ${hit.boundIn.join(', ')}` : '')
|
|
2665
|
+
: hit.tier === 'local'
|
|
2666
|
+
? `lesson ${hit.where}`
|
|
2667
|
+
: `gloss on ${hit.where}`;
|
|
2668
|
+
const via = hit.matchedBy === 'latex' ? '' : ` [matched by ${hit.matchedBy}]`;
|
|
2669
|
+
return `${hit.key} — ${hit.label}${units} — ${where}${via}`;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
/**
|
|
2673
|
+
* The text form of a symbol report: one block per tier, then the near
|
|
2674
|
+
* misses, then one line that counts what was found, worded for the next
|
|
2675
|
+
* step an author takes.
|
|
2676
|
+
*/
|
|
2677
|
+
export function formatSymbolReport(report: SymbolReport): string {
|
|
2678
|
+
const lines: string[] = [report.query];
|
|
2679
|
+
const section = (title: string, hits: readonly SymbolHit[]): void => {
|
|
2680
|
+
if (hits.length === 0) {
|
|
2681
|
+
lines.push(` ${title.padEnd(7)} (none)`);
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
for (const [index, hit] of hits.entries()) {
|
|
2685
|
+
lines.push(` ${(index === 0 ? title : '').padEnd(7)} ${symbolHitLine(hit)}`);
|
|
2686
|
+
if (hit.meaning) lines.push(` ${firstSentence(hit.meaning)}`);
|
|
2687
|
+
}
|
|
2688
|
+
};
|
|
2689
|
+
section('shared', report.shared);
|
|
2690
|
+
section('local', report.local);
|
|
2691
|
+
section('gloss', report.glosses);
|
|
2692
|
+
if (report.contains.length > 0) {
|
|
2693
|
+
for (const [index, hit] of report.contains.entries()) {
|
|
2694
|
+
lines.push(
|
|
2695
|
+
` ${(index === 0 ? 'also' : '').padEnd(7)} ${hit.latex} — ${symbolHitLine(hit)}`,
|
|
2696
|
+
);
|
|
2697
|
+
}
|
|
2698
|
+
if (report.containsTotal > report.contains.length) {
|
|
2699
|
+
lines.push(
|
|
2700
|
+
` … and ${report.containsTotal - report.contains.length} more; --json lists them all`,
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
const pageMeanings = report.local.length + report.glosses.length;
|
|
2706
|
+
if (report.shared.length > 0) {
|
|
2707
|
+
const bound = report.shared.reduce((n, hit) => n + (hit.boundIn?.length ?? 0), 0);
|
|
2708
|
+
lines.push(
|
|
2709
|
+
`${report.shared.length} shared card(s), bound on ${bound} page(s). Reference it with [[key]] before the first math use.`,
|
|
2710
|
+
);
|
|
2711
|
+
} else if (pageMeanings > 0) {
|
|
2712
|
+
lines.push(
|
|
2713
|
+
`No shared card. It is a page meaning ${pageMeanings} time(s): ${report.local.length} local, ${report.glosses.length} gloss. A second page that needs the same meaning wants a shared card.`,
|
|
2714
|
+
);
|
|
2715
|
+
} else {
|
|
2716
|
+
lines.push(
|
|
2717
|
+
`Not defined anywhere in this course${report.containsTotal > 0 ? ' (near misses above)' : ''}.`,
|
|
2718
|
+
);
|
|
2719
|
+
}
|
|
2720
|
+
return `${lines.join('\n')}\n`;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
// --- content check ---------------------------------------------------
|
|
2724
|
+
|
|
2725
|
+
/**
|
|
2726
|
+
* A page `content check` checks: a lesson's (its chapter, or a slides-first
|
|
2727
|
+
* lesson's deck), or a deck beside its chapter or a part's, by its page id.
|
|
2728
|
+
*/
|
|
2729
|
+
export type CheckTarget = Pick<ManifestLesson, 'id' | 'file' | 'assessments'> & {
|
|
2730
|
+
readonly notation: Pick<ManifestLesson['notation'], 'definitionIds'>;
|
|
2731
|
+
};
|
|
2732
|
+
|
|
2733
|
+
/**
|
|
2734
|
+
* The lesson `rawId` names, or the deck whose page it names: a deck beside
|
|
2735
|
+
* its chapter by its route (`motion/range/slides`), a part's deck by its own
|
|
2736
|
+
* (`motion/slides`).
|
|
2737
|
+
*/
|
|
2738
|
+
export function findCheckTarget(manifest: Manifest, rawId: string): CheckTarget {
|
|
2739
|
+
const id = normalizeLessonId(rawId);
|
|
2740
|
+
const lesson = manifest.lessons.find((entry) => entry.id === id);
|
|
2741
|
+
if (lesson) return lesson;
|
|
2742
|
+
const deck = (manifest.decks ?? []).find((entry) => entry.pageId === id);
|
|
2743
|
+
if (deck) return { id: deck.pageId, file: deck.file, assessments: [], notation: deck.notation };
|
|
2744
|
+
throw new ContentCliError(
|
|
2745
|
+
'unknown-lesson',
|
|
2746
|
+
`No lesson or deck ${JSON.stringify(id)}. Known ids: ${[
|
|
2747
|
+
...manifest.lessons.map((entry) => entry.id),
|
|
2748
|
+
...(manifest.decks ?? []).filter((entry) => entry.chapter !== undefined || entry.part !== undefined).map((entry) => entry.pageId),
|
|
2749
|
+
].join(', ')}`,
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
export interface CheckLessonResult {
|
|
2754
|
+
readonly lesson: string;
|
|
2755
|
+
readonly file: string;
|
|
2756
|
+
readonly ok: boolean;
|
|
2757
|
+
readonly rendered: boolean;
|
|
2758
|
+
readonly diagnostics: readonly CliDiagnostic[];
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
export interface CheckReport {
|
|
2762
|
+
readonly ok: boolean;
|
|
2763
|
+
readonly lessons: readonly CheckLessonResult[];
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
async function checkOneLesson(
|
|
2767
|
+
manifest: Manifest,
|
|
2768
|
+
lesson: CheckTarget,
|
|
2769
|
+
): Promise<CheckLessonResult> {
|
|
2770
|
+
const manifestDiagnostics = lessonDiagnostics(manifest, lesson);
|
|
2771
|
+
const { frontmatter, body } = lessonSource(
|
|
2772
|
+
readFileSync(lessonAbsolutePath(lesson), 'utf8'),
|
|
2773
|
+
);
|
|
2774
|
+
const render = await renderLesson(manifest, {
|
|
2775
|
+
body,
|
|
2776
|
+
frontmatter,
|
|
2777
|
+
path: lessonAbsolutePath(lesson),
|
|
2778
|
+
});
|
|
2779
|
+
|
|
2780
|
+
const diagnostics = [...manifestDiagnostics, ...render.diagnostics].sort(
|
|
2781
|
+
(a, b) =>
|
|
2782
|
+
a.code.localeCompare(b.code) || a.message.localeCompare(b.message),
|
|
2783
|
+
);
|
|
2784
|
+
return {
|
|
2785
|
+
lesson: lesson.id,
|
|
2786
|
+
file: lesson.file,
|
|
2787
|
+
ok: !diagnostics.some((diagnostic) => diagnostic.severity === 'error'),
|
|
2788
|
+
rendered: render.rendered,
|
|
2789
|
+
diagnostics,
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
/** `.mdx` / `.checks.yml` paths that differ from `HEAD`, tracked or not. */
|
|
2794
|
+
export function changedLessonIds(): string[] {
|
|
2795
|
+
let output: string;
|
|
2796
|
+
try {
|
|
2797
|
+
const tracked = execFileSync(
|
|
2798
|
+
'git',
|
|
2799
|
+
['diff', '--name-only', 'HEAD', '--', 'content/docs'],
|
|
2800
|
+
{ cwd: REPOSITORY_ROOT, encoding: 'utf8' },
|
|
2801
|
+
);
|
|
2802
|
+
const untracked = execFileSync(
|
|
2803
|
+
'git',
|
|
2804
|
+
['ls-files', '--others', '--exclude-standard', '--', 'content/docs'],
|
|
2805
|
+
{ cwd: REPOSITORY_ROOT, encoding: 'utf8' },
|
|
2806
|
+
);
|
|
2807
|
+
output = `${tracked}\n${untracked}`;
|
|
2808
|
+
} catch {
|
|
2809
|
+
throw new ContentCliError(
|
|
2810
|
+
'no-git',
|
|
2811
|
+
'Could not read git status for --changed. Run inside a git working tree.',
|
|
2812
|
+
);
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
const ids = new Set<string>();
|
|
2816
|
+
for (const line of output.split('\n')) {
|
|
2817
|
+
const path = line.trim();
|
|
2818
|
+
if (!path) continue;
|
|
2819
|
+
const slug = path
|
|
2820
|
+
.replace(/^content\/docs\//, '')
|
|
2821
|
+
.replace(/\.checks\.yml$/, '.mdx')
|
|
2822
|
+
.replace(/\.mdx?$/, '');
|
|
2823
|
+
if (!isLessonSlug(slug)) continue;
|
|
2824
|
+
ids.add(slug.replace(/\//g, '.'));
|
|
2825
|
+
}
|
|
2826
|
+
return [...ids].sort();
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
export async function contentCheck(
|
|
2830
|
+
manifest: Manifest,
|
|
2831
|
+
options: {
|
|
2832
|
+
lesson?: string;
|
|
2833
|
+
/** Several lessons, each checked; `lesson` is the one-lesson form. */
|
|
2834
|
+
lessons?: readonly string[];
|
|
2835
|
+
changed?: boolean;
|
|
2836
|
+
},
|
|
2837
|
+
): Promise<CheckReport> {
|
|
2838
|
+
let targets: CheckTarget[];
|
|
1164
2839
|
const named = [...(options.lesson ? [options.lesson] : []), ...(options.lessons ?? [])];
|
|
1165
2840
|
if (options.changed) {
|
|
1166
2841
|
const ids = changedLessonIds();
|
|
@@ -1172,7 +2847,7 @@ export async function contentCheck(
|
|
|
1172
2847
|
'content check needs a <lesson> id or --changed.',
|
|
1173
2848
|
);
|
|
1174
2849
|
}
|
|
1175
|
-
targets = named.map((id) =>
|
|
2850
|
+
targets = named.map((id) => findCheckTarget(manifest, id));
|
|
1176
2851
|
}
|
|
1177
2852
|
|
|
1178
2853
|
const lessons = await Promise.all(
|
|
@@ -1265,6 +2940,280 @@ After this lesson, you should be able to:
|
|
|
1265
2940
|
};
|
|
1266
2941
|
}
|
|
1267
2942
|
|
|
2943
|
+
/** One heading of a chapter, as a deck draft starts from it. */
|
|
2944
|
+
interface OutlineDraft {
|
|
2945
|
+
level: 2 | 3;
|
|
2946
|
+
title: string;
|
|
2947
|
+
/** The numbered floats under the heading, as `<Recall ref>` names them. */
|
|
2948
|
+
recalls: string[];
|
|
2949
|
+
/** The first sentence of its first paragraph: the frame's notes. */
|
|
2950
|
+
note: string;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
/**
|
|
2954
|
+
* A chapter's `##` and `###` headings, each with the numbered equations,
|
|
2955
|
+
* figures, tables and diagrams under it and its first sentence: what
|
|
2956
|
+
* `content new deck` drafts frames from.
|
|
2957
|
+
*/
|
|
2958
|
+
function chapterOutline(content: string): OutlineDraft[] {
|
|
2959
|
+
const lines = content.split('\n');
|
|
2960
|
+
const drafts: OutlineDraft[] = [];
|
|
2961
|
+
let fence = false;
|
|
2962
|
+
let paragraph: string[] = [];
|
|
2963
|
+
let pendingTag: string | undefined;
|
|
2964
|
+
const flushParagraph = () => {
|
|
2965
|
+
const current = drafts.at(-1);
|
|
2966
|
+
if (current && current.note === '' && paragraph.length > 0) {
|
|
2967
|
+
const text = paragraph.join(' ');
|
|
2968
|
+
if (!/^\s*[<{:|]/.test(text)) current.note = firstSentence(text);
|
|
2969
|
+
}
|
|
2970
|
+
paragraph = [];
|
|
2971
|
+
};
|
|
2972
|
+
for (const line of lines) {
|
|
2973
|
+
if (/^\s*(```|~~~)/.test(line)) fence = !fence;
|
|
2974
|
+
if (fence) continue;
|
|
2975
|
+
const heading = /^(#{2,3})[ \t]+(.+?)[ \t]*$/.exec(line);
|
|
2976
|
+
if (heading) {
|
|
2977
|
+
flushParagraph();
|
|
2978
|
+
drafts.push({
|
|
2979
|
+
level: heading[1]!.length as 2 | 3,
|
|
2980
|
+
title: heading[2]!.replace(/(?:\s*\[[a-z=-]+\])+\s*$/i, ''),
|
|
2981
|
+
recalls: [],
|
|
2982
|
+
note: '',
|
|
2983
|
+
});
|
|
2984
|
+
continue;
|
|
2985
|
+
}
|
|
2986
|
+
const current = drafts.at(-1);
|
|
2987
|
+
for (const match of line.matchAll(/\\label\{eq:([a-z0-9-]+)\}/g)) current?.recalls.push(`eq-${match[1]}`);
|
|
2988
|
+
// A float's tag may run over several lines: it counts where it opens.
|
|
2989
|
+
if (/<(Figure|Table|Diagram)\b/.test(line)) pendingTag = line;
|
|
2990
|
+
else if (pendingTag !== undefined) pendingTag += ` ${line}`;
|
|
2991
|
+
if (pendingTag !== undefined && pendingTag.includes('>')) {
|
|
2992
|
+
const tag = /<(Figure|Table|Diagram)\b[^>]*?\bid=["']([a-z0-9-]+)["']/.exec(pendingTag);
|
|
2993
|
+
if (tag) {
|
|
2994
|
+
const prefix = tag[1] === 'Figure' ? 'fig' : tag[1] === 'Table' ? 'tab' : 'dia';
|
|
2995
|
+
current?.recalls.push(`${prefix}-${tag[2]}`);
|
|
2996
|
+
}
|
|
2997
|
+
pendingTag = undefined;
|
|
2998
|
+
}
|
|
2999
|
+
if (line.trim() === '') flushParagraph();
|
|
3000
|
+
else if (!/^\s*(import|export)\s/.test(line) && !/^\s*[-*$<{|>]/.test(line)) paragraph.push(line.trim());
|
|
3001
|
+
}
|
|
3002
|
+
flushParagraph();
|
|
3003
|
+
return drafts;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
/** A draft frame: its title, a `<Recall>` per float, and its notes. */
|
|
3007
|
+
function draftFrame(out: string[], draft: Pick<OutlineDraft, 'title' | 'recalls' | 'note'>): void {
|
|
3008
|
+
out.push(`### ${draft.title}`, '');
|
|
3009
|
+
for (const ref of draft.recalls) out.push(`<Recall ref="${ref}" />`, '');
|
|
3010
|
+
out.push('<Notes>', draft.note || 'What you say on this frame.', '</Notes>', '');
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
/**
|
|
3014
|
+
* `content new deck <lesson>`: the slide edition of a lesson, as a draft to
|
|
3015
|
+
* rewrite (`docs/authoring/deck.md`). Beside an existing chapter it starts
|
|
3016
|
+
* from the chapter: a `##` section per chapter section, a `###` frame per
|
|
3017
|
+
* subsection (or one for a section without any), a `<Recall>` for each
|
|
3018
|
+
* numbered equation, figure, table and diagram there, and the section's first
|
|
3019
|
+
* sentence in the frame's notes. Without a chapter it is a slides-first
|
|
3020
|
+
* lesson, with the lesson's frontmatter and a skeleton frame.
|
|
3021
|
+
*
|
|
3022
|
+
* `content new deck <part>`, for a part's directory with its page
|
|
3023
|
+
* (`index.mdx`), drafts the part's deck (`<part>/index.deck.mdx`): a
|
|
3024
|
+
* `## <chapter title> [chapter: <name>]` section per chapter, in the order
|
|
3025
|
+
* the tracks read them, and a frame per section of the chapter, with a
|
|
3026
|
+
* `<Recall>` for each float it numbers and its first sentence in the notes.
|
|
3027
|
+
*/
|
|
3028
|
+
export function scaffoldDeck(rawId: string): Scaffold {
|
|
3029
|
+
const slug = rawId
|
|
3030
|
+
.trim()
|
|
3031
|
+
.replace(/(?:\.deck)?\.mdx?$/, '')
|
|
3032
|
+
.replace(/\./g, '/')
|
|
3033
|
+
.replace(/\/(?:index)?$/, '');
|
|
3034
|
+
if (!slug.includes('/') && ID_PATTERN.test(slug) && partPagePath(slug) !== undefined) {
|
|
3035
|
+
return scaffoldPartDeck(slug);
|
|
3036
|
+
}
|
|
3037
|
+
if (!slug.includes('/') || !isLessonSlug(slug) || slug.split('/').some((part) => !ID_PATTERN.test(part))) {
|
|
3038
|
+
throw new ContentCliError(
|
|
3039
|
+
'invalid-id',
|
|
3040
|
+
`A deck is named by its lesson, <section>/<slug> (or <section>.<slug>), or by a part's directory ` +
|
|
3041
|
+
`with its page (content/docs/<part>/index.mdx), got ${JSON.stringify(rawId)}.`,
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
3044
|
+
const relPath = `content/docs/${slug}.deck.mdx`;
|
|
3045
|
+
if (existsSync(join(REPOSITORY_ROOT, relPath))) {
|
|
3046
|
+
throw new ContentCliError('exists', `${relPath} already exists; not overwriting.`);
|
|
3047
|
+
}
|
|
3048
|
+
const chapterPath = ['.mdx', '.md']
|
|
3049
|
+
.map((extension) => `content/docs/${slug}${extension}`)
|
|
3050
|
+
.find((path) => existsSync(join(REPOSITORY_ROOT, path)));
|
|
3051
|
+
const titleCase = (slug.split('/').at(-1) ?? slug)
|
|
3052
|
+
.split('-')
|
|
3053
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
3054
|
+
.join(' ');
|
|
3055
|
+
|
|
3056
|
+
if (chapterPath === undefined) {
|
|
3057
|
+
return {
|
|
3058
|
+
path: relPath,
|
|
3059
|
+
contents: `---
|
|
3060
|
+
title: ${titleCase}
|
|
3061
|
+
description: One sentence on what the student can do after this lesson.
|
|
3062
|
+
teaches: []
|
|
3063
|
+
assumptions: []
|
|
3064
|
+
---
|
|
3065
|
+
|
|
3066
|
+
{/* A slides-first lesson: this deck is the lesson. ## opens a section, ### is a frame
|
|
3067
|
+
whose title is the claim it shows (rule D3); the title frame is generated. */}
|
|
3068
|
+
|
|
3069
|
+
## The question
|
|
3070
|
+
|
|
3071
|
+
### The claim this frame shows
|
|
3072
|
+
|
|
3073
|
+
- One line per point the frame makes
|
|
3074
|
+
|
|
3075
|
+
<Notes>
|
|
3076
|
+
What you say on this frame: the reasoning, the transition, the question to ask.
|
|
3077
|
+
</Notes>
|
|
3078
|
+
`,
|
|
3079
|
+
companions: [
|
|
3080
|
+
{
|
|
3081
|
+
path: `content/docs/${slug}.checks.yml`,
|
|
3082
|
+
contents: `# Assessment sets for this lesson. Ids resolve against content/assessments/.\n[]\n`,
|
|
3083
|
+
},
|
|
3084
|
+
],
|
|
3085
|
+
};
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
const raw = readFileSync(join(REPOSITORY_ROOT, chapterPath), 'utf8');
|
|
3089
|
+
const { frontmatter, content } = parseFrontmatter(raw) as {
|
|
3090
|
+
frontmatter?: Record<string, unknown>;
|
|
3091
|
+
content: string;
|
|
3092
|
+
};
|
|
3093
|
+
const data = frontmatter ?? {};
|
|
3094
|
+
const title = typeof data.title === 'string' ? data.title : titleCase;
|
|
3095
|
+
const description = typeof data.description === 'string' ? data.description : '';
|
|
3096
|
+
// The chapter's outline, with every numbered float under the heading it
|
|
3097
|
+
// follows; the frame draft is one per subsection, or per section without one.
|
|
3098
|
+
const drafts = chapterOutline(content);
|
|
3099
|
+
|
|
3100
|
+
const out: string[] = [
|
|
3101
|
+
'---',
|
|
3102
|
+
`title: ${JSON.stringify(title)}`,
|
|
3103
|
+
...(description ? [`description: ${JSON.stringify(description)}`] : []),
|
|
3104
|
+
'---',
|
|
3105
|
+
'',
|
|
3106
|
+
`{/* Drafted by \`content new deck\` from ${chapterPath}. Rewrite each frame title as the claim it`,
|
|
3107
|
+
' shows (rule D3), keep to what fits the frame (rule D2), and put what you say in its Notes',
|
|
3108
|
+
' (rule D4). Each Recall shows the chapter\'s own float, with its number (rule D8). */}',
|
|
3109
|
+
'',
|
|
3110
|
+
];
|
|
3111
|
+
const frame = (draft: OutlineDraft) => draftFrame(out, draft);
|
|
3112
|
+
drafts.forEach((draft, index) => {
|
|
3113
|
+
if (draft.level === 2) {
|
|
3114
|
+
out.push(`## ${draft.title}`, '');
|
|
3115
|
+
// A section with no subsection is drafted as one frame of its own.
|
|
3116
|
+
if (drafts[index + 1]?.level !== 3 || draft.recalls.length > 0) frame(draft);
|
|
3117
|
+
} else {
|
|
3118
|
+
frame(draft);
|
|
3119
|
+
}
|
|
3120
|
+
});
|
|
3121
|
+
if (drafts.length === 0) {
|
|
3122
|
+
out.push('## The question', '', '### The claim this frame shows', '', '<Notes>', 'What you say on this frame.', '</Notes>', '');
|
|
3123
|
+
}
|
|
3124
|
+
return { path: relPath, contents: `${out.join('\n').trimEnd()}\n`, companions: [] };
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
/** A part's page, `content/docs/<dir>/index.mdx` (or `.md`), repository-relative, if it has one. */
|
|
3128
|
+
function partPagePath(dir: string): string | undefined {
|
|
3129
|
+
return ['.mdx', '.md']
|
|
3130
|
+
.map((extension) => `content/docs/${dir}/index${extension}`)
|
|
3131
|
+
.find((path) => existsSync(join(REPOSITORY_ROOT, path)));
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
/** The lessons in the order the course's tracks read them (`content/tracks/*.json`, merged). */
|
|
3135
|
+
function trackOrder(): string[] {
|
|
3136
|
+
const directory = join(CONTENT_ROOT, 'tracks');
|
|
3137
|
+
if (!existsSync(directory)) return [];
|
|
3138
|
+
const tracks = readdirSync(directory)
|
|
3139
|
+
.filter((name) => name.endsWith('.json'))
|
|
3140
|
+
.sort()
|
|
3141
|
+
.flatMap((name) => {
|
|
3142
|
+
try {
|
|
3143
|
+
const data = JSON.parse(readFileSync(join(directory, name), 'utf8')) as { id?: unknown; lessons?: unknown };
|
|
3144
|
+
return typeof data.id === 'string' && Array.isArray(data.lessons)
|
|
3145
|
+
? [{ id: data.id, lessons: data.lessons.filter((lesson): lesson is string => typeof lesson === 'string') }]
|
|
3146
|
+
: [];
|
|
3147
|
+
} catch {
|
|
3148
|
+
return [];
|
|
3149
|
+
}
|
|
3150
|
+
});
|
|
3151
|
+
return mergeTrackOrder(tracks);
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
/** `content new deck <part>`: the part's deck, drafted from its chapters (see {@link scaffoldDeck}). */
|
|
3155
|
+
function scaffoldPartDeck(dir: string): Scaffold {
|
|
3156
|
+
const relPath = `content/docs/${dir}/index.deck.mdx`;
|
|
3157
|
+
if (existsSync(join(REPOSITORY_ROOT, relPath))) {
|
|
3158
|
+
throw new ContentCliError('exists', `${relPath} already exists; not overwriting.`);
|
|
3159
|
+
}
|
|
3160
|
+
const pagePath = partPagePath(dir)!;
|
|
3161
|
+
const page = (parseFrontmatter(readFileSync(join(REPOSITORY_ROOT, pagePath), 'utf8')).frontmatter ?? {}) as Record<
|
|
3162
|
+
string,
|
|
3163
|
+
unknown
|
|
3164
|
+
>;
|
|
3165
|
+
const title = typeof page.title === 'string' ? page.title : dir;
|
|
3166
|
+
const description = typeof page.description === 'string' ? page.description : '';
|
|
3167
|
+
// The part's chapters (not its slides-first lessons), as the tracks read them.
|
|
3168
|
+
const rank = new Map(trackOrder().map((lessonId, index) => [lessonId, index]));
|
|
3169
|
+
const chapters = readdirSync(join(DOCS_ROOT, dir))
|
|
3170
|
+
.filter((name) => /\.mdx?$/.test(name) && !/\.deck\.mdx?$/.test(name) && !/^index\.mdx?$/.test(name))
|
|
3171
|
+
.map((name) => ({ name: name.replace(/\.mdx?$/, ''), path: `content/docs/${dir}/${name}` }))
|
|
3172
|
+
.sort(
|
|
3173
|
+
(left, right) =>
|
|
3174
|
+
(rank.get(`${dir}.${left.name}`) ?? Number.POSITIVE_INFINITY) -
|
|
3175
|
+
(rank.get(`${dir}.${right.name}`) ?? Number.POSITIVE_INFINITY) || left.name.localeCompare(right.name),
|
|
3176
|
+
);
|
|
3177
|
+
|
|
3178
|
+
const out: string[] = [
|
|
3179
|
+
'---',
|
|
3180
|
+
`title: ${JSON.stringify(title)}`,
|
|
3181
|
+
...(description ? [`description: ${JSON.stringify(description)}`] : []),
|
|
3182
|
+
'---',
|
|
3183
|
+
'',
|
|
3184
|
+
`{/* Drafted by \`content new deck\` from the chapters of ${dir}. A section per chapter names it`,
|
|
3185
|
+
' with [chapter: <name>]: its divider is the chapter\'s address in this deck. Rewrite each frame',
|
|
3186
|
+
' title as the claim it shows (rule D3), keep to what fits the frame (rule D2), and put what you',
|
|
3187
|
+
' say in its Notes (rule D4). Each Recall shows the chapter\'s own float, with its number (rule D8). */}',
|
|
3188
|
+
'',
|
|
3189
|
+
];
|
|
3190
|
+
for (const chapter of chapters) {
|
|
3191
|
+
const { frontmatter, content } = parseFrontmatter(readFileSync(join(REPOSITORY_ROOT, chapter.path), 'utf8')) as {
|
|
3192
|
+
frontmatter?: Record<string, unknown>;
|
|
3193
|
+
content: string;
|
|
3194
|
+
};
|
|
3195
|
+
const chapterTitle = typeof frontmatter?.title === 'string' ? frontmatter.title : chapter.name;
|
|
3196
|
+
out.push(`## ${chapterTitle} [chapter: ${chapter.name}]`, '');
|
|
3197
|
+
// A frame per section, with every float of the section and its subsections.
|
|
3198
|
+
const sections: Pick<OutlineDraft, 'title' | 'recalls' | 'note'>[] = [];
|
|
3199
|
+
for (const draft of chapterOutline(content)) {
|
|
3200
|
+
const current = sections.at(-1);
|
|
3201
|
+
if (draft.level === 2 || current === undefined) {
|
|
3202
|
+
sections.push({ title: draft.title, recalls: [...draft.recalls], note: draft.note });
|
|
3203
|
+
} else {
|
|
3204
|
+
current.recalls.push(...draft.recalls);
|
|
3205
|
+
if (current.note === '') current.note = draft.note;
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
if (sections.length === 0) sections.push({ title: 'The claim this frame shows', recalls: [], note: '' });
|
|
3209
|
+
for (const section of sections) draftFrame(out, section);
|
|
3210
|
+
}
|
|
3211
|
+
if (chapters.length === 0) {
|
|
3212
|
+
out.push('## The question', '', '### The claim this frame shows', '', '<Notes>', 'What you say on this frame.', '</Notes>', '');
|
|
3213
|
+
}
|
|
3214
|
+
return { path: relPath, contents: `${out.join('\n').trimEnd()}\n`, companions: [] };
|
|
3215
|
+
}
|
|
3216
|
+
|
|
1268
3217
|
export function scaffoldTerm(rawKey: string): Scaffold {
|
|
1269
3218
|
const key = rawKey.trim();
|
|
1270
3219
|
if (!ID_PATTERN.test(key)) {
|