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.
Files changed (239) hide show
  1. package/CHANGELOG.md +894 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +43 -0
  6. package/dist/compiler/cli-main.js +392 -0
  7. package/dist/compiler/cli.d.ts +424 -9
  8. package/dist/compiler/cli.js +1289 -8
  9. package/dist/compiler/collections.d.ts +88 -1
  10. package/dist/compiler/collections.js +170 -25
  11. package/dist/compiler/course-config.d.ts +1 -1
  12. package/dist/compiler/course-config.js +105 -1
  13. package/dist/compiler/decks.d.ts +161 -0
  14. package/dist/compiler/decks.js +235 -0
  15. package/dist/compiler/exports/combined-html.d.ts +5 -1
  16. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  17. package/dist/compiler/exports/deck-fit.js +90 -0
  18. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  19. package/dist/compiler/exports/export-chapters.js +64 -5
  20. package/dist/compiler/exports/export-pages.d.ts +30 -3
  21. package/dist/compiler/exports/export-pages.js +24 -1
  22. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  23. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  24. package/dist/compiler/exports/latex/cache.js +129 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  26. package/dist/compiler/exports/latex/capture.js +3 -1
  27. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  28. package/dist/compiler/exports/latex/compile.js +12 -1
  29. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  30. package/dist/compiler/exports/latex/deck.js +349 -0
  31. package/dist/compiler/exports/latex/document.d.ts +51 -6
  32. package/dist/compiler/exports/latex/document.js +71 -12
  33. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  34. package/dist/compiler/exports/latex/explico.cls +13 -183
  35. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  36. package/dist/compiler/exports/latex/serialize.js +4 -2
  37. package/dist/compiler/exports/latex-export.d.ts +24 -2
  38. package/dist/compiler/exports/latex-export.js +107 -26
  39. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  40. package/dist/compiler/exports/markdown/serialize.js +85 -8
  41. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  42. package/dist/compiler/exports/markdown-export.js +50 -11
  43. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  44. package/dist/compiler/exports/standalone-html.js +3 -3
  45. package/dist/compiler/exports/static-tree.d.ts +38 -1
  46. package/dist/compiler/exports/static-tree.js +120 -4
  47. package/dist/compiler/knowledge-version.d.ts +26 -2
  48. package/dist/compiler/knowledge-version.js +27 -17
  49. package/dist/compiler/lesson-derivation.d.ts +143 -6
  50. package/dist/compiler/lesson-derivation.js +145 -13
  51. package/dist/compiler/manifest.d.ts +133 -4
  52. package/dist/compiler/manifest.js +359 -58
  53. package/dist/compiler/page-versions.d.ts +160 -0
  54. package/dist/compiler/page-versions.js +198 -0
  55. package/dist/compiler/render-diagnostics.d.ts +5 -1
  56. package/dist/compiler/resolution-report.d.ts +24 -6
  57. package/dist/compiler/resolution-report.js +22 -4
  58. package/dist/compiler/validate-content.d.ts +7 -0
  59. package/dist/compiler/validate-content.js +160 -0
  60. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  61. package/dist/curriculum/competency-graph.d.ts +54 -0
  62. package/dist/curriculum/competency-graph.js +41 -0
  63. package/dist/reference/base-library.d.mts +8 -0
  64. package/dist/reference/base-library.mjs +19 -0
  65. package/dist/reference/consistency.d.ts +8 -4
  66. package/dist/reference/consistency.js +76 -26
  67. package/dist/reference/deck-remark.d.mts +123 -0
  68. package/dist/reference/deck-remark.mjs +600 -0
  69. package/dist/reference/deck-source.d.ts +107 -0
  70. package/dist/reference/deck-source.js +140 -0
  71. package/dist/reference/decks-validate.d.ts +133 -0
  72. package/dist/reference/decks-validate.js +359 -0
  73. package/dist/reference/derived-entries.d.ts +5 -1
  74. package/dist/reference/diagram-fit.d.ts +1 -1
  75. package/dist/reference/figure-symbols.d.ts +23 -6
  76. package/dist/reference/figure-symbols.js +51 -15
  77. package/dist/reference/float-name.d.ts +43 -0
  78. package/dist/reference/float-name.js +58 -0
  79. package/dist/reference/folds-validate.d.ts +1 -1
  80. package/dist/reference/gate-abbreviations.d.ts +1 -1
  81. package/dist/reference/gate-indices.js +5 -4
  82. package/dist/reference/gate-math.d.ts +29 -2
  83. package/dist/reference/gate-math.js +99 -18
  84. package/dist/reference/gate-part-pages.d.ts +1 -1
  85. package/dist/reference/lab-katex-validate.d.ts +10 -6
  86. package/dist/reference/lab-katex-validate.js +17 -9
  87. package/dist/reference/lab-math-scope.js +29 -4
  88. package/dist/reference/math-glyphs.d.mts +25 -0
  89. package/dist/reference/math-glyphs.mjs +189 -25
  90. package/dist/reference/page-notation.d.ts +22 -3
  91. package/dist/reference/page-notation.js +32 -10
  92. package/dist/reference/references.d.ts +14 -1
  93. package/dist/reference/references.js +13 -0
  94. package/dist/reference/registry-scope.d.mts +33 -0
  95. package/dist/reference/registry-scope.mjs +34 -0
  96. package/dist/reference/registry.js +29 -10
  97. package/dist/reference/rehype-deck.d.mts +6 -0
  98. package/dist/reference/rehype-deck.mjs +286 -0
  99. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  100. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  101. package/dist/reference/remark-notation.mjs +249 -40
  102. package/dist/reference/source-lines.d.mts +12 -0
  103. package/dist/reference/source-lines.mjs +65 -0
  104. package/dist/reference/types.d.ts +28 -0
  105. package/dist/rules/catalog.d.ts +21 -4
  106. package/dist/rules/catalog.js +221 -2
  107. package/dist/rules/course-rules.d.ts +5 -1
  108. package/dist/rules/course-rules.js +17 -3
  109. package/dist/rules/course-rules.schema.json +12 -0
  110. package/dist/rules/in-effect.d.ts +13 -3
  111. package/dist/rules/in-effect.js +30 -7
  112. package/dist/rules/rules.schema.json +259 -1
  113. package/dist/rules/settings.d.ts +1 -1
  114. package/dist/session/PreferenceStore.d.ts +6 -0
  115. package/dist/session/PreferenceStore.js +18 -1
  116. package/dist/session/deck-memory.d.ts +116 -0
  117. package/dist/session/deck-memory.js +143 -0
  118. package/dist/session/reading-memory.d.ts +96 -0
  119. package/dist/session/reading-memory.js +133 -0
  120. package/docs/VERSIONING.md +21 -18
  121. package/docs/authoring/README.md +29 -9
  122. package/docs/authoring/agents.md +63 -16
  123. package/docs/authoring/components.md +15 -0
  124. package/docs/authoring/deck.md +588 -0
  125. package/docs/authoring/migrating.md +1087 -14
  126. package/docs/authoring/notation.md +49 -6
  127. package/docs/authoring/structure.md +80 -1
  128. package/docs/authoring/versioning.md +54 -2
  129. package/docs/authoring/visualizations.md +7 -2
  130. package/docs/course-agent/AGENTS.md +37 -0
  131. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  132. package/docs/course-agent/skills/draft-lesson/SKILL.md +37 -0
  133. package/docs/course-agent/skills/migrate-course/SKILL.md +31 -0
  134. package/docs/course-agent/skills/release-content/SKILL.md +24 -0
  135. package/docs/diagram-scaling.md +148 -0
  136. package/docs/privacy.md +59 -0
  137. package/package.json +8 -2
  138. package/src/compiler/astro.ts +60 -21
  139. package/src/compiler/cli-main.ts +469 -0
  140. package/src/compiler/cli.ts +1905 -16
  141. package/src/compiler/collections.ts +284 -25
  142. package/src/compiler/course-config.ts +267 -0
  143. package/src/compiler/decks.ts +455 -0
  144. package/src/compiler/exports/deck-fit.ts +127 -0
  145. package/src/compiler/exports/export-chapters.ts +105 -6
  146. package/src/compiler/exports/export-pages.ts +46 -2
  147. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  148. package/src/compiler/exports/latex/cache.ts +183 -0
  149. package/src/compiler/exports/latex/capture.ts +5 -2
  150. package/src/compiler/exports/latex/compile.ts +18 -0
  151. package/src/compiler/exports/latex/deck.ts +419 -0
  152. package/src/compiler/exports/latex/document.ts +117 -21
  153. package/src/compiler/exports/latex/explico-common.sty +198 -0
  154. package/src/compiler/exports/latex/explico.cls +13 -183
  155. package/src/compiler/exports/latex/serialize.ts +25 -21
  156. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  157. package/src/compiler/exports/latex-export.ts +187 -45
  158. package/src/compiler/exports/markdown/serialize.ts +118 -9
  159. package/src/compiler/exports/markdown-export.ts +56 -11
  160. package/src/compiler/exports/standalone-html.ts +8 -7
  161. package/src/compiler/exports/static-tree.ts +175 -3
  162. package/src/compiler/knowledge-version.ts +56 -21
  163. package/src/compiler/lesson-derivation.ts +282 -16
  164. package/src/compiler/manifest.ts +558 -63
  165. package/src/compiler/page-versions.ts +359 -0
  166. package/src/compiler/resolution-report.ts +38 -7
  167. package/src/compiler/validate-content.ts +212 -0
  168. package/src/components/CourseContents.astro +124 -6
  169. package/src/components/CurriculumMap.astro +49 -1
  170. package/src/components/FloatIndex.astro +1 -1
  171. package/src/components/PartOverview.astro +51 -5
  172. package/src/components/assessments/AssessmentSet.astro +41 -15
  173. package/src/components/block-heading.ts +15 -6
  174. package/src/components/card-carry.ts +7 -4
  175. package/src/components/card-manager.ts +14 -2
  176. package/src/components/card-memory.ts +13 -9
  177. package/src/components/deck/DeckEnhancer.astro +13 -0
  178. package/src/components/deck/deck-context.ts +104 -0
  179. package/src/components/deck/deck-controller.ts +1287 -0
  180. package/src/components/deck/deck-map.ts +91 -0
  181. package/src/components/deck-lane.ts +163 -0
  182. package/src/components/diagram/Diagram.astro +131 -86
  183. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  184. package/src/components/figure/Figure.astro +7 -2
  185. package/src/components/figure/FloatCaption.astro +5 -3
  186. package/src/components/fold/Fold.astro +2 -2
  187. package/src/components/notation/NotationFormula.astro +81 -11
  188. package/src/components/notation/NotationGlossary.astro +132 -1
  189. package/src/components/notation/NotationLayer.astro +29 -0
  190. package/src/components/starlight/AboutPanel.astro +186 -3
  191. package/src/components/starlight/CollapsibleSections.astro +24 -3
  192. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  193. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  194. package/src/components/starlight/LayoutHeader.astro +17 -0
  195. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  196. package/src/components/starlight/LayoutSidebar.astro +807 -21
  197. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  198. package/src/components/starlight/LessonExportActions.astro +27 -9
  199. package/src/components/starlight/LessonFooter.astro +59 -0
  200. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  201. package/src/components/starlight/TextSizeControl.astro +24 -13
  202. package/src/components/starlight/page-marks.ts +139 -0
  203. package/src/components/starlight/section-summary.ts +1 -1
  204. package/src/components/starlight/sidebar-editions.ts +245 -0
  205. package/src/components/table/Table.astro +15 -7
  206. package/src/content-config.ts +87 -1
  207. package/src/curriculum/competency-graph.ts +95 -0
  208. package/src/reference/base-library.mjs +19 -0
  209. package/src/reference/consistency.ts +126 -39
  210. package/src/reference/deck-remark.mjs +600 -0
  211. package/src/reference/deck-source.ts +273 -0
  212. package/src/reference/decks-validate.ts +604 -0
  213. package/src/reference/diagram-layout.ts +3 -1
  214. package/src/reference/figure-symbols.ts +108 -31
  215. package/src/reference/folds-validate.ts +1 -1
  216. package/src/reference/gate-indices.ts +5 -3
  217. package/src/reference/gate-math.ts +169 -25
  218. package/src/reference/lab-katex-validate.ts +30 -15
  219. package/src/reference/lab-math-scope.ts +34 -3
  220. package/src/reference/math-glyphs.mjs +189 -25
  221. package/src/reference/page-notation.ts +69 -15
  222. package/src/reference/references.ts +28 -0
  223. package/src/reference/registry-scope.mjs +34 -0
  224. package/src/reference/registry.ts +41 -11
  225. package/src/reference/rehype-deck.mjs +286 -0
  226. package/src/reference/rehype-heading-anchors.mjs +48 -6
  227. package/src/reference/remark-notation.mjs +249 -40
  228. package/src/reference/source-lines.mjs +65 -0
  229. package/src/reference/types.ts +28 -0
  230. package/src/rules/catalog.ts +257 -4
  231. package/src/rules/course-rules.ts +21 -1
  232. package/src/rules/in-effect.ts +54 -8
  233. package/src/session/PreferenceStore.ts +23 -0
  234. package/src/session/deck-memory.ts +263 -0
  235. package/src/session/reading-memory.ts +243 -0
  236. package/src/styles/deck.css +1150 -0
  237. package/src/styles/global.css +958 -27
  238. package/src/styles/zones.css +277 -0
  239. /package/src/{components → reference}/float-name.ts +0 -0
@@ -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 { isCourseRuleId, ownerOf, ELEMENTS } from "../rules/catalog.js";
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) => findLesson(manifest, 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
  };