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