explico 0.6.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 (73) hide show
  1. package/CHANGELOG.md +180 -1
  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/exports/standalone-html.js +13 -0
  8. package/dist/compiler/logging.d.ts +109 -0
  9. package/dist/compiler/logging.js +107 -0
  10. package/dist/compiler/manifest.d.ts +7 -3
  11. package/dist/compiler/manifest.js +1 -1
  12. package/dist/reference/base-abbreviations.mjs +8 -2
  13. package/dist/reference/consistency.d.ts +8 -10
  14. package/dist/reference/consistency.js +3 -47
  15. package/dist/reference/delimiter-size.d.ts +39 -0
  16. package/dist/reference/delimiter-size.js +177 -0
  17. package/dist/reference/diagrams-validate.d.ts +1 -1
  18. package/dist/reference/equations-validate.d.ts +2 -2
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/figures-validate.d.ts +1 -1
  21. package/dist/reference/gate-math.d.ts +2 -0
  22. package/dist/reference/gate-math.js +28 -6
  23. package/dist/reference/keyed-float-validate.d.ts +2 -2
  24. package/dist/reference/keyed-float-validate.js +1 -1
  25. package/dist/reference/references.js +2 -1
  26. package/dist/reference/registry.js +1 -1
  27. package/dist/reference/rehype-base-links.d.mts +23 -0
  28. package/dist/reference/rehype-base-links.mjs +40 -0
  29. package/dist/reference/remark-abbreviations.mjs +10 -12
  30. package/dist/reference/remark-citation.mjs +0 -0
  31. package/dist/reference/render-lab-math.d.ts +18 -1
  32. package/dist/reference/render-lab-math.js +10 -3
  33. package/dist/reference/tables-validate.d.ts +1 -1
  34. package/dist/reference/types.d.ts +1 -1
  35. package/docs/VERSIONING.md +2 -2
  36. package/docs/authoring/README.md +12 -2
  37. package/docs/authoring/agents.md +7 -3
  38. package/docs/authoring/migrating.md +286 -0
  39. package/docs/authoring/notation.md +139 -7
  40. package/docs/authoring/prose.md +64 -0
  41. package/package.json +1 -1
  42. package/src/compiler/astro.ts +89 -0
  43. package/src/compiler/cli.ts +144 -12
  44. package/src/compiler/exports/standalone-html.ts +15 -0
  45. package/src/compiler/logging.ts +248 -0
  46. package/src/compiler/manifest.ts +7 -3
  47. package/src/components/memo/Memo.astro +2 -5
  48. package/src/components/memo/ShortNote.astro +2 -5
  49. package/src/components/notation/NotationFormula.astro +199 -10
  50. package/src/components/notation/NotationGlossary.astro +89 -58
  51. package/src/components/notation/NotationLayer.astro +83 -81
  52. package/src/components/scroll-animation.ts +160 -0
  53. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  54. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  55. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  56. package/src/components/starlight/LessonFooter.astro +2 -0
  57. package/src/reference/base-abbreviations.mjs +8 -2
  58. package/src/reference/consistency.ts +10 -66
  59. package/src/reference/delimiter-size.ts +301 -0
  60. package/src/reference/diagrams-validate.ts +1 -1
  61. package/src/reference/equations-validate.ts +3 -3
  62. package/src/reference/figures-validate.ts +1 -1
  63. package/src/reference/gate-math.ts +41 -6
  64. package/src/reference/keyed-float-validate.ts +3 -3
  65. package/src/reference/references.ts +2 -1
  66. package/src/reference/registry.ts +1 -1
  67. package/src/reference/rehype-base-links.mjs +40 -0
  68. package/src/reference/remark-abbreviations.mjs +10 -12
  69. package/src/reference/remark-citation.mjs +0 -0
  70. package/src/reference/render-lab-math.ts +24 -1
  71. package/src/reference/tables-validate.ts +1 -1
  72. package/src/reference/types.ts +1 -1
  73. package/src/styles/global.css +119 -14
@@ -18,6 +18,9 @@
18
18
  * source metadata + locators, the section's domain signatures, and the
19
19
  * lesson's current diagnostics — and reports token counts against the doc
20
20
  * set + corpus so the size win is visible.
21
+ * - `content diagnostics [severity]` flattens every diagnostic category at
22
+ * or above a threshold into one stable, source-aware list for editors.
23
+ * - `content warnings` is the warning-only compatibility view.
21
24
  * - `content check` compiles the lesson MDX → remark → KaTeX → HTML and fails
22
25
  * on any renderability error (unparseable MDX, KaTeX error, unresolved
23
26
  * notation, unknown `[[key]]`, unknown citation), on top of the manifest's
@@ -45,6 +48,7 @@ import remarkNotation, {
45
48
  createRemarkNotationOptions,
46
49
  } from '../reference/remark-notation.mjs';
47
50
  import remarkShortNote from '../reference/remark-short-note.mjs';
51
+ import type { DiagnosticSeverity } from '../reference/types';
48
52
  import type { Manifest, ManifestLesson } from './manifest';
49
53
  import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
50
54
 
@@ -81,10 +85,15 @@ export class ContentCliError extends Error {
81
85
  /** One normalized diagnostic. `code` is `<source>:<kind>` and always stable. */
82
86
  export interface CliDiagnostic {
83
87
  readonly code: string;
84
- readonly severity: 'error' | 'warning';
88
+ readonly severity: DiagnosticSeverity;
85
89
  readonly message: string;
86
90
  readonly file?: string;
87
91
  readonly line?: number;
92
+ readonly column?: number;
93
+ }
94
+
95
+ function diagnosticSeverity(value: unknown): DiagnosticSeverity {
96
+ return value === 'info' || value === 'warning' ? value : 'error';
88
97
  }
89
98
 
90
99
  function mentions(message: string, lessonId: string, file: string): boolean {
@@ -115,7 +124,7 @@ export function lessonDiagnostics(
115
124
  if (!mentions(issue.message, id, file)) continue;
116
125
  out.push({
117
126
  code: `curriculum:${issue.kind}`,
118
- severity: issue.severity === 'warning' ? 'warning' : 'error',
127
+ severity: diagnosticSeverity(issue.severity),
119
128
  message: issue.message,
120
129
  });
121
130
  }
@@ -135,7 +144,7 @@ export function lessonDiagnostics(
135
144
  }
136
145
  out.push({
137
146
  code: `${group}:${entry.code}`,
138
- severity: entry.severity === 'warning' ? 'warning' : 'error',
147
+ severity: diagnosticSeverity(entry.severity),
139
148
  message: entry.message,
140
149
  ...withFile(entryFile),
141
150
  });
@@ -150,11 +159,12 @@ export function lessonDiagnostics(
150
159
  ));
151
160
  if (!forLesson) continue;
152
161
  out.push({
153
- code: `math:${diagnostic.context}`,
162
+ code: `math:${diagnostic.code ?? diagnostic.context}`,
154
163
  severity: diagnostic.severity,
155
164
  message: diagnostic.message,
156
165
  file: diagnostic.file,
157
166
  ...(diagnostic.line === undefined ? {} : { line: diagnostic.line }),
167
+ ...(diagnostic.column === undefined ? {} : { column: diagnostic.column }),
158
168
  });
159
169
  }
160
170
 
@@ -168,7 +178,7 @@ export function lessonDiagnostics(
168
178
  }
169
179
  out.push({
170
180
  code: `equations:${diagnostic.code}`,
171
- severity: diagnostic.severity === 'warning' ? 'warning' : 'error',
181
+ severity: diagnosticSeverity(diagnostic.severity),
172
182
  message: diagnostic.message,
173
183
  ...withFile(diagnostic.file),
174
184
  });
@@ -190,7 +200,7 @@ export function lessonDiagnostics(
190
200
  }
191
201
  out.push({
192
202
  code: diagnostic.code,
193
- severity: diagnostic.severity === 'warning' ? 'warning' : 'error',
203
+ severity: diagnosticSeverity(diagnostic.severity),
194
204
  message: diagnostic.message,
195
205
  ...withFile(diagnostic.file),
196
206
  });
@@ -200,7 +210,7 @@ export function lessonDiagnostics(
200
210
  if (diagnostic.file !== file && diagnostic.lessonId !== id) continue;
201
211
  out.push({
202
212
  code: `consistency:${diagnostic.code}`,
203
- severity: 'warning',
213
+ severity: diagnostic.severity,
204
214
  message: diagnostic.message,
205
215
  ...withFile(diagnostic.file),
206
216
  });
@@ -500,6 +510,7 @@ export interface StatusReport {
500
510
  readonly diagnostics: {
501
511
  readonly errors: number;
502
512
  readonly warnings: number;
513
+ readonly infos: number;
503
514
  };
504
515
  }
505
516
 
@@ -517,10 +528,7 @@ export function contentStatus(manifest: Manifest): StatusReport {
517
528
  .filter((diagnostic) => diagnostic.code === 'unused-definition')
518
529
  .map((diagnostic) => ({
519
530
  code: `notation:${diagnostic.code}`,
520
- severity:
521
- diagnostic.severity === 'warning'
522
- ? ('warning' as const)
523
- : ('error' as const),
531
+ severity: diagnosticSeverity(diagnostic.severity),
524
532
  message: diagnostic.message,
525
533
  ...(diagnostic.source?.file === undefined
526
534
  ? {}
@@ -539,7 +547,7 @@ export function contentStatus(manifest: Manifest): StatusReport {
539
547
  ...manifest.diagnostics.diagrams.map((d) => d.severity),
540
548
  ...manifest.diagnostics.checks.map((d) => d.severity),
541
549
  ...manifest.diagnostics.rawFloats.map((d) => d.severity),
542
- ...manifest.diagnostics.consistency.map(() => 'warning' as const),
550
+ ...manifest.diagnostics.consistency.map((d) => d.severity),
543
551
  ...manifest.diagnostics.knowledge.map((d) => d.severity),
544
552
  ];
545
553
 
@@ -583,10 +591,134 @@ export function contentStatus(manifest: Manifest): StatusReport {
583
591
  errors: allDiagnostics.filter((severity) => severity === 'error').length,
584
592
  warnings: allDiagnostics.filter((severity) => severity === 'warning')
585
593
  .length,
594
+ infos: allDiagnostics.filter((severity) => severity === 'info').length,
586
595
  },
587
596
  };
588
597
  }
589
598
 
599
+ // --- content diagnostics -------------------------------------------------
600
+
601
+ export type DiagnosticThreshold = DiagnosticSeverity;
602
+
603
+ export interface DiagnosticsReport {
604
+ readonly minimumSeverity: DiagnosticThreshold;
605
+ readonly count: number;
606
+ readonly diagnostics: readonly CliDiagnostic[];
607
+ }
608
+
609
+ export interface WarningsReport {
610
+ readonly count: number;
611
+ readonly warnings: readonly CliDiagnostic[];
612
+ }
613
+
614
+ type DiagnosticRecord = Record<string, unknown> & {
615
+ readonly code?: unknown;
616
+ readonly kind?: unknown;
617
+ readonly severity?: unknown;
618
+ readonly message?: unknown;
619
+ readonly file?: unknown;
620
+ readonly lessonId?: unknown;
621
+ readonly line?: unknown;
622
+ readonly column?: unknown;
623
+ readonly source?: unknown;
624
+ };
625
+
626
+ function numericLocation(value: unknown): number | undefined {
627
+ return typeof value === 'number' && Number.isInteger(value) && value > 0
628
+ ? value
629
+ : undefined;
630
+ }
631
+
632
+ const SEVERITY_RANK: Readonly<Record<DiagnosticSeverity, number>> = {
633
+ info: 0,
634
+ warning: 1,
635
+ error: 2,
636
+ };
637
+
638
+ /** Manifest diagnostics at or above a severity, normalized for tooling. */
639
+ export function contentDiagnostics(
640
+ manifest: Manifest,
641
+ minimumSeverity: DiagnosticThreshold = 'info',
642
+ ): DiagnosticsReport {
643
+ const lessonFileByHint = new Map<string, string>();
644
+ for (const lesson of manifest.lessons) {
645
+ lessonFileByHint.set(lesson.id, lesson.file);
646
+ lessonFileByHint.set(lesson.slug, lesson.file);
647
+ lessonFileByHint.set(lesson.file, lesson.file);
648
+ }
649
+
650
+ const groups = Object.entries(manifest.diagnostics) as readonly [
651
+ string,
652
+ readonly unknown[],
653
+ ][];
654
+ const diagnostics: CliDiagnostic[] = [];
655
+ for (const [group, values] of groups) {
656
+ for (const value of values) {
657
+ const diagnostic = value as DiagnosticRecord;
658
+ const severity = diagnosticSeverity(diagnostic.severity);
659
+ if (SEVERITY_RANK[severity] < SEVERITY_RANK[minimumSeverity]) continue;
660
+ const source =
661
+ diagnostic.source !== null && typeof diagnostic.source === 'object'
662
+ ? (diagnostic.source as Record<string, unknown>)
663
+ : undefined;
664
+ const rawFile =
665
+ typeof diagnostic.file === 'string'
666
+ ? diagnostic.file
667
+ : typeof source?.file === 'string'
668
+ ? source.file
669
+ : undefined;
670
+ const lessonId =
671
+ typeof diagnostic.lessonId === 'string'
672
+ ? diagnostic.lessonId
673
+ : undefined;
674
+ const file =
675
+ (rawFile === undefined ? undefined : lessonFileByHint.get(rawFile)) ??
676
+ (lessonId === undefined ? undefined : lessonFileByHint.get(lessonId)) ??
677
+ rawFile;
678
+ const line =
679
+ numericLocation(diagnostic.line) ?? numericLocation(source?.line);
680
+ const column =
681
+ numericLocation(diagnostic.column) ?? numericLocation(source?.column);
682
+ const detail =
683
+ typeof diagnostic.code === 'string'
684
+ ? diagnostic.code
685
+ : typeof diagnostic.kind === 'string'
686
+ ? diagnostic.kind
687
+ : 'diagnostic';
688
+ diagnostics.push({
689
+ code: `${group}:${detail}`,
690
+ severity,
691
+ message: String(diagnostic.message ?? detail),
692
+ ...(file === undefined ? {} : { file }),
693
+ ...(line === undefined ? {} : { line }),
694
+ ...(column === undefined ? {} : { column }),
695
+ });
696
+ }
697
+ }
698
+
699
+ diagnostics.sort(
700
+ (left, right) =>
701
+ (left.file ?? '').localeCompare(right.file ?? '') ||
702
+ (left.line ?? 0) - (right.line ?? 0) ||
703
+ (left.column ?? 0) - (right.column ?? 0) ||
704
+ left.code.localeCompare(right.code) ||
705
+ left.message.localeCompare(right.message),
706
+ );
707
+ return {
708
+ minimumSeverity,
709
+ count: diagnostics.length,
710
+ diagnostics,
711
+ };
712
+ }
713
+
714
+ /** Every warning exactly; errors and informational advice are excluded. */
715
+ export function contentWarnings(manifest: Manifest): WarningsReport {
716
+ const warnings = contentDiagnostics(manifest, 'warning').diagnostics.filter(
717
+ (diagnostic) => diagnostic.severity === 'warning',
718
+ );
719
+ return { count: warnings.length, warnings };
720
+ }
721
+
590
722
  // --- content context ---------------------------------------------------
591
723
 
592
724
  export interface ContextReport {
@@ -722,6 +722,20 @@ async function inlineResourceAttribute(
722
722
  setAttribute(node, name, await assetDataUrl(path, context));
723
723
  }
724
724
 
725
+ const INLINEABLE_LINK_RELS: ReadonlySet<string> = new Set([
726
+ 'stylesheet',
727
+ 'icon',
728
+ 'shortcut icon',
729
+ 'apple-touch-icon',
730
+ 'apple-touch-icon-precomposed',
731
+ 'mask-icon',
732
+ ]);
733
+
734
+ function isInlineableLinkRel(node: HtmlElement): boolean {
735
+ const rel = attribute(node, 'rel');
736
+ return rel !== undefined && INLINEABLE_LINK_RELS.has(rel.trim().toLowerCase());
737
+ }
738
+
725
739
  function parseSourceSet(value: string): readonly { url: string; descriptor: string }[] {
726
740
  const candidates = value.match(/(?:[^\s,]+)(?:\s+(?:\d+w|\d+(?:\.\d+)?x))?/gu) ?? [];
727
741
  return candidates.map((candidate) => {
@@ -748,6 +762,7 @@ async function inlineMedia(document: HtmlNode, context: ExportContext): Promise<
748
762
  'Standalone HTML does not support an iframe src; use self-contained semantic DOM instead.',
749
763
  );
750
764
  }
765
+ if (node.tagName === 'link' && !isInlineableLinkRel(node)) continue;
751
766
  const names = resources[node.tagName] ?? [];
752
767
  for (const name of names) await inlineResourceAttribute(node, name, context);
753
768
  if (node.tagName === 'use' || node.tagName === 'image') {
@@ -0,0 +1,248 @@
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
+
36
+ import {
37
+ appendFileSync,
38
+ existsSync,
39
+ mkdirSync,
40
+ rmSync,
41
+ symlinkSync,
42
+ writeFileSync,
43
+ } from 'node:fs';
44
+ import { join, relative } from 'node:path';
45
+
46
+ const REPOSITORY_ROOT = process.cwd();
47
+ /** Sits beside `build/manifest.json` and `build/resolution/` (Phase F). */
48
+ export const LOGS_DIR = join(REPOSITORY_ROOT, 'build', 'logs');
49
+
50
+ /** Python's levels, narrowed to the three the engine actually emits. */
51
+ export type LogLevel = 'ERROR' | 'WARNING' | 'INFO';
52
+
53
+ /** Ordered most severe first; a file at index `i` takes levels `0..i`. */
54
+ const LEVELS: readonly LogLevel[] = ['ERROR', 'WARNING', 'INFO'];
55
+
56
+ /** `error.log` holds `ERROR`, `warning.log` also `WARNING`, and so on. */
57
+ const FILES: readonly { readonly name: string; readonly level: LogLevel }[] = [
58
+ { name: 'error.log', level: 'ERROR' },
59
+ { name: 'warning.log', level: 'WARNING' },
60
+ { name: 'info.log', level: 'INFO' },
61
+ ];
62
+
63
+ /** `true` when a record at `record` belongs in a file whose floor is `file`. */
64
+ function includes(file: LogLevel, record: LogLevel): boolean {
65
+ return LEVELS.indexOf(record) <= LEVELS.indexOf(file);
66
+ }
67
+
68
+ function pad(value: number, width = 2): string {
69
+ return String(value).padStart(width, '0');
70
+ }
71
+
72
+ /**
73
+ * Python's `%(asctime)s` — local time, with the comma before the milliseconds
74
+ * that `logging.Formatter` writes by default.
75
+ */
76
+ export function formatTimestamp(date: Date): string {
77
+ return (
78
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
79
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
80
+ `,${pad(date.getMilliseconds(), 3)}`
81
+ );
82
+ }
83
+
84
+ /** A filesystem-safe local-time stamp for the run directory's name. */
85
+ export function formatRunStamp(date: Date): string {
86
+ return (
87
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T` +
88
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
89
+ );
90
+ }
91
+
92
+ /**
93
+ * `explico.<source>` — the logger name is the diagnostic's subsystem, so
94
+ * filtering by name is filtering by subsystem. `rawFloats` becomes
95
+ * `explico.raw-floats`; anything already lowercase passes through.
96
+ */
97
+ export function loggerName(source: string): string {
98
+ const normalized = source
99
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
100
+ .toLowerCase();
101
+ return normalized.startsWith('explico.')
102
+ ? normalized
103
+ : `explico.${normalized}`;
104
+ }
105
+
106
+ /** One formatted line, without its trailing newline. */
107
+ export function formatRecord(
108
+ level: LogLevel,
109
+ source: string,
110
+ message: string,
111
+ date: Date,
112
+ ): string {
113
+ return `${formatTimestamp(date)} ${level} ${loggerName(source)}: ${message}`;
114
+ }
115
+
116
+ export interface RunLogSummary {
117
+ /** Absolute path of this run's directory. */
118
+ readonly directory: string;
119
+ /** Repo-relative form, for printing. */
120
+ readonly relativeDirectory: string;
121
+ readonly counts: Readonly<Record<LogLevel, number>>;
122
+ }
123
+
124
+ /** One diagnostic, across every category's slightly different shape. */
125
+ export interface AnyDiagnostic {
126
+ readonly severity?: string;
127
+ readonly message: string;
128
+ readonly code?: string;
129
+ readonly kind?: string;
130
+ readonly file?: string;
131
+ readonly line?: number;
132
+ readonly column?: number;
133
+ }
134
+
135
+ /** `manifest.diagnostics`, structurally — one array per subsystem. */
136
+ export type DiagnosticGroups = Readonly<
137
+ Record<string, readonly AnyDiagnostic[]>
138
+ >;
139
+
140
+ export interface RunLog {
141
+ /** Absolute path of this run's directory. */
142
+ readonly directory: string;
143
+ /** Records at `ERROR` and returns `message`. */
144
+ error(source: string, message: string): string;
145
+ /** Records at `WARNING` and returns `message`. */
146
+ warning(source: string, message: string): string;
147
+ /** Records at `INFO` and returns `message`. */
148
+ info(source: string, message: string): string;
149
+ /**
150
+ * Sweeps every severity-`info` diagnostic in a manifest's `diagnostics` into
151
+ * `info.log`. Errors and warnings are deliberately **not** taken from here:
152
+ * they are recorded at the call site that also prints the console line, so
153
+ * the two can never drift.
154
+ */
155
+ infoDiagnostics(diagnostics: DiagnosticGroups): number;
156
+ /** Refreshes `latest` and reports what the run recorded. */
157
+ close(): RunLogSummary;
158
+ }
159
+
160
+ /** `[code] message`, matching what the console has always printed. */
161
+ export function diagnosticLine(diagnostic: AnyDiagnostic): string {
162
+ const code = diagnostic.code ?? diagnostic.kind;
163
+ const at = [diagnostic.file, diagnostic.line, diagnostic.column]
164
+ .filter((part) => part !== undefined)
165
+ .join(':');
166
+ const prefix = code === undefined ? '' : `[${code}] `;
167
+ return at === ''
168
+ ? `${prefix}${diagnostic.message}`
169
+ : `${prefix}${at}: ${diagnostic.message}`;
170
+ }
171
+
172
+ export interface RunLogOptions {
173
+ /** Overridden in tests; defaults to `build/logs` under the repository root. */
174
+ readonly root?: string;
175
+ /** Overridden in tests so the directory name is deterministic. */
176
+ readonly now?: Date;
177
+ /** Set `false` to skip the `latest` symlink. */
178
+ readonly latest?: boolean;
179
+ }
180
+
181
+ /**
182
+ * Opens a run directory and returns its log. The directory and its three files
183
+ * are created up front, so a run that crashes before {@link RunLog.close} still
184
+ * leaves everything recorded up to that point on disk.
185
+ */
186
+ export function createRunLog(options: RunLogOptions = {}): RunLog {
187
+ const root = options.root ?? LOGS_DIR;
188
+ const started = options.now ?? new Date();
189
+ const stamp = formatRunStamp(started);
190
+
191
+ // Two runs inside the same second get `…-2`, `…-3`, rather than collide.
192
+ let directory = join(root, stamp);
193
+ for (let suffix = 2; existsSync(directory); suffix += 1) {
194
+ directory = join(root, `${stamp}-${suffix}`);
195
+ }
196
+ mkdirSync(directory, { recursive: true });
197
+ for (const file of FILES) writeFileSync(join(directory, file.name), '');
198
+
199
+ const counts: Record<LogLevel, number> = { ERROR: 0, WARNING: 0, INFO: 0 };
200
+
201
+ const record = (level: LogLevel, source: string, message: string): string => {
202
+ counts[level] += 1;
203
+ const line = `${formatRecord(level, source, message, new Date())}\n`;
204
+ for (const file of FILES) {
205
+ if (includes(file.level, level)) {
206
+ appendFileSync(join(directory, file.name), line);
207
+ }
208
+ }
209
+ return message;
210
+ };
211
+
212
+ return {
213
+ directory,
214
+ error: (source, message) => record('ERROR', source, message),
215
+ warning: (source, message) => record('WARNING', source, message),
216
+ info: (source, message) => record('INFO', source, message),
217
+ infoDiagnostics(diagnostics) {
218
+ let written = 0;
219
+ for (const [source, group] of Object.entries(diagnostics)) {
220
+ if (!Array.isArray(group)) continue;
221
+ for (const diagnostic of group) {
222
+ if (diagnostic.severity !== 'info') continue;
223
+ record('INFO', source, diagnosticLine(diagnostic));
224
+ written += 1;
225
+ }
226
+ }
227
+ return written;
228
+ },
229
+ close() {
230
+ if (options.latest !== false) {
231
+ // Convenience only — a filesystem that refuses symlinks must not fail
232
+ // a content build that otherwise passed.
233
+ const link = join(root, 'latest');
234
+ try {
235
+ rmSync(link, { force: true });
236
+ symlinkSync(directory, link, 'dir');
237
+ } catch {
238
+ /* a missing `latest` is not worth failing a run over */
239
+ }
240
+ }
241
+ return {
242
+ directory,
243
+ relativeDirectory: relative(REPOSITORY_ROOT, directory),
244
+ counts: { ...counts },
245
+ };
246
+ },
247
+ };
248
+ }
@@ -186,8 +186,12 @@ import {
186
186
  * engine-authored sections, not written by a lesson, so they get their own
187
187
  * default independent of a course's `##` heading default; closed unless the
188
188
  * course sets one.
189
+ * v15 (diagnostic severity): public diagnostic severity unions add `info` for
190
+ * optional advice. Unused keys/definitions and restructuring candidates move
191
+ * from `warning` to `info`; their diagnostic codes and container fields stay
192
+ * unchanged.
189
193
  */
190
- export const MANIFEST_SCHEMA_VERSION = 14;
194
+ export const MANIFEST_SCHEMA_VERSION = 15;
191
195
 
192
196
  const REPOSITORY_ROOT = process.cwd();
193
197
  // Content is the top-level `content/` folder; the build output is `build/`
@@ -453,11 +457,11 @@ export interface Manifest {
453
457
  readonly notation: readonly NotationDiagnostic[];
454
458
  readonly alignment: readonly NotationDiagnostic[];
455
459
  readonly math: readonly MathGateDiagnostic[];
456
- /** Tier-3 corpus consistency checks (D6). Warnings only. */
460
+ /** Tier-3 corpus consistency checks (D6). Warnings and optional info. */
457
461
  readonly consistency: readonly ConsistencyDiagnostic[];
458
462
  /** Knowledge-version checks (C3): `about.version` vs `content/CHANGELOG.md`. Warnings only. */
459
463
  readonly knowledge: readonly KnowledgeVersionDiagnostic[];
460
- /** Equation-identity checks (D7): `eq-duplicate-key`, `eq-ref-resolves` (error); `eq-key-unused` (warning). */
464
+ /** Equation identity: broken identities are errors; unused keys are info. */
461
465
  readonly equations: readonly EquationDiagnostic[];
462
466
  /** `<Table>` identity checks (F-tables-figures) — same codes, `tab-` prefixed. */
463
467
  readonly tables: readonly TableDiagnostic[];
@@ -239,8 +239,8 @@ if (!number || !anchorId) {
239
239
  <script>
240
240
  import { attachHoverPeek, closeHoverPeek } from '../disclosure/hover-preview';
241
241
  import { wirePersistedDetails } from '../disclosure/persisted-disclosure';
242
+ import { animatedScrollIntoView } from '../scroll-animation';
242
243
 
243
- const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
244
244
  let memoPreviewSerial = 0;
245
245
 
246
246
  function cloneMemoBody(body: HTMLElement): HTMLElement {
@@ -442,10 +442,7 @@ if (!number || !anchorId) {
442
442
  history.pushState(null, '', `#${details.id}`);
443
443
  }
444
444
  requestAnimationFrame(() => {
445
- details.scrollIntoView({
446
- behavior: reduceMotion.matches ? 'auto' : 'smooth',
447
- block: 'start',
448
- });
445
+ animatedScrollIntoView(details, { block: 'start' });
449
446
  });
450
447
  });
451
448
  }
@@ -116,7 +116,7 @@ if (!anchorId) {
116
116
  </style>
117
117
 
118
118
  <script>
119
- const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
119
+ import { animatedScrollIntoView } from '../scroll-animation';
120
120
 
121
121
  document
122
122
  .querySelectorAll<HTMLElement>('.short-note:not([data-short-note-wired])')
@@ -185,10 +185,7 @@ if (!anchorId) {
185
185
  history.pushState(null, '', `#${note.id}`);
186
186
  }
187
187
  requestAnimationFrame(() => {
188
- note.scrollIntoView({
189
- behavior: reduceMotion.matches ? 'auto' : 'smooth',
190
- block: 'nearest',
191
- });
188
+ animatedScrollIntoView(note, { block: 'nearest' });
192
189
  });
193
190
  };
194
191