explico 0.13.0 → 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 +894 -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 +1087 -14
- 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 +29 -0
- 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/dist/compiler/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
-
import { join, relative } from "node:path";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import {
|
|
6
6
|
createMarkdownProcessor,
|
|
7
7
|
parseFrontmatter
|
|
@@ -18,14 +18,27 @@ import remarkNotation, {
|
|
|
18
18
|
createRemarkNotationOptions
|
|
19
19
|
} from "../reference/remark-notation.mjs";
|
|
20
20
|
import remarkShortNote from "../reference/remark-short-note.mjs";
|
|
21
|
+
import {
|
|
22
|
+
explainCalls,
|
|
23
|
+
mathSpans,
|
|
24
|
+
redundantExplainWraps
|
|
25
|
+
} from "../reference/gate-math.js";
|
|
26
|
+
import { resolveMathGlyphs } from "../reference/math-glyphs.mjs";
|
|
27
|
+
import { stripEquationLabels } from "../reference/equations.mjs";
|
|
21
28
|
import { readRenderDiagnostics } from "./render-diagnostics.js";
|
|
22
|
-
import { isLessonSlug, lessonIdFromSlug } from "./lesson-derivation.js";
|
|
23
|
-
import {
|
|
29
|
+
import { isLessonSlug, lessonIdFromSlug, mergeTrackOrder } from "./lesson-derivation.js";
|
|
30
|
+
import {
|
|
31
|
+
isCourseRuleId,
|
|
32
|
+
ownerOf,
|
|
33
|
+
retiredRule,
|
|
34
|
+
ELEMENTS
|
|
35
|
+
} from "../rules/catalog.js";
|
|
24
36
|
import {
|
|
25
37
|
renderRulesInEffect,
|
|
26
38
|
rulesInEffect
|
|
27
39
|
} from "../rules/in-effect.js";
|
|
28
40
|
import { RESERVED_KEY_PREFIXES, reservedKeyPrefix } from "../reference/references.js";
|
|
41
|
+
import { glyphMatcher } from "../reference/consistency.js";
|
|
29
42
|
const REPOSITORY_ROOT = process.cwd();
|
|
30
43
|
const CONTENT_ROOT = join(REPOSITORY_ROOT, "content");
|
|
31
44
|
const DOCS_ROOT = join(CONTENT_ROOT, "docs");
|
|
@@ -44,9 +57,17 @@ function diagnosticSeverity(value) {
|
|
|
44
57
|
function ownerFields(code) {
|
|
45
58
|
const bare = code.slice(code.indexOf(":") + 1);
|
|
46
59
|
const owner = ownerOf(bare);
|
|
47
|
-
if (owner !== void 0) return { rule: owner.id };
|
|
60
|
+
if (owner !== void 0) return { rule: owner.id, doc: owner.doc };
|
|
48
61
|
return isCourseRuleId(bare) ? { rule: bare } : {};
|
|
49
62
|
}
|
|
63
|
+
function ownerTag(diagnostic) {
|
|
64
|
+
if (!diagnostic.rule) return "";
|
|
65
|
+
return ` [${diagnostic.rule}${diagnostic.doc ? ` \xB7 ${diagnostic.doc}` : ""}]`;
|
|
66
|
+
}
|
|
67
|
+
function formatCliDiagnostic(diagnostic) {
|
|
68
|
+
const location = diagnostic.file ? `${diagnostic.file}:${diagnostic.line ?? 1}:${diagnostic.column ?? 1}: ` : "";
|
|
69
|
+
return `${location}${diagnostic.severity} ${diagnostic.code}${ownerTag(diagnostic)}: ${diagnostic.message}`;
|
|
70
|
+
}
|
|
50
71
|
function withOwner(diagnostic) {
|
|
51
72
|
return { ...diagnostic, ...ownerFields(diagnostic.code) };
|
|
52
73
|
}
|
|
@@ -378,6 +399,33 @@ function domainSignaturesForSection(section) {
|
|
|
378
399
|
signatures: signaturesInFile(path)
|
|
379
400
|
})).filter((entry) => entry.signatures.length > 0);
|
|
380
401
|
}
|
|
402
|
+
const AGENT_GUIDE_MENTION = /docs\/authoring\/agents\.md/;
|
|
403
|
+
function agentGuideState() {
|
|
404
|
+
const agentsFile = ["AGENTS.md", "CLAUDE.md"].find((name) => existsSync(join(REPOSITORY_ROOT, name))) ?? null;
|
|
405
|
+
const linksGuide = agentsFile !== null && AGENT_GUIDE_MENTION.test(readFileSync(join(REPOSITORY_ROOT, agentsFile), "utf8"));
|
|
406
|
+
const skillsInstalled = existsSync(join(REPOSITORY_ROOT, ".claude", "skills", "draft-lesson", "SKILL.md"));
|
|
407
|
+
return { agentsFile, linksGuide, skillsInstalled };
|
|
408
|
+
}
|
|
409
|
+
function agentSurfaces(manifest) {
|
|
410
|
+
const surfaces = [];
|
|
411
|
+
for (const rel of ["AGENTS.md", join("..", "AGENTS.md")]) {
|
|
412
|
+
const path = join(REPOSITORY_ROOT, rel);
|
|
413
|
+
if (!existsSync(path)) continue;
|
|
414
|
+
surfaces.push({ name: rel, tokens: estimateTokens(readFileSync(path, "utf8")) });
|
|
415
|
+
}
|
|
416
|
+
surfaces.push({
|
|
417
|
+
name: "rules in effect",
|
|
418
|
+
tokens: estimateTokens(renderRulesInEffect(manifest))
|
|
419
|
+
});
|
|
420
|
+
try {
|
|
421
|
+
const migration = contentMigrate();
|
|
422
|
+
if (migration.sections.length > 0) {
|
|
423
|
+
surfaces.push({ name: `migration ${migration.from} \u2192 ${migration.to}`, tokens: migration.tokens });
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
}
|
|
427
|
+
return surfaces;
|
|
428
|
+
}
|
|
381
429
|
function contentStatus(manifest, rendered = readRenderDiagnostics()) {
|
|
382
430
|
const orphans = manifest.notation.diagnostics.filter((diagnostic) => diagnostic.code === "unused-definition").map((diagnostic) => ({
|
|
383
431
|
code: `notation:${diagnostic.code}`,
|
|
@@ -428,8 +476,20 @@ function contentStatus(manifest, rendered = readRenderDiagnostics()) {
|
|
|
428
476
|
const assessments = new Set(
|
|
429
477
|
manifest.lessons.flatMap((lesson) => lesson.assessments)
|
|
430
478
|
).size;
|
|
479
|
+
const trackedPages = [
|
|
480
|
+
...manifest.lessons.map((lesson) => ({ slug: lesson.slug, since: lesson.since })),
|
|
481
|
+
...manifest.parts.flatMap(
|
|
482
|
+
(part) => part.overview ? [{ slug: `${part.dir}/index`, since: part.overview.since }] : []
|
|
483
|
+
)
|
|
484
|
+
];
|
|
431
485
|
return {
|
|
432
486
|
knowledgeVersion: manifest.about.knowledge.version,
|
|
487
|
+
agentSurfaces: agentSurfaces(manifest),
|
|
488
|
+
agentGuide: agentGuideState(),
|
|
489
|
+
pageVersions: {
|
|
490
|
+
tracked: manifest.pageVersions.enabled ? trackedPages.length : 0,
|
|
491
|
+
unreleased: trackedPages.filter((page) => page.since === null).map((page) => page.slug).sort()
|
|
492
|
+
},
|
|
433
493
|
counts: {
|
|
434
494
|
lessons: manifest.lessons.length,
|
|
435
495
|
competencies: manifest.competencies.length,
|
|
@@ -451,6 +511,81 @@ function contentStatus(manifest, rendered = readRenderDiagnostics()) {
|
|
|
451
511
|
}
|
|
452
512
|
};
|
|
453
513
|
}
|
|
514
|
+
function severityCounts(diagnostics) {
|
|
515
|
+
const counts = { errors: 0, warnings: 0, infos: 0 };
|
|
516
|
+
for (const diagnostic of diagnostics) {
|
|
517
|
+
if (diagnostic.severity === "error") counts.errors += 1;
|
|
518
|
+
else if (diagnostic.severity === "warning") counts.warnings += 1;
|
|
519
|
+
else counts.infos += 1;
|
|
520
|
+
}
|
|
521
|
+
return counts;
|
|
522
|
+
}
|
|
523
|
+
function summarizeDiagnostics(report) {
|
|
524
|
+
const byCode = /* @__PURE__ */ new Map();
|
|
525
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
526
|
+
for (const diagnostic of report.diagnostics) {
|
|
527
|
+
const codeKey = `${diagnostic.code}\0${diagnostic.severity}`;
|
|
528
|
+
const row = byCode.get(codeKey);
|
|
529
|
+
if (row) byCode.set(codeKey, { ...row, count: row.count + 1 });
|
|
530
|
+
else {
|
|
531
|
+
byCode.set(codeKey, {
|
|
532
|
+
code: diagnostic.code,
|
|
533
|
+
...diagnostic.rule === void 0 ? {} : { rule: diagnostic.rule },
|
|
534
|
+
...diagnostic.doc === void 0 ? {} : { doc: diagnostic.doc },
|
|
535
|
+
severity: diagnostic.severity,
|
|
536
|
+
count: 1
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const file = diagnostic.file ?? "(no file)";
|
|
540
|
+
byFile.set(file, [...byFile.get(file) ?? [], diagnostic]);
|
|
541
|
+
}
|
|
542
|
+
const byCount = (key) => (a, b) => b.count - a.count || key(a).localeCompare(key(b));
|
|
543
|
+
return {
|
|
544
|
+
minimumSeverity: report.minimumSeverity,
|
|
545
|
+
count: report.count,
|
|
546
|
+
bySeverity: severityCounts(report.diagnostics),
|
|
547
|
+
byCode: [...byCode.values()].sort(byCount((row) => row.code)),
|
|
548
|
+
byFile: [...byFile].map(([file, list]) => ({ file, count: list.length, ...severityCounts(list) })).sort(byCount((row) => row.file))
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function filterDiagnostics(report, filter) {
|
|
552
|
+
const files = filter.files === void 0 ? void 0 : new Set(filter.files);
|
|
553
|
+
const diagnostics = report.diagnostics.filter((diagnostic) => {
|
|
554
|
+
if (filter.code !== void 0 && diagnostic.code !== filter.code && diagnostic.code.slice(diagnostic.code.indexOf(":") + 1) !== filter.code) {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
return files === void 0 || diagnostic.file !== void 0 && files.has(diagnostic.file);
|
|
558
|
+
});
|
|
559
|
+
return { minimumSeverity: report.minimumSeverity, count: diagnostics.length, diagnostics };
|
|
560
|
+
}
|
|
561
|
+
function formatDiagnosticsSummary(summary, options = {}) {
|
|
562
|
+
const fileCap = options.fileCap ?? 25;
|
|
563
|
+
const s = summary.bySeverity;
|
|
564
|
+
const lines = [
|
|
565
|
+
`${summary.count} diagnostic(s) at ${summary.minimumSeverity} or higher: ${s.errors} error(s), ${s.warnings} warning(s), ${s.infos} info`
|
|
566
|
+
];
|
|
567
|
+
if (summary.count === 0) return `${lines[0]}
|
|
568
|
+
`;
|
|
569
|
+
lines.push("by code");
|
|
570
|
+
for (const row of summary.byCode) {
|
|
571
|
+
lines.push(` ${String(row.count).padStart(5)} ${row.severity.padEnd(7)} ${row.code}${ownerTag(row)}`);
|
|
572
|
+
}
|
|
573
|
+
lines.push("by file");
|
|
574
|
+
for (const row of summary.byFile.slice(0, fileCap)) {
|
|
575
|
+
const parts = [
|
|
576
|
+
row.errors > 0 ? `${row.errors} error(s)` : "",
|
|
577
|
+
row.warnings > 0 ? `${row.warnings} warning(s)` : "",
|
|
578
|
+
row.infos > 0 ? `${row.infos} info` : ""
|
|
579
|
+
].filter((part) => part !== "");
|
|
580
|
+
lines.push(` ${String(row.count).padStart(5)} ${row.file} (${parts.join(", ")})`);
|
|
581
|
+
}
|
|
582
|
+
if (summary.byFile.length > fileCap) {
|
|
583
|
+
lines.push(` \u2026 and ${summary.byFile.length - fileCap} more file(s)`);
|
|
584
|
+
}
|
|
585
|
+
lines.push("List them: --all for every line; --code <code>, --lesson <id> or --changed for a part.");
|
|
586
|
+
return `${lines.join("\n")}
|
|
587
|
+
`;
|
|
588
|
+
}
|
|
454
589
|
function equationLink(value) {
|
|
455
590
|
if (value === null || typeof value !== "object") return void 0;
|
|
456
591
|
const { key, href } = value;
|
|
@@ -519,6 +654,12 @@ function contentWarnings(manifest, rendered = readRenderDiagnostics()) {
|
|
|
519
654
|
}
|
|
520
655
|
function parseRulesFilter(input) {
|
|
521
656
|
const filter = {};
|
|
657
|
+
if (input.edition !== void 0) {
|
|
658
|
+
if (input.edition !== "chapter" && input.edition !== "deck") {
|
|
659
|
+
throw new ContentCliError("usage", `--edition ${input.edition}: one of chapter, deck.`);
|
|
660
|
+
}
|
|
661
|
+
filter.edition = input.edition;
|
|
662
|
+
}
|
|
522
663
|
if (input.element !== void 0) {
|
|
523
664
|
const elements = input.element.split(",").map((e) => e.trim()).filter(Boolean);
|
|
524
665
|
for (const element of elements) {
|
|
@@ -552,6 +693,766 @@ function contentRules(manifest, filter = {}) {
|
|
|
552
693
|
markdown: renderRulesInEffect(manifest, filter)
|
|
553
694
|
};
|
|
554
695
|
}
|
|
696
|
+
const MIGRATION_HEADING = /^## ([^\n→]+?) → ([^\n]+?)[\t ]*$/;
|
|
697
|
+
function parseMigrationSections(markdown) {
|
|
698
|
+
const lines = markdown.split("\n");
|
|
699
|
+
const starts = lines.map((line, index) => ({ index, match: MIGRATION_HEADING.exec(line) })).filter((entry) => entry.match !== null);
|
|
700
|
+
return starts.map(({ index, match }, position) => {
|
|
701
|
+
let end = lines.findIndex((line, i) => i > index && line.startsWith("## "));
|
|
702
|
+
if (end < 0) end = lines.length;
|
|
703
|
+
const body = lines.slice(index, end);
|
|
704
|
+
while (body.length > 0 && /^(---)?\s*$/.test(body[body.length - 1])) body.pop();
|
|
705
|
+
const text = body.join("\n");
|
|
706
|
+
return {
|
|
707
|
+
from: match[1].trim(),
|
|
708
|
+
to: match[2].trim(),
|
|
709
|
+
heading: lines[index],
|
|
710
|
+
markdown: text,
|
|
711
|
+
tokens: estimateTokens(text),
|
|
712
|
+
...position === starts.length ? {} : {}
|
|
713
|
+
};
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
function versionParts(version) {
|
|
717
|
+
if (version === "Unreleased") return void 0;
|
|
718
|
+
const parts = version.split(".").map((part) => part === "x" ? -1 : Number(part));
|
|
719
|
+
return parts.every((part) => Number.isInteger(part)) ? parts : void 0;
|
|
720
|
+
}
|
|
721
|
+
function versionMatches(pattern, version) {
|
|
722
|
+
if (pattern === version) return true;
|
|
723
|
+
const p = versionParts(pattern);
|
|
724
|
+
const v = versionParts(version);
|
|
725
|
+
if (!p || !v) return false;
|
|
726
|
+
return p.every((part, index) => part === -1 || part === v[index]);
|
|
727
|
+
}
|
|
728
|
+
function compareVersions(a, b) {
|
|
729
|
+
if (a === b) return 0;
|
|
730
|
+
if (a === "Unreleased") return 1;
|
|
731
|
+
if (b === "Unreleased") return -1;
|
|
732
|
+
const pa = versionParts(a) ?? [];
|
|
733
|
+
const pb = versionParts(b) ?? [];
|
|
734
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
735
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
736
|
+
if (d !== 0) return d;
|
|
737
|
+
}
|
|
738
|
+
return 0;
|
|
739
|
+
}
|
|
740
|
+
function selectMigrationPath(sections, from, to) {
|
|
741
|
+
if (compareVersions(from, to) > 0) {
|
|
742
|
+
throw new ContentCliError(
|
|
743
|
+
"unknown-version",
|
|
744
|
+
`--from ${from} is newer than --to ${to}; the guide only goes forward.`
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
const path = [];
|
|
748
|
+
let cursor = from;
|
|
749
|
+
while (compareVersions(cursor, to) < 0) {
|
|
750
|
+
const next = sections.filter((section) => versionMatches(section.from, cursor) && compareVersions(section.to, to) <= 0).sort((a, b) => compareVersions(a.to, b.to))[0];
|
|
751
|
+
if (!next) {
|
|
752
|
+
throw new ContentCliError(
|
|
753
|
+
"migration-gap",
|
|
754
|
+
`No section continues from ${cursor} towards ${to}. Sections known: ${sections.map((section) => `${section.from} \u2192 ${section.to}`).join(", ")}.`
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
path.push(next);
|
|
758
|
+
cursor = next.to;
|
|
759
|
+
}
|
|
760
|
+
return path;
|
|
761
|
+
}
|
|
762
|
+
function installedEngineVersion() {
|
|
763
|
+
for (const path of [
|
|
764
|
+
join(REPOSITORY_ROOT, "node_modules", "explico", "package.json"),
|
|
765
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"),
|
|
766
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "package.json")
|
|
767
|
+
]) {
|
|
768
|
+
if (!existsSync(path)) continue;
|
|
769
|
+
try {
|
|
770
|
+
const pkg = JSON.parse(readFileSync(path, "utf8"));
|
|
771
|
+
if (pkg.name === "explico" && typeof pkg.version === "string") return pkg.version;
|
|
772
|
+
} catch {
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return void 0;
|
|
776
|
+
}
|
|
777
|
+
function pinnedEngineVersion() {
|
|
778
|
+
let text;
|
|
779
|
+
try {
|
|
780
|
+
text = execFileSync("git", ["show", "HEAD:package.json"], {
|
|
781
|
+
cwd: REPOSITORY_ROOT,
|
|
782
|
+
encoding: "utf8",
|
|
783
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
784
|
+
});
|
|
785
|
+
} catch {
|
|
786
|
+
return void 0;
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
const pkg = JSON.parse(text);
|
|
790
|
+
const pinned = pkg.dependencies?.explico ?? pkg.devDependencies?.explico;
|
|
791
|
+
if (typeof pinned !== "string") return void 0;
|
|
792
|
+
const exact = pinned.replace(/^[\^~=v]/, "");
|
|
793
|
+
return versionParts(exact) ? exact : void 0;
|
|
794
|
+
} catch {
|
|
795
|
+
return void 0;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function contentMigrate(options = {}) {
|
|
799
|
+
const dir = authoringDocsDir();
|
|
800
|
+
const file = dir === void 0 ? void 0 : join(dir, "migrating.md");
|
|
801
|
+
if (file === void 0 || !existsSync(file)) {
|
|
802
|
+
throw new ContentCliError("unknown-version", "No migrating.md beside the engine (node_modules/explico/docs/authoring).");
|
|
803
|
+
}
|
|
804
|
+
const to = options.to ?? installedEngineVersion();
|
|
805
|
+
if (to === void 0) {
|
|
806
|
+
throw new ContentCliError("unknown-version", "Could not read the installed engine version; pass --to <version>.");
|
|
807
|
+
}
|
|
808
|
+
const from = options.from ?? pinnedEngineVersion();
|
|
809
|
+
if (from === void 0) {
|
|
810
|
+
throw new ContentCliError(
|
|
811
|
+
"unknown-version",
|
|
812
|
+
"package.json at HEAD pins no exact explico version (a workspace link or a range); pass --from <version>."
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
const sections = selectMigrationPath(parseMigrationSections(readFileSync(file, "utf8")), from, to);
|
|
816
|
+
return {
|
|
817
|
+
from,
|
|
818
|
+
to,
|
|
819
|
+
file: join("docs", "authoring", "migrating.md"),
|
|
820
|
+
sections,
|
|
821
|
+
tokens: sections.reduce((n, section) => n + section.tokens, 0)
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
function formatMigrateReport(report) {
|
|
825
|
+
if (report.sections.length === 0) {
|
|
826
|
+
return `Already at ${report.to}: nothing to migrate from ${report.from}.
|
|
827
|
+
`;
|
|
828
|
+
}
|
|
829
|
+
const head = `Migrating ${report.from} \u2192 ${report.to}: ${report.sections.length} section(s) of ${report.file}, \u2248${report.tokens} tokens, oldest first. Apply every one; each ends with the checks to run.
|
|
830
|
+
`;
|
|
831
|
+
return `${head}
|
|
832
|
+
${report.sections.map((section) => section.markdown).join("\n\n---\n\n")}
|
|
833
|
+
`;
|
|
834
|
+
}
|
|
835
|
+
function spanBindings(latex, scope, options = {}) {
|
|
836
|
+
const source = stripEquationLabels(latex);
|
|
837
|
+
const resolve = (expression) => resolveMathGlyphs(expression, [...scope], {
|
|
838
|
+
suspended: options.suspended ?? [],
|
|
839
|
+
derive: options.derive ?? true
|
|
840
|
+
});
|
|
841
|
+
let result;
|
|
842
|
+
try {
|
|
843
|
+
result = resolve(source);
|
|
844
|
+
} catch (error) {
|
|
845
|
+
return { bindings: [], wraps: [], unresolved: [], error: error instanceof Error ? error.message : String(error) };
|
|
846
|
+
}
|
|
847
|
+
const redundant = new Set(
|
|
848
|
+
redundantExplainWraps(source, scope, { suspended: options.suspended ?? [], derive: options.derive ?? true }).map(
|
|
849
|
+
(wrap) => wrap.index
|
|
850
|
+
)
|
|
851
|
+
);
|
|
852
|
+
const wraps = explainCalls(source).map((call) => ({
|
|
853
|
+
key: call.key,
|
|
854
|
+
inner: call.inner,
|
|
855
|
+
redundant: redundant.has(call.index)
|
|
856
|
+
}));
|
|
857
|
+
return {
|
|
858
|
+
bindings: result.bindings.map((binding) => ({
|
|
859
|
+
token: binding.token,
|
|
860
|
+
key: binding.key,
|
|
861
|
+
how: binding.level === "explicit" ? "explicit" : binding.match
|
|
862
|
+
})),
|
|
863
|
+
wraps,
|
|
864
|
+
unresolved: result.unresolved.map((item) => ({
|
|
865
|
+
token: item.token,
|
|
866
|
+
...item.suspended === void 0 ? {} : { suspended: item.suspended },
|
|
867
|
+
...item.lenders === void 0 || item.lenders.length === 0 ? {} : { lenders: [...item.lenders] }
|
|
868
|
+
}))
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function lessonScope(manifest, lesson, frontmatter) {
|
|
872
|
+
const ids = new Set(lesson.notation.definitionIds);
|
|
873
|
+
const records = manifest.notation.definitions.filter((definition) => ids.has(definition.id));
|
|
874
|
+
const scope = records.map((definition) => ({
|
|
875
|
+
key: definition.key,
|
|
876
|
+
notation: definition.notation,
|
|
877
|
+
...definition.fixedIndex ? { fixedIndex: true } : {}
|
|
878
|
+
}));
|
|
879
|
+
const suspend = frontmatter.notation?.suspend ?? [];
|
|
880
|
+
const suspended = suspend.map((entry) => entry.operator).filter((operator) => typeof operator === "string");
|
|
881
|
+
return { records, scope, suspended };
|
|
882
|
+
}
|
|
883
|
+
function symbolLookup(manifest) {
|
|
884
|
+
const lookups = /* @__PURE__ */ new Map();
|
|
885
|
+
return (token) => {
|
|
886
|
+
const cached = lookups.get(token);
|
|
887
|
+
if (cached) return cached;
|
|
888
|
+
const report = contentSymbol(manifest, token);
|
|
889
|
+
const found = {
|
|
890
|
+
defined: [...report.shared, ...report.local, ...report.glosses].map((hit) => ({
|
|
891
|
+
tier: hit.tier,
|
|
892
|
+
key: hit.key,
|
|
893
|
+
where: hit.where
|
|
894
|
+
})),
|
|
895
|
+
near: report.contains.slice(0, 3).map((hit) => ({ latex: hit.latex, key: hit.key }))
|
|
896
|
+
};
|
|
897
|
+
lookups.set(token, found);
|
|
898
|
+
return found;
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function contentBindings(manifest, rawId) {
|
|
902
|
+
const lesson = findLesson(manifest, rawId);
|
|
903
|
+
const { frontmatter, body } = lessonRaw(lesson);
|
|
904
|
+
const text = readFileSync(lessonAbsolutePath(lesson), "utf8");
|
|
905
|
+
const bodyStart = Math.max(0, text.indexOf(body));
|
|
906
|
+
const bodyLine = text.slice(0, bodyStart).split("\n").length;
|
|
907
|
+
const { records: scopeRecords, scope, suspended } = lessonScope(manifest, lesson, frontmatter);
|
|
908
|
+
const lookup = symbolLookup(manifest);
|
|
909
|
+
const spans = mathSpans(body).map((span) => {
|
|
910
|
+
const resolved = spanBindings(span.latex, scope, { suspended, derive: true });
|
|
911
|
+
return {
|
|
912
|
+
line: bodyLine + span.line - 1,
|
|
913
|
+
column: span.column,
|
|
914
|
+
display: body.slice(Math.max(0, span.offset - 2), span.offset) === "$$",
|
|
915
|
+
latex: span.latex,
|
|
916
|
+
bindings: resolved.bindings,
|
|
917
|
+
wraps: resolved.wraps,
|
|
918
|
+
unresolved: resolved.unresolved.map((item) => ({ ...item, ...lookup(item.token) })),
|
|
919
|
+
...resolved.error === void 0 ? {} : { error: resolved.error }
|
|
920
|
+
};
|
|
921
|
+
});
|
|
922
|
+
spans.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
923
|
+
return {
|
|
924
|
+
lesson: lesson.id,
|
|
925
|
+
file: lesson.file,
|
|
926
|
+
scope: scopeRecords.map((definition) => ({ key: definition.key, notation: definition.notation, scope: definition.kind })).sort((a, b) => a.key.localeCompare(b.key)),
|
|
927
|
+
spans,
|
|
928
|
+
counts: {
|
|
929
|
+
spans: spans.length,
|
|
930
|
+
bindings: spans.reduce((n, span) => n + span.bindings.length, 0),
|
|
931
|
+
unresolved: spans.reduce((n, span) => n + span.unresolved.length, 0),
|
|
932
|
+
wraps: spans.reduce((n, span) => n + span.wraps.length, 0),
|
|
933
|
+
redundantWraps: spans.reduce((n, span) => n + span.wraps.filter((wrap) => wrap.redundant).length, 0),
|
|
934
|
+
errors: spans.filter((span) => span.error !== void 0).length
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function latexPreview(latex, width = 44) {
|
|
939
|
+
const flat = latex.replace(/\s+/g, " ").trim();
|
|
940
|
+
return flat.length > width ? `${flat.slice(0, width - 1)}\u2026` : flat;
|
|
941
|
+
}
|
|
942
|
+
function formatBindingsReport(report) {
|
|
943
|
+
const c = report.counts;
|
|
944
|
+
const lines = [
|
|
945
|
+
`${report.lesson} \u2014 ${report.file}`,
|
|
946
|
+
`${c.spans} math span(s) \xB7 ${c.bindings} binding(s) \xB7 ${c.unresolved} unresolved \xB7 ${c.wraps} explicit wrap(s)` + (c.wraps > 0 ? `, ${c.redundantWraps} redundant (the bare symbol resolves to the same card)` : "") + (c.errors > 0 ? ` \xB7 ${c.errors} span(s) the resolver rejects` : ""),
|
|
947
|
+
`scope (${report.scope.length}): ${report.scope.map((entry) => `${entry.key} ${entry.notation}${entry.scope === "local" ? " (local)" : ""}`).join(", ") || "(none)"}`
|
|
948
|
+
];
|
|
949
|
+
for (const span of report.spans) {
|
|
950
|
+
const where = `L${span.line}:${span.column}`.padEnd(9);
|
|
951
|
+
const preview = (span.display ? "$$" : "$") + latexPreview(span.latex) + (span.display ? "$$" : "$");
|
|
952
|
+
const parts = [];
|
|
953
|
+
if (span.error !== void 0) parts.push(`REJECTED ${span.error}`);
|
|
954
|
+
for (const binding of span.bindings) {
|
|
955
|
+
const explicit = binding.how === "explicit";
|
|
956
|
+
const wrap = explicit ? span.wraps.find((w) => w.key === binding.key) : void 0;
|
|
957
|
+
const note = explicit ? ` [wrap${wrap?.redundant ? ", redundant" : ""}]` : binding.how === "canonical" ? "" : ` [${binding.how}]`;
|
|
958
|
+
parts.push(`${binding.token}${explicit ? "\u21D2" : "\u2192"}${binding.key}${note}`);
|
|
959
|
+
}
|
|
960
|
+
for (const item of span.unresolved) {
|
|
961
|
+
const defined = item.defined.length > 0 ? ` \u2014 defined: ${item.defined.map((d) => `${d.key} (${d.tier}, ${d.where})`).join("; ")}` : item.near.length > 0 ? ` \u2014 not defined; near: ${item.near.map((n) => `${n.latex} ${n.key}`).join(", ")}` : " \u2014 not defined anywhere";
|
|
962
|
+
const why = item.suspended ? ` (suspended ${item.suspended})` : item.lenders ? ` (indexed entries ${item.lenders.join(", ")} lend no card to the bare letter)` : "";
|
|
963
|
+
parts.push(`${item.token} UNRESOLVED${why}${defined}`);
|
|
964
|
+
}
|
|
965
|
+
lines.push(`${where} ${preview.padEnd(48)} ${parts.join(" \xB7 ") || "(nothing to bind)"}`);
|
|
966
|
+
}
|
|
967
|
+
if (report.spans.length === 0) lines.push("(no math)");
|
|
968
|
+
return `${lines.join("\n")}
|
|
969
|
+
`;
|
|
970
|
+
}
|
|
971
|
+
function contentEquation(manifest, latex, options = {}) {
|
|
972
|
+
const lookup = symbolLookup(manifest);
|
|
973
|
+
let resolved;
|
|
974
|
+
let against;
|
|
975
|
+
if (options.lesson !== void 0) {
|
|
976
|
+
const lesson = findLesson(manifest, options.lesson);
|
|
977
|
+
const { frontmatter } = lessonRaw(lesson);
|
|
978
|
+
const { scope, suspended } = lessonScope(manifest, lesson, frontmatter);
|
|
979
|
+
resolved = spanBindings(latex, scope, { suspended, derive: true });
|
|
980
|
+
against = { kind: "lesson", lesson: lesson.id, cards: scope.length };
|
|
981
|
+
} else {
|
|
982
|
+
const scope = manifest.notation.definitions.filter((definition) => definition.kind === "shared").map((definition) => ({
|
|
983
|
+
key: definition.key,
|
|
984
|
+
notation: definition.notation,
|
|
985
|
+
...definition.fixedIndex ? { fixedIndex: true } : {}
|
|
986
|
+
}));
|
|
987
|
+
resolved = spanBindings(latex, scope, { suspended: [], derive: false });
|
|
988
|
+
against = { kind: "shared", cards: scope.length };
|
|
989
|
+
}
|
|
990
|
+
const unresolved = resolved.unresolved.map((item) => ({ ...item, ...lookup(item.token) }));
|
|
991
|
+
return {
|
|
992
|
+
latex,
|
|
993
|
+
against,
|
|
994
|
+
bindings: resolved.bindings,
|
|
995
|
+
wraps: resolved.wraps,
|
|
996
|
+
unresolved,
|
|
997
|
+
...resolved.error === void 0 ? {} : { error: resolved.error },
|
|
998
|
+
ok: resolved.error === void 0 && unresolved.length === 0
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
function formatEquationReport(report) {
|
|
1002
|
+
const against = report.against.kind === "lesson" ? `${report.against.lesson}'s scope (${report.against.cards} cards)` : `every shared card (${report.against.cards})`;
|
|
1003
|
+
const lines = [`$${latexPreview(report.latex, 80)}$ against ${against}`];
|
|
1004
|
+
if (report.error !== void 0) lines.push(` REJECTED ${report.error}`);
|
|
1005
|
+
if (report.bindings.length > 0) {
|
|
1006
|
+
lines.push(
|
|
1007
|
+
` ${report.bindings.map((binding) => {
|
|
1008
|
+
const explicit = binding.how === "explicit";
|
|
1009
|
+
const wrap = explicit ? report.wraps.find((w) => w.key === binding.key) : void 0;
|
|
1010
|
+
const note = explicit ? ` [wrap${wrap?.redundant ? ", redundant" : ""}]` : binding.how === "canonical" ? "" : ` [${binding.how}]`;
|
|
1011
|
+
return `${binding.token}${explicit ? "\u21D2" : "\u2192"}${binding.key}${note}`;
|
|
1012
|
+
}).join(" \xB7 ")}`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
for (const item of report.unresolved) {
|
|
1016
|
+
const defined = item.defined.length > 0 ? ` \u2014 defined: ${item.defined.map((d) => `${d.key} (${d.tier}, ${d.where})`).join("; ")}` : item.near.length > 0 ? ` \u2014 not defined; near: ${item.near.map((n) => `${n.latex} ${n.key}`).join(", ")}` : " \u2014 not defined anywhere";
|
|
1017
|
+
const why = item.suspended ? ` (suspended ${item.suspended})` : item.lenders ? ` (indexed entries ${item.lenders.join(", ")} lend no card to the bare letter)` : "";
|
|
1018
|
+
lines.push(` ${item.token} UNRESOLVED${why}${defined}`);
|
|
1019
|
+
}
|
|
1020
|
+
lines.push(
|
|
1021
|
+
report.ok ? "ok: every glyph binds." : report.error !== void 0 ? "The build would reject this span." : `${report.unresolved.length} unresolved glyph(s): the build would fail on this span.`
|
|
1022
|
+
);
|
|
1023
|
+
return `${lines.join("\n")}
|
|
1024
|
+
`;
|
|
1025
|
+
}
|
|
1026
|
+
const KEY_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
1027
|
+
function assertNotationKey(key) {
|
|
1028
|
+
if (!KEY_PATTERN.test(key)) {
|
|
1029
|
+
throw new ContentCliError("invalid-id", `A notation key is lower-case, dot/hyphen separated, got ${JSON.stringify(key)}.`);
|
|
1030
|
+
}
|
|
1031
|
+
const reserved = reservedKeyPrefix(key);
|
|
1032
|
+
if (reserved !== void 0) {
|
|
1033
|
+
throw new ContentCliError(
|
|
1034
|
+
"invalid-id",
|
|
1035
|
+
`A notation key may not start with ${RESERVED_KEY_PREFIXES.join(", ")}: [[${key}]] would be read as a reference to that kind of float.`
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
function escapeRegExp(text) {
|
|
1040
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1041
|
+
}
|
|
1042
|
+
function rewriteKeyReferences(text, oldKey, newKey) {
|
|
1043
|
+
const k = escapeRegExp(oldKey);
|
|
1044
|
+
let count = 0;
|
|
1045
|
+
const out = text.replace(new RegExp(`\\[\\[\\s*${k}\\s*\\]\\]`, "g"), () => {
|
|
1046
|
+
count += 1;
|
|
1047
|
+
return `[[${newKey}]]`;
|
|
1048
|
+
}).replace(new RegExp(`\\\\explain(\\s*)\\{${k}\\}`, "g"), (_m, space) => {
|
|
1049
|
+
count += 1;
|
|
1050
|
+
return `\\explain${space}{${newKey}}`;
|
|
1051
|
+
});
|
|
1052
|
+
return { text: out, count };
|
|
1053
|
+
}
|
|
1054
|
+
function rewriteKeyMentions(text, oldKey, newKey) {
|
|
1055
|
+
const k = escapeRegExp(oldKey);
|
|
1056
|
+
let count = 0;
|
|
1057
|
+
const out = text.replace(new RegExp(`^(\\s*-\\s*)${k}[\\t ]*$`, "gm"), (_m, lead) => {
|
|
1058
|
+
count += 1;
|
|
1059
|
+
return `${lead}${newKey}`;
|
|
1060
|
+
}).replace(new RegExp(`^(\\s*-\\s*[a-z-]+:)${k}(\\s+\u2014)`, "gm"), (_m, lead, tail) => {
|
|
1061
|
+
count += 1;
|
|
1062
|
+
return `${lead}${newKey}${tail}`;
|
|
1063
|
+
});
|
|
1064
|
+
return { text: out, count };
|
|
1065
|
+
}
|
|
1066
|
+
function removeLocalItem(text, key) {
|
|
1067
|
+
const lines = text.split("\n");
|
|
1068
|
+
const start = lines.findIndex((line) => new RegExp(`^(\\s*)-\\s*key:\\s*${escapeRegExp(key)}\\s*$`).test(line));
|
|
1069
|
+
if (start < 0) return { text, removed: false };
|
|
1070
|
+
const indent = /^(\s*)-/.exec(lines[start])[1].length;
|
|
1071
|
+
let end = start + 1;
|
|
1072
|
+
while (end < lines.length) {
|
|
1073
|
+
const line = lines[end];
|
|
1074
|
+
if (line.trim() !== "" && line.length - line.trimStart().length <= indent) break;
|
|
1075
|
+
if (line === "---") break;
|
|
1076
|
+
end += 1;
|
|
1077
|
+
}
|
|
1078
|
+
while (end > start + 1 && lines[end - 1].trim() === "") end -= 1;
|
|
1079
|
+
lines.splice(start, end - start);
|
|
1080
|
+
let listLine = start - 1;
|
|
1081
|
+
while (listLine >= 0 && !/^\s*local:\s*$/.test(lines[listLine])) listLine -= 1;
|
|
1082
|
+
if (listLine >= 0) {
|
|
1083
|
+
const listIndent = lines[listLine].length - lines[listLine].trimStart().length;
|
|
1084
|
+
let probe = listLine + 1;
|
|
1085
|
+
while (probe < lines.length && lines[probe].trim() === "") probe += 1;
|
|
1086
|
+
const nextIndent = probe < lines.length ? lines[probe].length - lines[probe].trimStart().length : 0;
|
|
1087
|
+
if (probe >= lines.length || lines[probe] === "---" || nextIndent <= listIndent) {
|
|
1088
|
+
lines[listLine] = `${" ".repeat(listIndent)}local: []`;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return { text: lines.join("\n"), removed: true };
|
|
1092
|
+
}
|
|
1093
|
+
function yamlScalar(value) {
|
|
1094
|
+
if (typeof value === "string") {
|
|
1095
|
+
return /^[A-Za-z0-9][A-Za-z0-9 ,.()/%^_-]*$/.test(value) && !/^(true|false|null|yes|no|on|off)$/i.test(value) && !/^\d/.test(value) ? value : JSON.stringify(value);
|
|
1096
|
+
}
|
|
1097
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1098
|
+
return JSON.stringify(value);
|
|
1099
|
+
}
|
|
1100
|
+
function yamlBlock(value, indent) {
|
|
1101
|
+
const pad = " ".repeat(indent);
|
|
1102
|
+
if (Array.isArray(value)) {
|
|
1103
|
+
if (value.length === 0) return [`${pad}[]`];
|
|
1104
|
+
return value.flatMap((item) => {
|
|
1105
|
+
if (item !== null && typeof item === "object") {
|
|
1106
|
+
const inner = yamlBlock(item, indent + 2);
|
|
1107
|
+
return [`${pad}- ${inner[0].trimStart()}`, ...inner.slice(1)];
|
|
1108
|
+
}
|
|
1109
|
+
return [`${pad}- ${yamlScalar(item)}`];
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
if (value !== null && typeof value === "object") {
|
|
1113
|
+
return Object.entries(value).flatMap(([k, v]) => {
|
|
1114
|
+
if (v === void 0) return [];
|
|
1115
|
+
if (Array.isArray(v) && v.length === 0) return [`${pad}${k}: []`];
|
|
1116
|
+
if (v !== null && typeof v === "object") return [`${pad}${k}:`, ...yamlBlock(v, indent + 2)];
|
|
1117
|
+
return [`${pad}${k}: ${yamlScalar(v)}`];
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
return [`${pad}${yamlScalar(value)}`];
|
|
1121
|
+
}
|
|
1122
|
+
function sharedCardFromLocal(local, options) {
|
|
1123
|
+
const key = options.key;
|
|
1124
|
+
const domain = key.includes(".") ? key.slice(0, key.indexOf(".")) : "general";
|
|
1125
|
+
const front = {
|
|
1126
|
+
key,
|
|
1127
|
+
latex: local.latex,
|
|
1128
|
+
...local.label === void 0 ? {} : { label: local.label },
|
|
1129
|
+
meaning: local.meaning,
|
|
1130
|
+
...local.formula === void 0 ? {} : { formula: local.formula },
|
|
1131
|
+
domain,
|
|
1132
|
+
...local.units === void 0 ? {} : { units: local.units },
|
|
1133
|
+
...local.dimensionless === void 0 ? {} : { dimensionless: local.dimensionless },
|
|
1134
|
+
...local.range === void 0 ? {} : { range: local.range },
|
|
1135
|
+
...local.fixedIndex === void 0 ? {} : { fixedIndex: local.fixedIndex },
|
|
1136
|
+
...local.reads === void 0 ? {} : { reads: local.reads },
|
|
1137
|
+
sources: local.sources ?? [],
|
|
1138
|
+
seeAlso: local.seeAlso ?? [],
|
|
1139
|
+
...local.glosses === void 0 ? {} : { glosses: local.glosses },
|
|
1140
|
+
alignment: local.alignment ?? { kind: "general", rationale: `Promoted from ${options.fromLesson}'s page-local entry.` },
|
|
1141
|
+
aiAssisted: true
|
|
1142
|
+
};
|
|
1143
|
+
return `---
|
|
1144
|
+
${yamlBlock(front, 0).join("\n")}
|
|
1145
|
+
---
|
|
1146
|
+
|
|
1147
|
+
Promoted from \`${options.fromLesson}\`'s \`notation.local\`; the meaning above is what that page said. Extend this body with what a glossary reader needs.
|
|
1148
|
+
`;
|
|
1149
|
+
}
|
|
1150
|
+
function courseTextFiles() {
|
|
1151
|
+
const out = [];
|
|
1152
|
+
const walk = (dir) => {
|
|
1153
|
+
for (const entry of readdirSync(join(REPOSITORY_ROOT, dir), { withFileTypes: true })) {
|
|
1154
|
+
const rel = `${dir}/${entry.name}`;
|
|
1155
|
+
if (entry.isDirectory()) walk(rel);
|
|
1156
|
+
else if (/\.(mdx?|ya?ml)$/.test(entry.name)) out.push(rel);
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
if (existsSync(join(REPOSITORY_ROOT, "content"))) walk("content");
|
|
1160
|
+
if (existsSync(join(REPOSITORY_ROOT, "lint-ignore.yml"))) out.push("lint-ignore.yml");
|
|
1161
|
+
return out.sort();
|
|
1162
|
+
}
|
|
1163
|
+
function referenceEdits(oldKey, newKey, skip) {
|
|
1164
|
+
const edits = [];
|
|
1165
|
+
let count = 0;
|
|
1166
|
+
for (const file of courseTextFiles()) {
|
|
1167
|
+
if (skip.has(file)) continue;
|
|
1168
|
+
const before = readFileSync(join(REPOSITORY_ROOT, file), "utf8");
|
|
1169
|
+
const refs = rewriteKeyReferences(before, oldKey, newKey);
|
|
1170
|
+
const mentions2 = /^content\/notation\/|^lint-ignore\.yml$/.test(file) ? rewriteKeyMentions(refs.text, oldKey, newKey) : { text: refs.text, count: 0 };
|
|
1171
|
+
const n = refs.count + mentions2.count;
|
|
1172
|
+
if (n > 0) {
|
|
1173
|
+
edits.push({ file, before, after: mentions2.text });
|
|
1174
|
+
count += n;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return { edits, count };
|
|
1178
|
+
}
|
|
1179
|
+
function planMove(manifest, lessonId, key, options = {}) {
|
|
1180
|
+
const lesson = findLesson(manifest, lessonId);
|
|
1181
|
+
const newKey = options.to ?? key;
|
|
1182
|
+
assertNotationKey(newKey);
|
|
1183
|
+
const target = `content/notation/${newKey}.md`;
|
|
1184
|
+
if (existsSync(join(REPOSITORY_ROOT, target))) {
|
|
1185
|
+
throw new ContentCliError("exists", `${target} already exists; reference it with [[${newKey}]] instead, or move with --to another key.`);
|
|
1186
|
+
}
|
|
1187
|
+
const local = (lesson2) => {
|
|
1188
|
+
const { frontmatter } = lessonRaw(lesson2);
|
|
1189
|
+
const list = frontmatter.notation?.local ?? [];
|
|
1190
|
+
return list.find((entry) => entry.key === key);
|
|
1191
|
+
};
|
|
1192
|
+
const source = local(lesson);
|
|
1193
|
+
if (!source) {
|
|
1194
|
+
throw new ContentCliError("unknown-key", `${lesson.id} declares no notation.local entry ${JSON.stringify(key)}.`);
|
|
1195
|
+
}
|
|
1196
|
+
const summary = [];
|
|
1197
|
+
const edits = [];
|
|
1198
|
+
edits.push({ file: target, before: null, after: sharedCardFromLocal(source, { key: newKey, fromLesson: lesson.id }) });
|
|
1199
|
+
summary.push(`${target}: new shared card from ${lesson.id}'s entry${newKey === key ? "" : ` (key ${key} \u2192 ${newKey})`}`);
|
|
1200
|
+
const touched = /* @__PURE__ */ new Set();
|
|
1201
|
+
for (const other of manifest.lessons) {
|
|
1202
|
+
const copy = local(other);
|
|
1203
|
+
if (!copy) continue;
|
|
1204
|
+
const before = readFileSync(lessonAbsolutePath(other), "utf8");
|
|
1205
|
+
const removed = removeLocalItem(before, key);
|
|
1206
|
+
if (!removed.removed) continue;
|
|
1207
|
+
let after = removed.text;
|
|
1208
|
+
if (newKey !== key) after = rewriteKeyReferences(after, key, newKey).text;
|
|
1209
|
+
edits.push({ file: other.file, before, after });
|
|
1210
|
+
touched.add(other.file);
|
|
1211
|
+
const same = ["latex", "label", "meaning", "units"].every((field) => JSON.stringify(copy[field] ?? null) === JSON.stringify(source[field] ?? null));
|
|
1212
|
+
summary.push(
|
|
1213
|
+
`${other.file}: local entry ${key} removed` + (other.id === lesson.id ? "" : same ? " (a copy that agreed)" : " (a copy that DIFFERED \u2014 the named lesson's text is the card's; check it)")
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
if (newKey !== key) {
|
|
1217
|
+
const refs = referenceEdits(key, newKey, touched);
|
|
1218
|
+
edits.push(...refs.edits);
|
|
1219
|
+
if (refs.count > 0) summary.push(`${refs.count} reference(s) to ${key} in ${refs.edits.length} other file(s) now name ${newKey}`);
|
|
1220
|
+
}
|
|
1221
|
+
return { summary, edits, next: ["pnpm validate:content", `pnpm content symbol ${newKey}`] };
|
|
1222
|
+
}
|
|
1223
|
+
function planRename(manifest, oldKey, newKey) {
|
|
1224
|
+
assertNotationKey(newKey);
|
|
1225
|
+
const oldFile = `content/notation/${oldKey}.md`;
|
|
1226
|
+
const newFile = `content/notation/${newKey}.md`;
|
|
1227
|
+
if (!existsSync(join(REPOSITORY_ROOT, oldFile))) {
|
|
1228
|
+
const local = manifest.lessons.find((lesson) => {
|
|
1229
|
+
const { frontmatter } = lessonRaw(lesson);
|
|
1230
|
+
return (frontmatter.notation?.local ?? []).some((e) => e.key === oldKey);
|
|
1231
|
+
});
|
|
1232
|
+
throw new ContentCliError(
|
|
1233
|
+
"unknown-key",
|
|
1234
|
+
local ? `${oldKey} is a notation.local entry of ${local.id}, not a shared card; promote it with content move ${local.id}:${oldKey} --to ${newKey}.` : `No shared card ${oldKey} (${oldFile}).`
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
if (existsSync(join(REPOSITORY_ROOT, newFile))) throw new ContentCliError("exists", `${newFile} already exists.`);
|
|
1238
|
+
const before = readFileSync(join(REPOSITORY_ROOT, oldFile), "utf8");
|
|
1239
|
+
const keyLine = /^key:\s*.*$/m.exec(before);
|
|
1240
|
+
if (!keyLine) throw new ContentCliError("unknown-key", `${oldFile} has no key: line.`);
|
|
1241
|
+
const moved = before.slice(0, keyLine.index) + `key: ${newKey}` + before.slice(keyLine.index + keyLine[0].length);
|
|
1242
|
+
const edits = [
|
|
1243
|
+
{ file: oldFile, before, after: null },
|
|
1244
|
+
{ file: newFile, before: null, after: rewriteKeyReferences(moved, oldKey, newKey).text }
|
|
1245
|
+
];
|
|
1246
|
+
const refs = referenceEdits(oldKey, newKey, /* @__PURE__ */ new Set([oldFile]));
|
|
1247
|
+
edits.push(...refs.edits);
|
|
1248
|
+
return {
|
|
1249
|
+
summary: [
|
|
1250
|
+
`${oldFile} \u2192 ${newFile} (key: ${newKey})`,
|
|
1251
|
+
`${refs.count} reference(s) in ${refs.edits.length} file(s) now name ${newKey}`
|
|
1252
|
+
],
|
|
1253
|
+
edits,
|
|
1254
|
+
next: ["pnpm validate:content", `pnpm content symbol ${newKey}`]
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
function planFix(manifest, lessonId) {
|
|
1258
|
+
const lesson = findLesson(manifest, lessonId);
|
|
1259
|
+
const { frontmatter, body } = lessonRaw(lesson);
|
|
1260
|
+
const text = readFileSync(lessonAbsolutePath(lesson), "utf8");
|
|
1261
|
+
const bodyStart = Math.max(0, text.indexOf(body));
|
|
1262
|
+
const { scope, suspended } = lessonScope(manifest, lesson, frontmatter);
|
|
1263
|
+
const replacements = [];
|
|
1264
|
+
for (const span of mathSpans(body)) {
|
|
1265
|
+
const source = stripEquationLabels(span.latex);
|
|
1266
|
+
for (const wrap of redundantExplainWraps(source, scope, { suspended, derive: true })) {
|
|
1267
|
+
const needle = `\\explain{${wrap.key}}{${wrap.inner}}`;
|
|
1268
|
+
let at = span.latex.indexOf(needle);
|
|
1269
|
+
while (at >= 0) {
|
|
1270
|
+
replacements.push({ start: bodyStart + span.offset + at, end: bodyStart + span.offset + at + needle.length, inner: wrap.inner, key: wrap.key });
|
|
1271
|
+
at = span.latex.indexOf(needle, at + needle.length);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
const unique = [...new Map(replacements.map((r) => [r.start, r])).values()].sort((a, b) => b.start - a.start);
|
|
1276
|
+
let after = text;
|
|
1277
|
+
for (const r of unique) after = after.slice(0, r.start) + r.inner + after.slice(r.end);
|
|
1278
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1279
|
+
for (const r of unique) byKey.set(r.key, (byKey.get(r.key) ?? 0) + 1);
|
|
1280
|
+
return {
|
|
1281
|
+
summary: unique.length === 0 ? [`${lesson.file}: nothing to fix`] : [
|
|
1282
|
+
`${lesson.file}: ${unique.length} redundant \\explain wrap(s) unwrapped \u2014 ${[...byKey].sort(([a], [b]) => a.localeCompare(b)).map(([key, n]) => `${key} \xD7${n}`).join(", ")}`
|
|
1283
|
+
],
|
|
1284
|
+
edits: unique.length === 0 ? [] : [{ file: lesson.file, before: text, after }],
|
|
1285
|
+
next: [`pnpm content check ${lesson.id}`]
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
function renderEditPlan(plan) {
|
|
1289
|
+
const lines = plan.summary.map((line) => ` ${line}`);
|
|
1290
|
+
const scratch = join(REPOSITORY_ROOT, ".agent", "content-edits");
|
|
1291
|
+
mkdirSync(scratch, { recursive: true });
|
|
1292
|
+
plan.edits.forEach((edit, index) => {
|
|
1293
|
+
const a = join(scratch, `${index}.before`);
|
|
1294
|
+
const b = join(scratch, `${index}.after`);
|
|
1295
|
+
writeFileSync(a, edit.before ?? "");
|
|
1296
|
+
writeFileSync(b, edit.after ?? "");
|
|
1297
|
+
let diff = "";
|
|
1298
|
+
try {
|
|
1299
|
+
diff = execFileSync("git", ["diff", "--no-index", "--no-color", "--unified=2", "--", a, b], { encoding: "utf8" });
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
diff = typeof error.stdout === "string" ? error.stdout : "";
|
|
1302
|
+
}
|
|
1303
|
+
const label = edit.before === null ? `${edit.file} (new file)` : edit.after === null ? `${edit.file} (deleted)` : edit.file;
|
|
1304
|
+
const hunks = diff.split("\n").filter((line) => !/^(diff --git|index |--- |\+\+\+ )/.test(line)).join("\n").trim();
|
|
1305
|
+
lines.push("", `--- ${label}`, hunks || "(no textual change)");
|
|
1306
|
+
unlinkSync(a);
|
|
1307
|
+
unlinkSync(b);
|
|
1308
|
+
});
|
|
1309
|
+
return `${lines.join("\n")}
|
|
1310
|
+
`;
|
|
1311
|
+
}
|
|
1312
|
+
function applyEditPlan(plan) {
|
|
1313
|
+
const touched = [];
|
|
1314
|
+
for (const edit of plan.edits) {
|
|
1315
|
+
const path = join(REPOSITORY_ROOT, edit.file);
|
|
1316
|
+
if (edit.after === null) {
|
|
1317
|
+
if (existsSync(path)) unlinkSync(path);
|
|
1318
|
+
} else {
|
|
1319
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1320
|
+
writeFileSync(path, edit.after);
|
|
1321
|
+
}
|
|
1322
|
+
touched.push(edit.file);
|
|
1323
|
+
}
|
|
1324
|
+
return touched;
|
|
1325
|
+
}
|
|
1326
|
+
function courseAgentDir() {
|
|
1327
|
+
const candidates = [join(REPOSITORY_ROOT, "node_modules", "explico", "docs", "course-agent")];
|
|
1328
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
1329
|
+
for (let level = 0; level < 5; level += 1) {
|
|
1330
|
+
candidates.push(join(dir, "docs", "course-agent"));
|
|
1331
|
+
dir = dirname(dir);
|
|
1332
|
+
}
|
|
1333
|
+
return candidates.find((candidate) => existsSync(join(candidate, "AGENTS.md")));
|
|
1334
|
+
}
|
|
1335
|
+
function filesUnder(root, rel = "") {
|
|
1336
|
+
const out = [];
|
|
1337
|
+
for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
|
|
1338
|
+
const path = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
1339
|
+
if (entry.isDirectory()) out.push(...filesUnder(root, path));
|
|
1340
|
+
else out.push(path);
|
|
1341
|
+
}
|
|
1342
|
+
return out.sort();
|
|
1343
|
+
}
|
|
1344
|
+
function planInitAgent() {
|
|
1345
|
+
const source = courseAgentDir();
|
|
1346
|
+
if (source === void 0) {
|
|
1347
|
+
throw new ContentCliError("unknown-key", "No docs/course-agent/ beside the engine (node_modules/explico/docs/course-agent).");
|
|
1348
|
+
}
|
|
1349
|
+
const edits = [];
|
|
1350
|
+
const summary = [];
|
|
1351
|
+
const install = (from, to) => {
|
|
1352
|
+
const after = readFileSync(join(source, from), "utf8");
|
|
1353
|
+
const target = join(REPOSITORY_ROOT, to);
|
|
1354
|
+
const before = existsSync(target) ? readFileSync(target, "utf8") : null;
|
|
1355
|
+
if (before === after) return;
|
|
1356
|
+
edits.push({ file: to, before, after });
|
|
1357
|
+
summary.push(`${to}: ${before === null ? "installed" : "refreshed from the engine"}`);
|
|
1358
|
+
};
|
|
1359
|
+
for (const file of filesUnder(join(source, "skills"))) install(`skills/${file}`, `.claude/skills/${file}`);
|
|
1360
|
+
for (const file of filesUnder(join(source, "agents"))) install(`agents/${file}`, `.claude/agents/${file}`);
|
|
1361
|
+
const agents = agentGuideState();
|
|
1362
|
+
if (agents.agentsFile === null) {
|
|
1363
|
+
install("AGENTS.md", "AGENTS.md");
|
|
1364
|
+
} else if (!agents.linksGuide) {
|
|
1365
|
+
summary.push(
|
|
1366
|
+
`${agents.agentsFile}: left as it is; it does not point at the engine's guide \u2014 add a first line such as "Read node_modules/explico/docs/authoring/agents.md first; this file holds only what is particular to this course." (the baseline is ${relative(REPOSITORY_ROOT, join(source, "AGENTS.md"))})`
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
if (edits.length === 0 && summary.length === 0) summary.push("the agent procedures are installed and current");
|
|
1370
|
+
return { summary, edits, next: ["pnpm content status"] };
|
|
1371
|
+
}
|
|
1372
|
+
function authoringDocsDir() {
|
|
1373
|
+
const candidates = [join(REPOSITORY_ROOT, "node_modules", "explico", "docs", "authoring")];
|
|
1374
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
1375
|
+
for (let level = 0; level < 5; level += 1) {
|
|
1376
|
+
candidates.push(join(dir, "docs", "authoring"));
|
|
1377
|
+
dir = dirname(dir);
|
|
1378
|
+
}
|
|
1379
|
+
return candidates.find((candidate) => existsSync(join(candidate, "README.md")));
|
|
1380
|
+
}
|
|
1381
|
+
function headingSlug(text) {
|
|
1382
|
+
return text.toLowerCase().replace(/`/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1383
|
+
}
|
|
1384
|
+
function ruleSection(page, anchor) {
|
|
1385
|
+
const lines = page.split("\n");
|
|
1386
|
+
const want = anchor.toLowerCase();
|
|
1387
|
+
const start = lines.findIndex((line) => {
|
|
1388
|
+
const match = /^(#{1,6})\s+(.*\S)\s*$/.exec(line);
|
|
1389
|
+
return match !== null && headingSlug(match[2]) === want;
|
|
1390
|
+
});
|
|
1391
|
+
if (start < 0) return void 0;
|
|
1392
|
+
const level = /^#+/.exec(lines[start])[0].length;
|
|
1393
|
+
let end = lines.findIndex((line, index) => {
|
|
1394
|
+
if (index <= start) return false;
|
|
1395
|
+
const match = /^(#{1,6})\s/.exec(line);
|
|
1396
|
+
return match !== null && match[1].length <= level;
|
|
1397
|
+
});
|
|
1398
|
+
if (end < 0) end = lines.length;
|
|
1399
|
+
const body = lines.slice(start, end);
|
|
1400
|
+
while (body.length > 0 && /^(---)?\s*$/.test(body[body.length - 1])) body.pop();
|
|
1401
|
+
return body.join("\n");
|
|
1402
|
+
}
|
|
1403
|
+
function ruleSectionFor(rule) {
|
|
1404
|
+
if (rule.source === "course") return void 0;
|
|
1405
|
+
const dir = authoringDocsDir();
|
|
1406
|
+
if (dir === void 0) return void 0;
|
|
1407
|
+
const [link, anchor] = rule.doc.split("#");
|
|
1408
|
+
if (!link || !anchor) return void 0;
|
|
1409
|
+
const page = basename(link);
|
|
1410
|
+
const file = join(dir, page);
|
|
1411
|
+
if (!existsSync(file)) return void 0;
|
|
1412
|
+
const markdown = ruleSection(readFileSync(file, "utf8"), anchor);
|
|
1413
|
+
if (markdown === void 0) return void 0;
|
|
1414
|
+
return { file: join("docs", "authoring", page), markdown, tokens: estimateTokens(markdown) };
|
|
1415
|
+
}
|
|
1416
|
+
function contentRule(manifest, rawId) {
|
|
1417
|
+
const id = rawId.trim();
|
|
1418
|
+
const inEffect = rulesInEffect(manifest);
|
|
1419
|
+
const rule = inEffect.find((entry) => entry.id.toLowerCase() === id.toLowerCase());
|
|
1420
|
+
if (rule) {
|
|
1421
|
+
const section = ruleSectionFor(rule);
|
|
1422
|
+
return { id: rule.id, status: "in-effect", rule, ...section ? { section } : {}, known: [] };
|
|
1423
|
+
}
|
|
1424
|
+
const retired = retiredRule(id.toUpperCase());
|
|
1425
|
+
if (retired) return { id: retired.id, status: "retired", retired, known: [] };
|
|
1426
|
+
return { id, status: "unknown", known: inEffect.map((entry) => entry.id) };
|
|
1427
|
+
}
|
|
1428
|
+
function formatRuleReport(report) {
|
|
1429
|
+
if (report.status === "unknown") {
|
|
1430
|
+
return `No rule ${JSON.stringify(report.id)} in this course. Rules in effect: ${report.known.join(", ")}.
|
|
1431
|
+
`;
|
|
1432
|
+
}
|
|
1433
|
+
if (report.status === "retired") {
|
|
1434
|
+
const r = report.retired;
|
|
1435
|
+
return `${r.id} \xB7 retired ${r.date}${r.into ? `, folded into ${r.into}` : ""}
|
|
1436
|
+
was: ${r.title}
|
|
1437
|
+
${r.why}
|
|
1438
|
+
`;
|
|
1439
|
+
}
|
|
1440
|
+
const rule = report.rule;
|
|
1441
|
+
const lines = [
|
|
1442
|
+
`${rule.id} \xB7 ${rule.title}`,
|
|
1443
|
+
` ${rule.kind} \xB7 elements ${rule.elements.join(", ")} \xB7 enforcement ${rule.enforcement} \xB7 editions ${String(rule.editions)} \xB7 ${rule.enabled ? "on" : "off (course.settings.json rules)"}${rule.waivable ? " \xB7 waivable with {/* explico-allow " + rule.id + ": reason */}" : ""}`,
|
|
1444
|
+
` codes: ${rule.codes.length > 0 ? rule.codes.join(", ") : "(none)"}`
|
|
1445
|
+
];
|
|
1446
|
+
if (Object.keys(rule.settings).length > 0) lines.push(` settings: ${JSON.stringify(rule.settings)}`);
|
|
1447
|
+
lines.push(` summary: ${rule.summary}`);
|
|
1448
|
+
if (report.section) {
|
|
1449
|
+
lines.push(` page: ${report.section.file} (section below, \u2248${report.section.tokens} tokens)`, "", report.section.markdown);
|
|
1450
|
+
} else {
|
|
1451
|
+
lines.push(` page: ${rule.doc}${rule.source === "course" ? " (the course's own link)" : " (page not found beside the engine)"}`);
|
|
1452
|
+
}
|
|
1453
|
+
return `${lines.join("\n")}
|
|
1454
|
+
`;
|
|
1455
|
+
}
|
|
555
1456
|
function contentContext(manifest, rawId) {
|
|
556
1457
|
const lesson = findLesson(manifest, rawId);
|
|
557
1458
|
const { frontmatter, body } = lessonRaw(lesson);
|
|
@@ -581,6 +1482,7 @@ function contentContext(manifest, rawId) {
|
|
|
581
1482
|
teaches: lesson.teaches,
|
|
582
1483
|
requires: lesson.requires,
|
|
583
1484
|
assumptions: lesson.assumptions,
|
|
1485
|
+
since: lesson.since,
|
|
584
1486
|
notationLocal: frontmatter.notation?.local ?? [],
|
|
585
1487
|
body
|
|
586
1488
|
},
|
|
@@ -621,6 +1523,154 @@ function contentContext(manifest, rawId) {
|
|
|
621
1523
|
tokens: tokenCounts(contextWithoutTokens)
|
|
622
1524
|
};
|
|
623
1525
|
}
|
|
1526
|
+
const SYMBOL_CONTAINS_CAP = 12;
|
|
1527
|
+
function byKeyThenWhere(left, right) {
|
|
1528
|
+
return left.key.localeCompare(right.key) || left.where.localeCompare(right.where);
|
|
1529
|
+
}
|
|
1530
|
+
function contentSymbol(manifest, rawQuery) {
|
|
1531
|
+
const query = rawQuery.trim();
|
|
1532
|
+
const boundIn = /* @__PURE__ */ new Map();
|
|
1533
|
+
for (const bundle of manifest.notation.bundles) {
|
|
1534
|
+
for (const binding of bundle.bindings) {
|
|
1535
|
+
const list = boundIn.get(binding.definitionId) ?? [];
|
|
1536
|
+
if (!list.includes(bundle.lessonId)) list.push(bundle.lessonId);
|
|
1537
|
+
boundIn.set(binding.definitionId, list);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
const candidates = [];
|
|
1541
|
+
const push = (definition) => {
|
|
1542
|
+
if (definition.kind === "shared") {
|
|
1543
|
+
candidates.push({
|
|
1544
|
+
hit: {
|
|
1545
|
+
tier: "shared",
|
|
1546
|
+
key: definition.key,
|
|
1547
|
+
latex: definition.notation,
|
|
1548
|
+
label: definition.label,
|
|
1549
|
+
meaning: definition.summary,
|
|
1550
|
+
...definition.units === void 0 ? {} : { units: definition.units },
|
|
1551
|
+
where: definition.source.file,
|
|
1552
|
+
boundIn: [...boundIn.get(definition.id) ?? []].sort()
|
|
1553
|
+
},
|
|
1554
|
+
aliases: definition.aliases
|
|
1555
|
+
});
|
|
1556
|
+
for (const gloss of definition.glosses) {
|
|
1557
|
+
candidates.push({
|
|
1558
|
+
hit: {
|
|
1559
|
+
tier: "gloss",
|
|
1560
|
+
key: gloss.key,
|
|
1561
|
+
latex: gloss.notation,
|
|
1562
|
+
label: gloss.label,
|
|
1563
|
+
...gloss.units === void 0 ? {} : { units: gloss.units },
|
|
1564
|
+
where: gloss.ownerKey
|
|
1565
|
+
},
|
|
1566
|
+
aliases: []
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
candidates.push({
|
|
1572
|
+
hit: {
|
|
1573
|
+
tier: "local",
|
|
1574
|
+
key: definition.key,
|
|
1575
|
+
latex: definition.notation,
|
|
1576
|
+
label: definition.label,
|
|
1577
|
+
meaning: definition.summary,
|
|
1578
|
+
...definition.units === void 0 ? {} : { units: definition.units },
|
|
1579
|
+
where: definition.lessonId
|
|
1580
|
+
},
|
|
1581
|
+
aliases: []
|
|
1582
|
+
});
|
|
1583
|
+
};
|
|
1584
|
+
for (const definition of manifest.notation.definitions) push(definition);
|
|
1585
|
+
const exact = [];
|
|
1586
|
+
const rest = [];
|
|
1587
|
+
for (const candidate of candidates) {
|
|
1588
|
+
const { hit, aliases } = candidate;
|
|
1589
|
+
const matchedBy = hit.latex.trim() === query ? "latex" : aliases.some((alias) => alias.trim() === query) ? "alias" : hit.key === query ? "key" : void 0;
|
|
1590
|
+
if (matchedBy) exact.push({ ...hit, matchedBy });
|
|
1591
|
+
else rest.push(candidate);
|
|
1592
|
+
}
|
|
1593
|
+
const matcher = glyphMatcher(query);
|
|
1594
|
+
const containsAll = matcher ? rest.filter(({ hit }) => matcher.test(hit.latex)).map(({ hit }) => ({ ...hit, matchedBy: "latex" })).sort(byKeyThenWhere) : [];
|
|
1595
|
+
const tier = (name) => exact.filter((hit) => hit.tier === name).sort(byKeyThenWhere);
|
|
1596
|
+
return {
|
|
1597
|
+
query,
|
|
1598
|
+
shared: tier("shared"),
|
|
1599
|
+
local: tier("local"),
|
|
1600
|
+
glosses: tier("gloss"),
|
|
1601
|
+
contains: containsAll.slice(0, SYMBOL_CONTAINS_CAP),
|
|
1602
|
+
containsTotal: containsAll.length
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
function firstSentence(text, limit = 140) {
|
|
1606
|
+
const sentence = text.trim().split(/(?<=[.!?])\s/)[0] ?? "";
|
|
1607
|
+
return sentence.length > limit ? `${sentence.slice(0, limit - 1)}\u2026` : sentence;
|
|
1608
|
+
}
|
|
1609
|
+
function symbolHitLine(hit) {
|
|
1610
|
+
const units = hit.units === void 0 ? "" : ` (${hit.units})`;
|
|
1611
|
+
const where = hit.tier === "shared" ? `${hit.where}; bound on ${hit.boundIn?.length ?? 0} page(s)` + (hit.boundIn && hit.boundIn.length > 0 ? `: ${hit.boundIn.join(", ")}` : "") : hit.tier === "local" ? `lesson ${hit.where}` : `gloss on ${hit.where}`;
|
|
1612
|
+
const via = hit.matchedBy === "latex" ? "" : ` [matched by ${hit.matchedBy}]`;
|
|
1613
|
+
return `${hit.key} \u2014 ${hit.label}${units} \u2014 ${where}${via}`;
|
|
1614
|
+
}
|
|
1615
|
+
function formatSymbolReport(report) {
|
|
1616
|
+
const lines = [report.query];
|
|
1617
|
+
const section = (title, hits) => {
|
|
1618
|
+
if (hits.length === 0) {
|
|
1619
|
+
lines.push(` ${title.padEnd(7)} (none)`);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
for (const [index, hit] of hits.entries()) {
|
|
1623
|
+
lines.push(` ${(index === 0 ? title : "").padEnd(7)} ${symbolHitLine(hit)}`);
|
|
1624
|
+
if (hit.meaning) lines.push(` ${firstSentence(hit.meaning)}`);
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
section("shared", report.shared);
|
|
1628
|
+
section("local", report.local);
|
|
1629
|
+
section("gloss", report.glosses);
|
|
1630
|
+
if (report.contains.length > 0) {
|
|
1631
|
+
for (const [index, hit] of report.contains.entries()) {
|
|
1632
|
+
lines.push(
|
|
1633
|
+
` ${(index === 0 ? "also" : "").padEnd(7)} ${hit.latex} \u2014 ${symbolHitLine(hit)}`
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
if (report.containsTotal > report.contains.length) {
|
|
1637
|
+
lines.push(
|
|
1638
|
+
` \u2026 and ${report.containsTotal - report.contains.length} more; --json lists them all`
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
const pageMeanings = report.local.length + report.glosses.length;
|
|
1643
|
+
if (report.shared.length > 0) {
|
|
1644
|
+
const bound = report.shared.reduce((n, hit) => n + (hit.boundIn?.length ?? 0), 0);
|
|
1645
|
+
lines.push(
|
|
1646
|
+
`${report.shared.length} shared card(s), bound on ${bound} page(s). Reference it with [[key]] before the first math use.`
|
|
1647
|
+
);
|
|
1648
|
+
} else if (pageMeanings > 0) {
|
|
1649
|
+
lines.push(
|
|
1650
|
+
`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.`
|
|
1651
|
+
);
|
|
1652
|
+
} else {
|
|
1653
|
+
lines.push(
|
|
1654
|
+
`Not defined anywhere in this course${report.containsTotal > 0 ? " (near misses above)" : ""}.`
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
return `${lines.join("\n")}
|
|
1658
|
+
`;
|
|
1659
|
+
}
|
|
1660
|
+
function findCheckTarget(manifest, rawId) {
|
|
1661
|
+
const id = normalizeLessonId(rawId);
|
|
1662
|
+
const lesson = manifest.lessons.find((entry) => entry.id === id);
|
|
1663
|
+
if (lesson) return lesson;
|
|
1664
|
+
const deck = (manifest.decks ?? []).find((entry) => entry.pageId === id);
|
|
1665
|
+
if (deck) return { id: deck.pageId, file: deck.file, assessments: [], notation: deck.notation };
|
|
1666
|
+
throw new ContentCliError(
|
|
1667
|
+
"unknown-lesson",
|
|
1668
|
+
`No lesson or deck ${JSON.stringify(id)}. Known ids: ${[
|
|
1669
|
+
...manifest.lessons.map((entry) => entry.id),
|
|
1670
|
+
...(manifest.decks ?? []).filter((entry) => entry.chapter !== void 0 || entry.part !== void 0).map((entry) => entry.pageId)
|
|
1671
|
+
].join(", ")}`
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
624
1674
|
async function checkOneLesson(manifest, lesson) {
|
|
625
1675
|
const manifestDiagnostics = lessonDiagnostics(manifest, lesson);
|
|
626
1676
|
const { frontmatter, body } = lessonSource(
|
|
@@ -686,7 +1736,7 @@ async function contentCheck(manifest, options) {
|
|
|
686
1736
|
"content check needs a <lesson> id or --changed."
|
|
687
1737
|
);
|
|
688
1738
|
}
|
|
689
|
-
targets = named.map((id) =>
|
|
1739
|
+
targets = named.map((id) => findCheckTarget(manifest, id));
|
|
690
1740
|
}
|
|
691
1741
|
const lessons = await Promise.all(
|
|
692
1742
|
targets.map((lesson) => checkOneLesson(manifest, lesson))
|
|
@@ -754,6 +1804,203 @@ After this lesson, you should be able to:
|
|
|
754
1804
|
]
|
|
755
1805
|
};
|
|
756
1806
|
}
|
|
1807
|
+
function chapterOutline(content) {
|
|
1808
|
+
const lines = content.split("\n");
|
|
1809
|
+
const drafts = [];
|
|
1810
|
+
let fence = false;
|
|
1811
|
+
let paragraph = [];
|
|
1812
|
+
let pendingTag;
|
|
1813
|
+
const flushParagraph = () => {
|
|
1814
|
+
const current = drafts.at(-1);
|
|
1815
|
+
if (current && current.note === "" && paragraph.length > 0) {
|
|
1816
|
+
const text = paragraph.join(" ");
|
|
1817
|
+
if (!/^\s*[<{:|]/.test(text)) current.note = firstSentence(text);
|
|
1818
|
+
}
|
|
1819
|
+
paragraph = [];
|
|
1820
|
+
};
|
|
1821
|
+
for (const line of lines) {
|
|
1822
|
+
if (/^\s*(```|~~~)/.test(line)) fence = !fence;
|
|
1823
|
+
if (fence) continue;
|
|
1824
|
+
const heading = /^(#{2,3})[ \t]+(.+?)[ \t]*$/.exec(line);
|
|
1825
|
+
if (heading) {
|
|
1826
|
+
flushParagraph();
|
|
1827
|
+
drafts.push({
|
|
1828
|
+
level: heading[1].length,
|
|
1829
|
+
title: heading[2].replace(/(?:\s*\[[a-z=-]+\])+\s*$/i, ""),
|
|
1830
|
+
recalls: [],
|
|
1831
|
+
note: ""
|
|
1832
|
+
});
|
|
1833
|
+
continue;
|
|
1834
|
+
}
|
|
1835
|
+
const current = drafts.at(-1);
|
|
1836
|
+
for (const match of line.matchAll(/\\label\{eq:([a-z0-9-]+)\}/g)) current?.recalls.push(`eq-${match[1]}`);
|
|
1837
|
+
if (/<(Figure|Table|Diagram)\b/.test(line)) pendingTag = line;
|
|
1838
|
+
else if (pendingTag !== void 0) pendingTag += ` ${line}`;
|
|
1839
|
+
if (pendingTag !== void 0 && pendingTag.includes(">")) {
|
|
1840
|
+
const tag = /<(Figure|Table|Diagram)\b[^>]*?\bid=["']([a-z0-9-]+)["']/.exec(pendingTag);
|
|
1841
|
+
if (tag) {
|
|
1842
|
+
const prefix = tag[1] === "Figure" ? "fig" : tag[1] === "Table" ? "tab" : "dia";
|
|
1843
|
+
current?.recalls.push(`${prefix}-${tag[2]}`);
|
|
1844
|
+
}
|
|
1845
|
+
pendingTag = void 0;
|
|
1846
|
+
}
|
|
1847
|
+
if (line.trim() === "") flushParagraph();
|
|
1848
|
+
else if (!/^\s*(import|export)\s/.test(line) && !/^\s*[-*$<{|>]/.test(line)) paragraph.push(line.trim());
|
|
1849
|
+
}
|
|
1850
|
+
flushParagraph();
|
|
1851
|
+
return drafts;
|
|
1852
|
+
}
|
|
1853
|
+
function draftFrame(out, draft) {
|
|
1854
|
+
out.push(`### ${draft.title}`, "");
|
|
1855
|
+
for (const ref of draft.recalls) out.push(`<Recall ref="${ref}" />`, "");
|
|
1856
|
+
out.push("<Notes>", draft.note || "What you say on this frame.", "</Notes>", "");
|
|
1857
|
+
}
|
|
1858
|
+
function scaffoldDeck(rawId) {
|
|
1859
|
+
const slug = rawId.trim().replace(/(?:\.deck)?\.mdx?$/, "").replace(/\./g, "/").replace(/\/(?:index)?$/, "");
|
|
1860
|
+
if (!slug.includes("/") && ID_PATTERN.test(slug) && partPagePath(slug) !== void 0) {
|
|
1861
|
+
return scaffoldPartDeck(slug);
|
|
1862
|
+
}
|
|
1863
|
+
if (!slug.includes("/") || !isLessonSlug(slug) || slug.split("/").some((part) => !ID_PATTERN.test(part))) {
|
|
1864
|
+
throw new ContentCliError(
|
|
1865
|
+
"invalid-id",
|
|
1866
|
+
`A deck is named by its lesson, <section>/<slug> (or <section>.<slug>), or by a part's directory with its page (content/docs/<part>/index.mdx), got ${JSON.stringify(rawId)}.`
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
const relPath = `content/docs/${slug}.deck.mdx`;
|
|
1870
|
+
if (existsSync(join(REPOSITORY_ROOT, relPath))) {
|
|
1871
|
+
throw new ContentCliError("exists", `${relPath} already exists; not overwriting.`);
|
|
1872
|
+
}
|
|
1873
|
+
const chapterPath = [".mdx", ".md"].map((extension) => `content/docs/${slug}${extension}`).find((path) => existsSync(join(REPOSITORY_ROOT, path)));
|
|
1874
|
+
const titleCase = (slug.split("/").at(-1) ?? slug).split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
1875
|
+
if (chapterPath === void 0) {
|
|
1876
|
+
return {
|
|
1877
|
+
path: relPath,
|
|
1878
|
+
contents: `---
|
|
1879
|
+
title: ${titleCase}
|
|
1880
|
+
description: One sentence on what the student can do after this lesson.
|
|
1881
|
+
teaches: []
|
|
1882
|
+
assumptions: []
|
|
1883
|
+
---
|
|
1884
|
+
|
|
1885
|
+
{/* A slides-first lesson: this deck is the lesson. ## opens a section, ### is a frame
|
|
1886
|
+
whose title is the claim it shows (rule D3); the title frame is generated. */}
|
|
1887
|
+
|
|
1888
|
+
## The question
|
|
1889
|
+
|
|
1890
|
+
### The claim this frame shows
|
|
1891
|
+
|
|
1892
|
+
- One line per point the frame makes
|
|
1893
|
+
|
|
1894
|
+
<Notes>
|
|
1895
|
+
What you say on this frame: the reasoning, the transition, the question to ask.
|
|
1896
|
+
</Notes>
|
|
1897
|
+
`,
|
|
1898
|
+
companions: [
|
|
1899
|
+
{
|
|
1900
|
+
path: `content/docs/${slug}.checks.yml`,
|
|
1901
|
+
contents: `# Assessment sets for this lesson. Ids resolve against content/assessments/.
|
|
1902
|
+
[]
|
|
1903
|
+
`
|
|
1904
|
+
}
|
|
1905
|
+
]
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
const raw = readFileSync(join(REPOSITORY_ROOT, chapterPath), "utf8");
|
|
1909
|
+
const { frontmatter, content } = parseFrontmatter(raw);
|
|
1910
|
+
const data = frontmatter ?? {};
|
|
1911
|
+
const title = typeof data.title === "string" ? data.title : titleCase;
|
|
1912
|
+
const description = typeof data.description === "string" ? data.description : "";
|
|
1913
|
+
const drafts = chapterOutline(content);
|
|
1914
|
+
const out = [
|
|
1915
|
+
"---",
|
|
1916
|
+
`title: ${JSON.stringify(title)}`,
|
|
1917
|
+
...description ? [`description: ${JSON.stringify(description)}`] : [],
|
|
1918
|
+
"---",
|
|
1919
|
+
"",
|
|
1920
|
+
`{/* Drafted by \`content new deck\` from ${chapterPath}. Rewrite each frame title as the claim it`,
|
|
1921
|
+
" shows (rule D3), keep to what fits the frame (rule D2), and put what you say in its Notes",
|
|
1922
|
+
" (rule D4). Each Recall shows the chapter's own float, with its number (rule D8). */}",
|
|
1923
|
+
""
|
|
1924
|
+
];
|
|
1925
|
+
const frame = (draft) => draftFrame(out, draft);
|
|
1926
|
+
drafts.forEach((draft, index) => {
|
|
1927
|
+
if (draft.level === 2) {
|
|
1928
|
+
out.push(`## ${draft.title}`, "");
|
|
1929
|
+
if (drafts[index + 1]?.level !== 3 || draft.recalls.length > 0) frame(draft);
|
|
1930
|
+
} else {
|
|
1931
|
+
frame(draft);
|
|
1932
|
+
}
|
|
1933
|
+
});
|
|
1934
|
+
if (drafts.length === 0) {
|
|
1935
|
+
out.push("## The question", "", "### The claim this frame shows", "", "<Notes>", "What you say on this frame.", "</Notes>", "");
|
|
1936
|
+
}
|
|
1937
|
+
return { path: relPath, contents: `${out.join("\n").trimEnd()}
|
|
1938
|
+
`, companions: [] };
|
|
1939
|
+
}
|
|
1940
|
+
function partPagePath(dir) {
|
|
1941
|
+
return [".mdx", ".md"].map((extension) => `content/docs/${dir}/index${extension}`).find((path) => existsSync(join(REPOSITORY_ROOT, path)));
|
|
1942
|
+
}
|
|
1943
|
+
function trackOrder() {
|
|
1944
|
+
const directory = join(CONTENT_ROOT, "tracks");
|
|
1945
|
+
if (!existsSync(directory)) return [];
|
|
1946
|
+
const tracks = readdirSync(directory).filter((name) => name.endsWith(".json")).sort().flatMap((name) => {
|
|
1947
|
+
try {
|
|
1948
|
+
const data = JSON.parse(readFileSync(join(directory, name), "utf8"));
|
|
1949
|
+
return typeof data.id === "string" && Array.isArray(data.lessons) ? [{ id: data.id, lessons: data.lessons.filter((lesson) => typeof lesson === "string") }] : [];
|
|
1950
|
+
} catch {
|
|
1951
|
+
return [];
|
|
1952
|
+
}
|
|
1953
|
+
});
|
|
1954
|
+
return mergeTrackOrder(tracks);
|
|
1955
|
+
}
|
|
1956
|
+
function scaffoldPartDeck(dir) {
|
|
1957
|
+
const relPath = `content/docs/${dir}/index.deck.mdx`;
|
|
1958
|
+
if (existsSync(join(REPOSITORY_ROOT, relPath))) {
|
|
1959
|
+
throw new ContentCliError("exists", `${relPath} already exists; not overwriting.`);
|
|
1960
|
+
}
|
|
1961
|
+
const pagePath = partPagePath(dir);
|
|
1962
|
+
const page = parseFrontmatter(readFileSync(join(REPOSITORY_ROOT, pagePath), "utf8")).frontmatter ?? {};
|
|
1963
|
+
const title = typeof page.title === "string" ? page.title : dir;
|
|
1964
|
+
const description = typeof page.description === "string" ? page.description : "";
|
|
1965
|
+
const rank = new Map(trackOrder().map((lessonId, index) => [lessonId, index]));
|
|
1966
|
+
const chapters = readdirSync(join(DOCS_ROOT, dir)).filter((name) => /\.mdx?$/.test(name) && !/\.deck\.mdx?$/.test(name) && !/^index\.mdx?$/.test(name)).map((name) => ({ name: name.replace(/\.mdx?$/, ""), path: `content/docs/${dir}/${name}` })).sort(
|
|
1967
|
+
(left, right) => (rank.get(`${dir}.${left.name}`) ?? Number.POSITIVE_INFINITY) - (rank.get(`${dir}.${right.name}`) ?? Number.POSITIVE_INFINITY) || left.name.localeCompare(right.name)
|
|
1968
|
+
);
|
|
1969
|
+
const out = [
|
|
1970
|
+
"---",
|
|
1971
|
+
`title: ${JSON.stringify(title)}`,
|
|
1972
|
+
...description ? [`description: ${JSON.stringify(description)}`] : [],
|
|
1973
|
+
"---",
|
|
1974
|
+
"",
|
|
1975
|
+
`{/* Drafted by \`content new deck\` from the chapters of ${dir}. A section per chapter names it`,
|
|
1976
|
+
" with [chapter: <name>]: its divider is the chapter's address in this deck. Rewrite each frame",
|
|
1977
|
+
" title as the claim it shows (rule D3), keep to what fits the frame (rule D2), and put what you",
|
|
1978
|
+
" say in its Notes (rule D4). Each Recall shows the chapter's own float, with its number (rule D8). */}",
|
|
1979
|
+
""
|
|
1980
|
+
];
|
|
1981
|
+
for (const chapter of chapters) {
|
|
1982
|
+
const { frontmatter, content } = parseFrontmatter(readFileSync(join(REPOSITORY_ROOT, chapter.path), "utf8"));
|
|
1983
|
+
const chapterTitle = typeof frontmatter?.title === "string" ? frontmatter.title : chapter.name;
|
|
1984
|
+
out.push(`## ${chapterTitle} [chapter: ${chapter.name}]`, "");
|
|
1985
|
+
const sections = [];
|
|
1986
|
+
for (const draft of chapterOutline(content)) {
|
|
1987
|
+
const current = sections.at(-1);
|
|
1988
|
+
if (draft.level === 2 || current === void 0) {
|
|
1989
|
+
sections.push({ title: draft.title, recalls: [...draft.recalls], note: draft.note });
|
|
1990
|
+
} else {
|
|
1991
|
+
current.recalls.push(...draft.recalls);
|
|
1992
|
+
if (current.note === "") current.note = draft.note;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
if (sections.length === 0) sections.push({ title: "The claim this frame shows", recalls: [], note: "" });
|
|
1996
|
+
for (const section of sections) draftFrame(out, section);
|
|
1997
|
+
}
|
|
1998
|
+
if (chapters.length === 0) {
|
|
1999
|
+
out.push("## The question", "", "### The claim this frame shows", "", "<Notes>", "What you say on this frame.", "</Notes>", "");
|
|
2000
|
+
}
|
|
2001
|
+
return { path: relPath, contents: `${out.join("\n").trimEnd()}
|
|
2002
|
+
`, companions: [] };
|
|
2003
|
+
}
|
|
757
2004
|
function scaffoldTerm(rawKey) {
|
|
758
2005
|
const key = rawKey.trim();
|
|
759
2006
|
if (!ID_PATTERN.test(key)) {
|
|
@@ -797,20 +2044,54 @@ frontmatter.
|
|
|
797
2044
|
}
|
|
798
2045
|
export {
|
|
799
2046
|
ContentCliError,
|
|
2047
|
+
applyEditPlan,
|
|
2048
|
+
authoringDocsDir,
|
|
800
2049
|
changedLessonIds,
|
|
2050
|
+
contentBindings,
|
|
801
2051
|
contentCheck,
|
|
802
2052
|
contentContext,
|
|
803
2053
|
contentDiagnostics,
|
|
2054
|
+
contentEquation,
|
|
2055
|
+
contentMigrate,
|
|
2056
|
+
contentRule,
|
|
804
2057
|
contentRules,
|
|
805
2058
|
contentStatus,
|
|
2059
|
+
contentSymbol,
|
|
806
2060
|
contentWarnings,
|
|
2061
|
+
courseAgentDir,
|
|
807
2062
|
estimateTokens,
|
|
2063
|
+
filterDiagnostics,
|
|
2064
|
+
findCheckTarget,
|
|
808
2065
|
findLesson,
|
|
2066
|
+
formatBindingsReport,
|
|
2067
|
+
formatCliDiagnostic,
|
|
2068
|
+
formatDiagnosticsSummary,
|
|
2069
|
+
formatEquationReport,
|
|
2070
|
+
formatMigrateReport,
|
|
2071
|
+
formatRuleReport,
|
|
2072
|
+
formatSymbolReport,
|
|
2073
|
+
installedEngineVersion,
|
|
809
2074
|
lessonDiagnostics,
|
|
810
2075
|
lessonSource,
|
|
811
2076
|
normalizeLessonId,
|
|
2077
|
+
ownerTag,
|
|
2078
|
+
parseMigrationSections,
|
|
812
2079
|
parseRulesFilter,
|
|
2080
|
+
pinnedEngineVersion,
|
|
2081
|
+
planFix,
|
|
2082
|
+
planInitAgent,
|
|
2083
|
+
planMove,
|
|
2084
|
+
planRename,
|
|
2085
|
+
removeLocalItem,
|
|
2086
|
+
renderEditPlan,
|
|
813
2087
|
renderLesson,
|
|
2088
|
+
rewriteKeyReferences,
|
|
2089
|
+
ruleSection,
|
|
2090
|
+
scaffoldDeck,
|
|
814
2091
|
scaffoldLesson,
|
|
815
|
-
scaffoldTerm
|
|
2092
|
+
scaffoldTerm,
|
|
2093
|
+
selectMigrationPath,
|
|
2094
|
+
sharedCardFromLocal,
|
|
2095
|
+
spanBindings,
|
|
2096
|
+
summarizeDiagnostics
|
|
816
2097
|
};
|