explico 0.5.0 → 0.7.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 (88) hide show
  1. package/CHANGELOG.md +295 -7
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +22 -3
  6. package/dist/compiler/cli.js +71 -10
  7. package/dist/compiler/course-config.d.ts +31 -1
  8. package/dist/compiler/course-config.js +7 -0
  9. package/dist/compiler/exports/standalone-html.js +13 -0
  10. package/dist/compiler/logging.d.ts +109 -0
  11. package/dist/compiler/logging.js +107 -0
  12. package/dist/compiler/manifest.d.ts +16 -4
  13. package/dist/compiler/manifest.js +7 -3
  14. package/dist/reference/base-abbreviations.mjs +8 -2
  15. package/dist/reference/consistency.d.ts +8 -10
  16. package/dist/reference/consistency.js +3 -47
  17. package/dist/reference/delimiter-size.d.ts +39 -0
  18. package/dist/reference/delimiter-size.js +177 -0
  19. package/dist/reference/diagrams-validate.d.ts +1 -1
  20. package/dist/reference/equations-validate.d.ts +2 -2
  21. package/dist/reference/equations-validate.js +1 -1
  22. package/dist/reference/figures-validate.d.ts +1 -1
  23. package/dist/reference/gate-heading-length.d.ts +12 -0
  24. package/dist/reference/gate-heading-length.js +79 -0
  25. package/dist/reference/gate-math.d.ts +8 -2
  26. package/dist/reference/gate-math.js +30 -7
  27. package/dist/reference/keyed-float-validate.d.ts +2 -2
  28. package/dist/reference/keyed-float-validate.js +1 -1
  29. package/dist/reference/math-glyphs.d.mts +12 -0
  30. package/dist/reference/math-glyphs.mjs +183 -42
  31. package/dist/reference/references.js +2 -1
  32. package/dist/reference/registry.js +1 -1
  33. package/dist/reference/rehype-base-links.d.mts +23 -0
  34. package/dist/reference/rehype-base-links.mjs +40 -0
  35. package/dist/reference/remark-abbreviations.mjs +10 -12
  36. package/dist/reference/remark-citation.d.mts +5 -1
  37. package/dist/reference/remark-citation.mjs +0 -0
  38. package/dist/reference/render-lab-math.d.ts +18 -1
  39. package/dist/reference/render-lab-math.js +10 -3
  40. package/dist/reference/tables-validate.d.ts +1 -1
  41. package/dist/reference/types.d.ts +1 -1
  42. package/docs/VERSIONING.md +24 -4
  43. package/docs/authoring/README.md +14 -2
  44. package/docs/authoring/agents.md +7 -3
  45. package/docs/authoring/components.md +4 -1
  46. package/docs/authoring/migrating.md +477 -8
  47. package/docs/authoring/notation.md +194 -7
  48. package/docs/authoring/prose.md +64 -0
  49. package/docs/authoring/structure-rules.md +69 -0
  50. package/docs/authoring/visualizations.md +2 -2
  51. package/package.json +1 -1
  52. package/src/compiler/astro.ts +89 -0
  53. package/src/compiler/cli.ts +144 -12
  54. package/src/compiler/course-config.ts +40 -0
  55. package/src/compiler/exports/standalone-html.ts +15 -0
  56. package/src/compiler/logging.ts +248 -0
  57. package/src/compiler/manifest.ts +20 -3
  58. package/src/components/diagram/Diagram.astro +0 -55
  59. package/src/components/memo/Memo.astro +2 -5
  60. package/src/components/memo/ShortNote.astro +2 -5
  61. package/src/components/notation/NotationFormula.astro +199 -10
  62. package/src/components/notation/NotationGlossary.astro +89 -58
  63. package/src/components/notation/NotationLayer.astro +238 -156
  64. package/src/components/scroll-animation.ts +160 -0
  65. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  66. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  67. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  68. package/src/components/starlight/LessonFooter.astro +5 -1
  69. package/src/content-config.ts +16 -1
  70. package/src/reference/base-abbreviations.mjs +8 -2
  71. package/src/reference/consistency.ts +10 -66
  72. package/src/reference/delimiter-size.ts +301 -0
  73. package/src/reference/diagrams-validate.ts +1 -1
  74. package/src/reference/equations-validate.ts +3 -3
  75. package/src/reference/figures-validate.ts +1 -1
  76. package/src/reference/gate-heading-length.ts +131 -0
  77. package/src/reference/gate-math.ts +48 -8
  78. package/src/reference/keyed-float-validate.ts +3 -3
  79. package/src/reference/math-glyphs.mjs +183 -42
  80. package/src/reference/references.ts +2 -1
  81. package/src/reference/registry.ts +1 -1
  82. package/src/reference/rehype-base-links.mjs +40 -0
  83. package/src/reference/remark-abbreviations.mjs +10 -12
  84. package/src/reference/remark-citation.mjs +0 -0
  85. package/src/reference/render-lab-math.ts +24 -1
  86. package/src/reference/tables-validate.ts +1 -1
  87. package/src/reference/types.ts +1 -1
  88. package/src/styles/global.css +120 -15
@@ -30,6 +30,9 @@ class ContentCliError extends Error {
30
30
  this.code = code;
31
31
  }
32
32
  }
33
+ function diagnosticSeverity(value) {
34
+ return value === "info" || value === "warning" ? value : "error";
35
+ }
33
36
  function mentions(message, lessonId, file) {
34
37
  return message.includes(lessonId) || message.includes(file) || message.includes(file.replace(/^content\/docs\//, ""));
35
38
  }
@@ -42,7 +45,7 @@ function lessonDiagnostics(manifest, lesson) {
42
45
  if (!mentions(issue.message, id, file)) continue;
43
46
  out.push({
44
47
  code: `curriculum:${issue.kind}`,
45
- severity: issue.severity === "warning" ? "warning" : "error",
48
+ severity: diagnosticSeverity(issue.severity),
46
49
  message: issue.message
47
50
  });
48
51
  }
@@ -57,7 +60,7 @@ function lessonDiagnostics(manifest, lesson) {
57
60
  }
58
61
  out.push({
59
62
  code: `${group}:${entry.code}`,
60
- severity: entry.severity === "warning" ? "warning" : "error",
63
+ severity: diagnosticSeverity(entry.severity),
61
64
  message: entry.message,
62
65
  ...withFile(entryFile)
63
66
  });
@@ -68,11 +71,12 @@ function lessonDiagnostics(manifest, lesson) {
68
71
  );
69
72
  if (!forLesson) continue;
70
73
  out.push({
71
- code: `math:${diagnostic.context}`,
74
+ code: `math:${diagnostic.code ?? diagnostic.context}`,
72
75
  severity: diagnostic.severity,
73
76
  message: diagnostic.message,
74
77
  file: diagnostic.file,
75
- ...diagnostic.line === void 0 ? {} : { line: diagnostic.line }
78
+ ...diagnostic.line === void 0 ? {} : { line: diagnostic.line },
79
+ ...diagnostic.column === void 0 ? {} : { column: diagnostic.column }
76
80
  });
77
81
  }
78
82
  for (const diagnostic of manifest.diagnostics.equations) {
@@ -81,7 +85,7 @@ function lessonDiagnostics(manifest, lesson) {
81
85
  }
82
86
  out.push({
83
87
  code: `equations:${diagnostic.code}`,
84
- severity: diagnostic.severity === "warning" ? "warning" : "error",
88
+ severity: diagnosticSeverity(diagnostic.severity),
85
89
  message: diagnostic.message,
86
90
  ...withFile(diagnostic.file)
87
91
  });
@@ -98,7 +102,7 @@ function lessonDiagnostics(manifest, lesson) {
98
102
  }
99
103
  out.push({
100
104
  code: diagnostic.code,
101
- severity: diagnostic.severity === "warning" ? "warning" : "error",
105
+ severity: diagnosticSeverity(diagnostic.severity),
102
106
  message: diagnostic.message,
103
107
  ...withFile(diagnostic.file)
104
108
  });
@@ -107,7 +111,7 @@ function lessonDiagnostics(manifest, lesson) {
107
111
  if (diagnostic.file !== file && diagnostic.lessonId !== id) continue;
108
112
  out.push({
109
113
  code: `consistency:${diagnostic.code}`,
110
- severity: "warning",
114
+ severity: diagnostic.severity,
111
115
  message: diagnostic.message,
112
116
  ...withFile(diagnostic.file)
113
117
  });
@@ -301,7 +305,7 @@ function contentStatus(manifest) {
301
305
  const draftNotation = manifest.notation.definitions.filter((definition) => definition.status === "draft").map((definition) => definition.id).sort();
302
306
  const orphans = manifest.notation.diagnostics.filter((diagnostic) => diagnostic.code === "unused-definition").map((diagnostic) => ({
303
307
  code: `notation:${diagnostic.code}`,
304
- severity: diagnostic.severity === "warning" ? "warning" : "error",
308
+ severity: diagnosticSeverity(diagnostic.severity),
305
309
  message: diagnostic.message,
306
310
  ...diagnostic.source?.file === void 0 ? {} : { file: diagnostic.source.file }
307
311
  })).sort((a, b) => a.message.localeCompare(b.message));
@@ -316,7 +320,7 @@ function contentStatus(manifest) {
316
320
  ...manifest.diagnostics.diagrams.map((d) => d.severity),
317
321
  ...manifest.diagnostics.checks.map((d) => d.severity),
318
322
  ...manifest.diagnostics.rawFloats.map((d) => d.severity),
319
- ...manifest.diagnostics.consistency.map(() => "warning"),
323
+ ...manifest.diagnostics.consistency.map((d) => d.severity),
320
324
  ...manifest.diagnostics.knowledge.map((d) => d.severity)
321
325
  ];
322
326
  const keyedEquations = manifest.lessons.reduce(
@@ -356,10 +360,65 @@ function contentStatus(manifest) {
356
360
  orphans,
357
361
  diagnostics: {
358
362
  errors: allDiagnostics.filter((severity) => severity === "error").length,
359
- warnings: allDiagnostics.filter((severity) => severity === "warning").length
363
+ warnings: allDiagnostics.filter((severity) => severity === "warning").length,
364
+ infos: allDiagnostics.filter((severity) => severity === "info").length
365
+ }
366
+ };
367
+ }
368
+ function numericLocation(value) {
369
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
370
+ }
371
+ const SEVERITY_RANK = {
372
+ info: 0,
373
+ warning: 1,
374
+ error: 2
375
+ };
376
+ function contentDiagnostics(manifest, minimumSeverity = "info") {
377
+ const lessonFileByHint = /* @__PURE__ */ new Map();
378
+ for (const lesson of manifest.lessons) {
379
+ lessonFileByHint.set(lesson.id, lesson.file);
380
+ lessonFileByHint.set(lesson.slug, lesson.file);
381
+ lessonFileByHint.set(lesson.file, lesson.file);
382
+ }
383
+ const groups = Object.entries(manifest.diagnostics);
384
+ const diagnostics = [];
385
+ for (const [group, values] of groups) {
386
+ for (const value of values) {
387
+ const diagnostic = value;
388
+ const severity = diagnosticSeverity(diagnostic.severity);
389
+ if (SEVERITY_RANK[severity] < SEVERITY_RANK[minimumSeverity]) continue;
390
+ const source = diagnostic.source !== null && typeof diagnostic.source === "object" ? diagnostic.source : void 0;
391
+ const rawFile = typeof diagnostic.file === "string" ? diagnostic.file : typeof source?.file === "string" ? source.file : void 0;
392
+ const lessonId = typeof diagnostic.lessonId === "string" ? diagnostic.lessonId : void 0;
393
+ const file = (rawFile === void 0 ? void 0 : lessonFileByHint.get(rawFile)) ?? (lessonId === void 0 ? void 0 : lessonFileByHint.get(lessonId)) ?? rawFile;
394
+ const line = numericLocation(diagnostic.line) ?? numericLocation(source?.line);
395
+ const column = numericLocation(diagnostic.column) ?? numericLocation(source?.column);
396
+ const detail = typeof diagnostic.code === "string" ? diagnostic.code : typeof diagnostic.kind === "string" ? diagnostic.kind : "diagnostic";
397
+ diagnostics.push({
398
+ code: `${group}:${detail}`,
399
+ severity,
400
+ message: String(diagnostic.message ?? detail),
401
+ ...file === void 0 ? {} : { file },
402
+ ...line === void 0 ? {} : { line },
403
+ ...column === void 0 ? {} : { column }
404
+ });
360
405
  }
406
+ }
407
+ diagnostics.sort(
408
+ (left, right) => (left.file ?? "").localeCompare(right.file ?? "") || (left.line ?? 0) - (right.line ?? 0) || (left.column ?? 0) - (right.column ?? 0) || left.code.localeCompare(right.code) || left.message.localeCompare(right.message)
409
+ );
410
+ return {
411
+ minimumSeverity,
412
+ count: diagnostics.length,
413
+ diagnostics
361
414
  };
362
415
  }
416
+ function contentWarnings(manifest) {
417
+ const warnings = contentDiagnostics(manifest, "warning").diagnostics.filter(
418
+ (diagnostic) => diagnostic.severity === "warning"
419
+ );
420
+ return { count: warnings.length, warnings };
421
+ }
363
422
  function contentContext(manifest, rawId) {
364
423
  const lesson = findLesson(manifest, rawId);
365
424
  const { frontmatter, body } = lessonRaw(lesson);
@@ -599,7 +658,9 @@ export {
599
658
  changedLessonIds,
600
659
  contentCheck,
601
660
  contentContext,
661
+ contentDiagnostics,
602
662
  contentStatus,
663
+ contentWarnings,
603
664
  estimateTokens,
604
665
  findLesson,
605
666
  lessonDiagnostics,
@@ -107,6 +107,33 @@ interface CourseAbout {
107
107
  /** Link to the published content changelog (repo blob, docs page, …). */
108
108
  readonly changelogHref?: string;
109
109
  }
110
+ /**
111
+ * A course's chosen initial open/closed state for the engine's own built-in
112
+ * collapsible sections — the "Page notation" panel (`NotationLayer.astro`)
113
+ * and the generated "References" section (`remark-citation.mjs`). Each is
114
+ * independent of `defaultCollapsible.sections`/`.subsections` (an author's
115
+ * own `##` headings): both of these are engine-authored, not written by a
116
+ * lesson, so they need their own default rather than inheriting the site's
117
+ * general heading default.
118
+ *
119
+ * `true` starts open, `false` (or omitted) starts closed — the engine's own
120
+ * default is closed either way, matching a long panel a reader usually
121
+ * doesn't need open on arrival.
122
+ */
123
+ interface CourseDisclosureDefaults {
124
+ /** Initial state of the "Page notation" panel. Default: closed. */
125
+ readonly notation?: boolean;
126
+ /** Initial state of the generated "References" section. Default: closed. */
127
+ readonly references?: boolean;
128
+ }
129
+ /** The stable, non-optional form `NormalizedLessonExports` mirrors. */
130
+ interface NormalizedDisclosureDefaults {
131
+ readonly notation: boolean;
132
+ readonly references: boolean;
133
+ }
134
+ /** Validate and canonicalize `CourseConfig.disclosureDefaults`. Absent (or
135
+ * an absent sub-field) resolves to closed, the engine's own default. */
136
+ declare function normalizeDisclosureDefaults(config: CourseConfig): NormalizedDisclosureDefaults;
110
137
  /** Optional, build-time lesson artifacts exposed to readers. */
111
138
  interface LessonExportsConfig {
112
139
  /** Emit one self-contained, offline-capable HTML file per lesson. */
@@ -151,6 +178,9 @@ interface CourseConfig {
151
178
  readonly abbreviations?: CourseAbbreviations;
152
179
  /** Per-lesson export actions and downloadable artifacts. Disabled when omitted. */
153
180
  readonly lessonExports?: LessonExportsConfig;
181
+ /** Initial open/closed state for the engine's own built-in collapsible
182
+ * sections ("Page notation", "References"). Closed when omitted. */
183
+ readonly disclosureDefaults?: CourseDisclosureDefaults;
154
184
  }
155
185
  /** Validate and canonicalize the build-facing lesson-export configuration. */
156
186
  declare function normalizeLessonExports(config: CourseConfig): NormalizedLessonExports;
@@ -161,4 +191,4 @@ declare function normalizeLessonExports(config: CourseConfig): NormalizedLessonE
161
191
  */
162
192
  declare function coursePartsOf(config: CourseConfig): readonly CoursePart[];
163
193
 
164
- export { type CourseAbbreviations, type CourseAbout, type CourseAuthor, type CourseConfig, type CourseConventions, type CourseOperators, type CoursePart, type CourseRomanLabels, type CourseSection, type LessonExportsConfig, type NormalizedLessonExports, coursePartsOf, normalizeLessonExports };
194
+ export { type CourseAbbreviations, type CourseAbout, type CourseAuthor, type CourseConfig, type CourseConventions, type CourseDisclosureDefaults, type CourseOperators, type CoursePart, type CourseRomanLabels, type CourseSection, type LessonExportsConfig, type NormalizedDisclosureDefaults, type NormalizedLessonExports, coursePartsOf, normalizeDisclosureDefaults, normalizeLessonExports };
@@ -1,3 +1,9 @@
1
+ function normalizeDisclosureDefaults(config) {
2
+ return {
3
+ notation: config.disclosureDefaults?.notation === true,
4
+ references: config.disclosureDefaults?.references === true
5
+ };
6
+ }
1
7
  function normalizeLessonExports(config) {
2
8
  const standaloneHtml = config.lessonExports?.standaloneHtml === true;
3
9
  const print = config.lessonExports?.print === true;
@@ -35,5 +41,6 @@ function coursePartsOf(config) {
35
41
  }
36
42
  export {
37
43
  coursePartsOf,
44
+ normalizeDisclosureDefaults,
38
45
  normalizeLessonExports
39
46
  };
@@ -584,6 +584,18 @@ async function inlineResourceAttribute(node, name, context) {
584
584
  const path = await resolveLocalFile(reference, context.sourceFile, context.outputRoot);
585
585
  setAttribute(node, name, await assetDataUrl(path, context));
586
586
  }
587
+ const INLINEABLE_LINK_RELS = /* @__PURE__ */ new Set([
588
+ "stylesheet",
589
+ "icon",
590
+ "shortcut icon",
591
+ "apple-touch-icon",
592
+ "apple-touch-icon-precomposed",
593
+ "mask-icon"
594
+ ]);
595
+ function isInlineableLinkRel(node) {
596
+ const rel = attribute(node, "rel");
597
+ return rel !== void 0 && INLINEABLE_LINK_RELS.has(rel.trim().toLowerCase());
598
+ }
587
599
  function parseSourceSet(value) {
588
600
  const candidates = value.match(/(?:[^\s,]+)(?:\s+(?:\d+w|\d+(?:\.\d+)?x))?/gu) ?? [];
589
601
  return candidates.map((candidate) => {
@@ -609,6 +621,7 @@ async function inlineMedia(document, context) {
609
621
  "Standalone HTML does not support an iframe src; use self-contained semantic DOM instead."
610
622
  );
611
623
  }
624
+ if (node.tagName === "link" && !isInlineableLinkRel(node)) continue;
612
625
  const names = resources[node.tagName] ?? [];
613
626
  for (const name of names) await inlineResourceAttribute(node, name, context);
614
627
  if (node.tagName === "use" || node.tagName === "image") {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Run logs for `pnpm validate:content`, in Python's `logging` format.
3
+ *
4
+ * The terminal keeps the compact `[code] message` lines it has always shown.
5
+ * This module is the second handler over the same records: it writes them to
6
+ * `build/logs/<timestamp>/` in the format Python's `logging` emits, so a run
7
+ * can be grepped by level and by subsystem long after the terminal scrolled
8
+ * away.
9
+ *
10
+ * %(asctime)s %(levelname)s %(name)s: %(message)s
11
+ *
12
+ * 2026-09-18 03:22:47,482 WARNING explico.notation: [undefined-reference] …
13
+ *
14
+ * Three files per run, each a superset of the one above it, so you open the
15
+ * narrowest one that still answers the question:
16
+ *
17
+ * build/logs/2026-09-18T03-22-47/
18
+ * error.log errors only
19
+ * warning.log errors + warnings
20
+ * info.log errors + warnings + info
21
+ *
22
+ * Nothing is pruned — `build/` is git-ignored, so old runs are yours to clean
23
+ * up — and `build/logs/latest` points at the newest run.
24
+ *
25
+ * Levels carry their Python meaning. `error` is a blocking diagnostic (the
26
+ * caller still throws; this module only records), `warning` is a non-blocking
27
+ * finding, and `info` is the run summary plus the severity-`info` diagnostics
28
+ * the engine has always produced but never printed.
29
+ *
30
+ * Each level method **returns its message**, so a call site can log and print
31
+ * in one expression without the two drifting apart:
32
+ *
33
+ * console.warn(log.warning('notation', `[${d.code}] ${d.message}`));
34
+ */
35
+ /** Sits beside `build/manifest.json` and `build/resolution/` (Phase F). */
36
+ declare const LOGS_DIR: string;
37
+ /** Python's levels, narrowed to the three the engine actually emits. */
38
+ type LogLevel = 'ERROR' | 'WARNING' | 'INFO';
39
+ /**
40
+ * Python's `%(asctime)s` — local time, with the comma before the milliseconds
41
+ * that `logging.Formatter` writes by default.
42
+ */
43
+ declare function formatTimestamp(date: Date): string;
44
+ /** A filesystem-safe local-time stamp for the run directory's name. */
45
+ declare function formatRunStamp(date: Date): string;
46
+ /**
47
+ * `explico.<source>` — the logger name is the diagnostic's subsystem, so
48
+ * filtering by name is filtering by subsystem. `rawFloats` becomes
49
+ * `explico.raw-floats`; anything already lowercase passes through.
50
+ */
51
+ declare function loggerName(source: string): string;
52
+ /** One formatted line, without its trailing newline. */
53
+ declare function formatRecord(level: LogLevel, source: string, message: string, date: Date): string;
54
+ interface RunLogSummary {
55
+ /** Absolute path of this run's directory. */
56
+ readonly directory: string;
57
+ /** Repo-relative form, for printing. */
58
+ readonly relativeDirectory: string;
59
+ readonly counts: Readonly<Record<LogLevel, number>>;
60
+ }
61
+ /** One diagnostic, across every category's slightly different shape. */
62
+ interface AnyDiagnostic {
63
+ readonly severity?: string;
64
+ readonly message: string;
65
+ readonly code?: string;
66
+ readonly kind?: string;
67
+ readonly file?: string;
68
+ readonly line?: number;
69
+ readonly column?: number;
70
+ }
71
+ /** `manifest.diagnostics`, structurally — one array per subsystem. */
72
+ type DiagnosticGroups = Readonly<Record<string, readonly AnyDiagnostic[]>>;
73
+ interface RunLog {
74
+ /** Absolute path of this run's directory. */
75
+ readonly directory: string;
76
+ /** Records at `ERROR` and returns `message`. */
77
+ error(source: string, message: string): string;
78
+ /** Records at `WARNING` and returns `message`. */
79
+ warning(source: string, message: string): string;
80
+ /** Records at `INFO` and returns `message`. */
81
+ info(source: string, message: string): string;
82
+ /**
83
+ * Sweeps every severity-`info` diagnostic in a manifest's `diagnostics` into
84
+ * `info.log`. Errors and warnings are deliberately **not** taken from here:
85
+ * they are recorded at the call site that also prints the console line, so
86
+ * the two can never drift.
87
+ */
88
+ infoDiagnostics(diagnostics: DiagnosticGroups): number;
89
+ /** Refreshes `latest` and reports what the run recorded. */
90
+ close(): RunLogSummary;
91
+ }
92
+ /** `[code] message`, matching what the console has always printed. */
93
+ declare function diagnosticLine(diagnostic: AnyDiagnostic): string;
94
+ interface RunLogOptions {
95
+ /** Overridden in tests; defaults to `build/logs` under the repository root. */
96
+ readonly root?: string;
97
+ /** Overridden in tests so the directory name is deterministic. */
98
+ readonly now?: Date;
99
+ /** Set `false` to skip the `latest` symlink. */
100
+ readonly latest?: boolean;
101
+ }
102
+ /**
103
+ * Opens a run directory and returns its log. The directory and its three files
104
+ * are created up front, so a run that crashes before {@link RunLog.close} still
105
+ * leaves everything recorded up to that point on disk.
106
+ */
107
+ declare function createRunLog(options?: RunLogOptions): RunLog;
108
+
109
+ export { type AnyDiagnostic, type DiagnosticGroups, LOGS_DIR, type LogLevel, type RunLog, type RunLogOptions, type RunLogSummary, createRunLog, diagnosticLine, formatRecord, formatRunStamp, formatTimestamp, loggerName };
@@ -0,0 +1,107 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ rmSync,
6
+ symlinkSync,
7
+ writeFileSync
8
+ } from "node:fs";
9
+ import { join, relative } from "node:path";
10
+ const REPOSITORY_ROOT = process.cwd();
11
+ const LOGS_DIR = join(REPOSITORY_ROOT, "build", "logs");
12
+ const LEVELS = ["ERROR", "WARNING", "INFO"];
13
+ const FILES = [
14
+ { name: "error.log", level: "ERROR" },
15
+ { name: "warning.log", level: "WARNING" },
16
+ { name: "info.log", level: "INFO" }
17
+ ];
18
+ function includes(file, record) {
19
+ return LEVELS.indexOf(record) <= LEVELS.indexOf(file);
20
+ }
21
+ function pad(value, width = 2) {
22
+ return String(value).padStart(width, "0");
23
+ }
24
+ function formatTimestamp(date) {
25
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())},${pad(date.getMilliseconds(), 3)}`;
26
+ }
27
+ function formatRunStamp(date) {
28
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
29
+ }
30
+ function loggerName(source) {
31
+ const normalized = source.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
32
+ return normalized.startsWith("explico.") ? normalized : `explico.${normalized}`;
33
+ }
34
+ function formatRecord(level, source, message, date) {
35
+ return `${formatTimestamp(date)} ${level} ${loggerName(source)}: ${message}`;
36
+ }
37
+ function diagnosticLine(diagnostic) {
38
+ const code = diagnostic.code ?? diagnostic.kind;
39
+ const at = [diagnostic.file, diagnostic.line, diagnostic.column].filter((part) => part !== void 0).join(":");
40
+ const prefix = code === void 0 ? "" : `[${code}] `;
41
+ return at === "" ? `${prefix}${diagnostic.message}` : `${prefix}${at}: ${diagnostic.message}`;
42
+ }
43
+ function createRunLog(options = {}) {
44
+ const root = options.root ?? LOGS_DIR;
45
+ const started = options.now ?? /* @__PURE__ */ new Date();
46
+ const stamp = formatRunStamp(started);
47
+ let directory = join(root, stamp);
48
+ for (let suffix = 2; existsSync(directory); suffix += 1) {
49
+ directory = join(root, `${stamp}-${suffix}`);
50
+ }
51
+ mkdirSync(directory, { recursive: true });
52
+ for (const file of FILES) writeFileSync(join(directory, file.name), "");
53
+ const counts = { ERROR: 0, WARNING: 0, INFO: 0 };
54
+ const record = (level, source, message) => {
55
+ counts[level] += 1;
56
+ const line = `${formatRecord(level, source, message, /* @__PURE__ */ new Date())}
57
+ `;
58
+ for (const file of FILES) {
59
+ if (includes(file.level, level)) {
60
+ appendFileSync(join(directory, file.name), line);
61
+ }
62
+ }
63
+ return message;
64
+ };
65
+ return {
66
+ directory,
67
+ error: (source, message) => record("ERROR", source, message),
68
+ warning: (source, message) => record("WARNING", source, message),
69
+ info: (source, message) => record("INFO", source, message),
70
+ infoDiagnostics(diagnostics) {
71
+ let written = 0;
72
+ for (const [source, group] of Object.entries(diagnostics)) {
73
+ if (!Array.isArray(group)) continue;
74
+ for (const diagnostic of group) {
75
+ if (diagnostic.severity !== "info") continue;
76
+ record("INFO", source, diagnosticLine(diagnostic));
77
+ written += 1;
78
+ }
79
+ }
80
+ return written;
81
+ },
82
+ close() {
83
+ if (options.latest !== false) {
84
+ const link = join(root, "latest");
85
+ try {
86
+ rmSync(link, { force: true });
87
+ symlinkSync(directory, link, "dir");
88
+ } catch {
89
+ }
90
+ }
91
+ return {
92
+ directory,
93
+ relativeDirectory: relative(REPOSITORY_ROOT, directory),
94
+ counts: { ...counts }
95
+ };
96
+ }
97
+ };
98
+ }
99
+ export {
100
+ LOGS_DIR,
101
+ createRunLog,
102
+ diagnosticLine,
103
+ formatRecord,
104
+ formatRunStamp,
105
+ formatTimestamp,
106
+ loggerName
107
+ };
@@ -10,7 +10,7 @@ import { MathGateDiagnostic } from '../reference/gate-math.js';
10
10
  import { ContentSegment } from '../reference/mixed-content.js';
11
11
  import { GlyphScopeEntry } from '../reference/gloss.js';
12
12
  import { NotationDefinitionRecord, NotationPageBundle, NotationBacklink, NotationDiagnostic, EditorialStatus, NotationGlossEntry } from '../reference/types.js';
13
- import { NormalizedLessonExports, CourseConfig } from './course-config.js';
13
+ import { NormalizedLessonExports, NormalizedDisclosureDefaults, CourseConfig } from './course-config.js';
14
14
  import { KnowledgeVersionDiagnostic } from './knowledge-version.js';
15
15
  import { SidebarGroup } from './lesson-derivation.js';
16
16
  import './collections.js';
@@ -90,8 +90,18 @@ import '../reference/keyed-float-validate.js';
90
90
  * validation and can never get a stable number or `[[…]]` reference; this
91
91
  * flags it (warning-only — the manifest shape a client already reads is
92
92
  * unchanged).
93
+ * v14 (disclosure defaults, additive): `disclosureDefaults` — the resolved
94
+ * initial open/closed state for the "Page notation" panel and the generated
95
+ * "References" section, from `CourseConfig.disclosureDefaults`. Both are
96
+ * engine-authored sections, not written by a lesson, so they get their own
97
+ * default independent of a course's `##` heading default; closed unless the
98
+ * course sets one.
99
+ * v15 (diagnostic severity): public diagnostic severity unions add `info` for
100
+ * optional advice. Unused keys/definitions and restructuring candidates move
101
+ * from `warning` to `info`; their diagnostic codes and container fields stay
102
+ * unchanged.
93
103
  */
94
- declare const MANIFEST_SCHEMA_VERSION = 13;
104
+ declare const MANIFEST_SCHEMA_VERSION = 15;
95
105
  declare const MANIFEST_PATH: string;
96
106
  interface ManifestCitation {
97
107
  readonly id: string;
@@ -256,6 +266,8 @@ interface Manifest {
256
266
  readonly schemaVersion: number;
257
267
  /** Normalized build/UI settings for opt-in per-lesson exports. */
258
268
  readonly lessonExports: NormalizedLessonExports;
269
+ /** Initial open/closed state for "Page notation" and "References". */
270
+ readonly disclosureDefaults: NormalizedDisclosureDefaults;
259
271
  /** Reader-facing identity + versions (the `[?]` About panel). */
260
272
  readonly about: ManifestAbout;
261
273
  readonly notation: {
@@ -316,11 +328,11 @@ interface Manifest {
316
328
  readonly notation: readonly NotationDiagnostic[];
317
329
  readonly alignment: readonly NotationDiagnostic[];
318
330
  readonly math: readonly MathGateDiagnostic[];
319
- /** Tier-3 corpus consistency checks (D6). Warnings only. */
331
+ /** Tier-3 corpus consistency checks (D6). Warnings and optional info. */
320
332
  readonly consistency: readonly ConsistencyDiagnostic[];
321
333
  /** Knowledge-version checks (C3): `about.version` vs `content/CHANGELOG.md`. Warnings only. */
322
334
  readonly knowledge: readonly KnowledgeVersionDiagnostic[];
323
- /** Equation-identity checks (D7): `eq-duplicate-key`, `eq-ref-resolves` (error); `eq-key-unused` (warning). */
335
+ /** Equation identity: broken identities are errors; unused keys are info. */
324
336
  readonly equations: readonly EquationDiagnostic[];
325
337
  /** `<Table>` identity checks (F-tables-figures) — same codes, `tab-` prefixed. */
326
338
  readonly tables: readonly TableDiagnostic[];
@@ -45,6 +45,7 @@ import {
45
45
  gateProseNotationInAssessments
46
46
  } from "../reference/gate-prose-notation.js";
47
47
  import { gateAbbreviations } from "../reference/gate-abbreviations.js";
48
+ import { gateHeadingLength } from "../reference/gate-heading-length.js";
48
49
  import {
49
50
  splitMixedContent
50
51
  } from "../reference/mixed-content.js";
@@ -54,7 +55,8 @@ import { formulaGlyphScope } from "../reference/gloss.js";
54
55
  import { resolveReferenceId } from "../reference/references.js";
55
56
  import {
56
57
  coursePartsOf,
57
- normalizeLessonExports
58
+ normalizeLessonExports,
59
+ normalizeDisclosureDefaults
58
60
  } from "./course-config.js";
59
61
  import {
60
62
  resolveKnowledgeVersion
@@ -73,7 +75,7 @@ import {
73
75
  isLessonSlug,
74
76
  lessonIdFromSlug
75
77
  } from "./lesson-derivation.js";
76
- const MANIFEST_SCHEMA_VERSION = 13;
78
+ const MANIFEST_SCHEMA_VERSION = 15;
77
79
  const REPOSITORY_ROOT = process.cwd();
78
80
  const CONTENT_ROOT = join(REPOSITORY_ROOT, "content");
79
81
  const MANIFEST_PATH = join(REPOSITORY_ROOT, "build", "manifest.json");
@@ -243,7 +245,8 @@ async function compileManifest(courseConfig) {
243
245
  notationInput,
244
246
  abbreviations: abbreviationsInput,
245
247
  courseAbbreviations: courseConfig.abbreviations
246
- })
248
+ }),
249
+ ...gateHeadingLength(notationInput.lessons)
247
250
  ];
248
251
  const notationLessonById = new Map(
249
252
  notationInput.lessons.map((lesson) => [lesson.lessonId, lesson])
@@ -517,6 +520,7 @@ async function compileManifest(courseConfig) {
517
520
  return {
518
521
  schemaVersion: MANIFEST_SCHEMA_VERSION,
519
522
  lessonExports: normalizeLessonExports(courseConfig),
523
+ disclosureDefaults: normalizeDisclosureDefaults(courseConfig),
520
524
  about,
521
525
  notation: {
522
526
  definitions: registry.definitions,
@@ -20,12 +20,18 @@ export const BASE_ABBREVIATIONS = Object.freeze(
20
20
  'e.g.',
21
21
  'i.e.',
22
22
  'etc.',
23
+ 'e.t.c.',
23
24
  'cf.',
24
25
  'vs.',
25
- 'a.m.',
26
- 'p.m.',
27
26
  'a.k.a.',
28
27
  'et al.',
28
+ // time of day, in each casing/punctuation style a lesson might use
29
+ 'a.m.',
30
+ 'p.m.',
31
+ 'A.M.',
32
+ 'P.M.',
33
+ 'AM',
34
+ 'PM',
29
35
  // generic acronyms with no domain-specific meaning in this engine
30
36
  'AI',
31
37
  'ID',
@@ -8,11 +8,11 @@ import { NotationRegistry, NotationRegistryInput } from './types.js';
8
8
  * Corpus consistency checks (Step D6) — the Tier 1–2 checks from the cleanup
9
9
  * plan's "Enforced checks" section that no earlier step covers.
10
10
  *
11
- * Every check here enters as a **Tier-3 warning**: `pnpm validate:content`
12
- * surfaces the counts, `manifest.diagnostics.consistency` records the detail,
13
- * and `resolution/consistency-report.json` is the committed, diff-checkable
14
- * snapshot. Nothing here changes content — each finding is an open item for a
15
- * separate human-quantitative-reviewed pass. A finding is silenced only by a
11
+ * Every check here is non-blocking Tier 3: repairable violations are warnings
12
+ * and optional restructuring advice is info. `pnpm validate:content` surfaces
13
+ * warning counts, `manifest.diagnostics.consistency` records every detail, and
14
+ * `resolution/consistency-report.json` is the committed, diff-checkable
15
+ * snapshot. Nothing here changes content. A finding is silenced only by a
16
16
  * reviewed line in `lint-ignore.yml` (see `./lint-ignore`).
17
17
  *
18
18
  * (a) glyph-unique-in-corpus one rendered glyph → one meaning corpus-wide
@@ -20,8 +20,6 @@ import { NotationRegistry, NotationRegistryInput } from './types.js';
20
20
  * unit strings drawn from a controlled vocab
21
21
  * (c) convention-single-definition a sign / cash-flow-perspective convention a
22
22
  * lesson uses resolves to one declaration
23
- * (e) numerals-tagged numerals in `$…$` / result tables are tagged
24
- * to a `src/domain/` call or a source locator
25
23
  * (f) weak-local a `notation.local` symbol used only in its
26
24
  * own defining equation and prose
27
25
  * (g) notation-source-locator every notation `sources` entry has a locator
@@ -37,11 +35,11 @@ import { NotationRegistry, NotationRegistryInput } from './types.js';
37
35
  * `src/content/resolution-report.ts`.
38
36
  */
39
37
 
40
- type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'numerals-tagged' | 'weak-local' | 'notation-source-locator' | 'gloss-wants-promoting' | 'card-wants-demoting' | 'gloss-name-shape' | 'operator-explained' | 'roman-label-explained';
38
+ type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'weak-local' | 'notation-source-locator' | 'gloss-wants-promoting' | 'card-wants-demoting' | 'gloss-name-shape' | 'operator-explained' | 'roman-label-explained';
41
39
  interface ConsistencyDiagnostic {
42
40
  readonly code: ConsistencyCode;
43
- /** Tier 3 — every finding here is a warning until content is brought clean. */
44
- readonly severity: 'warning';
41
+ /** Tier 3 — repairable violations are warnings; optional advice is info. */
42
+ readonly severity: 'warning' | 'info';
45
43
  readonly message: string;
46
44
  readonly file?: string;
47
45
  readonly lessonId?: string;