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.
- package/CHANGELOG.md +295 -7
- package/README.md +16 -2
- package/dist/compiler/astro.d.ts +13 -2
- package/dist/compiler/astro.js +39 -0
- package/dist/compiler/cli.d.ts +22 -3
- package/dist/compiler/cli.js +71 -10
- package/dist/compiler/course-config.d.ts +31 -1
- package/dist/compiler/course-config.js +7 -0
- package/dist/compiler/exports/standalone-html.js +13 -0
- package/dist/compiler/logging.d.ts +109 -0
- package/dist/compiler/logging.js +107 -0
- package/dist/compiler/manifest.d.ts +16 -4
- package/dist/compiler/manifest.js +7 -3
- package/dist/reference/base-abbreviations.mjs +8 -2
- package/dist/reference/consistency.d.ts +8 -10
- package/dist/reference/consistency.js +3 -47
- package/dist/reference/delimiter-size.d.ts +39 -0
- package/dist/reference/delimiter-size.js +177 -0
- package/dist/reference/diagrams-validate.d.ts +1 -1
- package/dist/reference/equations-validate.d.ts +2 -2
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/gate-heading-length.d.ts +12 -0
- package/dist/reference/gate-heading-length.js +79 -0
- package/dist/reference/gate-math.d.ts +8 -2
- package/dist/reference/gate-math.js +30 -7
- package/dist/reference/keyed-float-validate.d.ts +2 -2
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/math-glyphs.d.mts +12 -0
- package/dist/reference/math-glyphs.mjs +183 -42
- package/dist/reference/references.js +2 -1
- package/dist/reference/registry.js +1 -1
- package/dist/reference/rehype-base-links.d.mts +23 -0
- package/dist/reference/rehype-base-links.mjs +40 -0
- package/dist/reference/remark-abbreviations.mjs +10 -12
- package/dist/reference/remark-citation.d.mts +5 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/render-lab-math.d.ts +18 -1
- package/dist/reference/render-lab-math.js +10 -3
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +1 -1
- package/docs/VERSIONING.md +24 -4
- package/docs/authoring/README.md +14 -2
- package/docs/authoring/agents.md +7 -3
- package/docs/authoring/components.md +4 -1
- package/docs/authoring/migrating.md +477 -8
- package/docs/authoring/notation.md +194 -7
- package/docs/authoring/prose.md +64 -0
- package/docs/authoring/structure-rules.md +69 -0
- package/docs/authoring/visualizations.md +2 -2
- package/package.json +1 -1
- package/src/compiler/astro.ts +89 -0
- package/src/compiler/cli.ts +144 -12
- package/src/compiler/course-config.ts +40 -0
- package/src/compiler/exports/standalone-html.ts +15 -0
- package/src/compiler/logging.ts +248 -0
- package/src/compiler/manifest.ts +20 -3
- package/src/components/diagram/Diagram.astro +0 -55
- package/src/components/memo/Memo.astro +2 -5
- package/src/components/memo/ShortNote.astro +2 -5
- package/src/components/notation/NotationFormula.astro +199 -10
- package/src/components/notation/NotationGlossary.astro +89 -58
- package/src/components/notation/NotationLayer.astro +238 -156
- package/src/components/scroll-animation.ts +160 -0
- package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
- package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
- package/src/components/starlight/LessonFooter.astro +5 -1
- package/src/content-config.ts +16 -1
- package/src/reference/base-abbreviations.mjs +8 -2
- package/src/reference/consistency.ts +10 -66
- package/src/reference/delimiter-size.ts +301 -0
- package/src/reference/diagrams-validate.ts +1 -1
- package/src/reference/equations-validate.ts +3 -3
- package/src/reference/figures-validate.ts +1 -1
- package/src/reference/gate-heading-length.ts +131 -0
- package/src/reference/gate-math.ts +48 -8
- package/src/reference/keyed-float-validate.ts +3 -3
- package/src/reference/math-glyphs.mjs +183 -42
- package/src/reference/references.ts +2 -1
- package/src/reference/registry.ts +1 -1
- package/src/reference/rehype-base-links.mjs +40 -0
- package/src/reference/remark-abbreviations.mjs +10 -12
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/render-lab-math.ts +24 -1
- package/src/reference/tables-validate.ts +1 -1
- package/src/reference/types.ts +1 -1
- package/src/styles/global.css +120 -15
package/src/compiler/cli.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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(() =>
|
|
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 {
|
|
@@ -116,6 +116,43 @@ export interface CourseAbout {
|
|
|
116
116
|
readonly changelogHref?: string;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* A course's chosen initial open/closed state for the engine's own built-in
|
|
121
|
+
* collapsible sections — the "Page notation" panel (`NotationLayer.astro`)
|
|
122
|
+
* and the generated "References" section (`remark-citation.mjs`). Each is
|
|
123
|
+
* independent of `defaultCollapsible.sections`/`.subsections` (an author's
|
|
124
|
+
* own `##` headings): both of these are engine-authored, not written by a
|
|
125
|
+
* lesson, so they need their own default rather than inheriting the site's
|
|
126
|
+
* general heading default.
|
|
127
|
+
*
|
|
128
|
+
* `true` starts open, `false` (or omitted) starts closed — the engine's own
|
|
129
|
+
* default is closed either way, matching a long panel a reader usually
|
|
130
|
+
* doesn't need open on arrival.
|
|
131
|
+
*/
|
|
132
|
+
export interface CourseDisclosureDefaults {
|
|
133
|
+
/** Initial state of the "Page notation" panel. Default: closed. */
|
|
134
|
+
readonly notation?: boolean;
|
|
135
|
+
/** Initial state of the generated "References" section. Default: closed. */
|
|
136
|
+
readonly references?: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The stable, non-optional form `NormalizedLessonExports` mirrors. */
|
|
140
|
+
export interface NormalizedDisclosureDefaults {
|
|
141
|
+
readonly notation: boolean;
|
|
142
|
+
readonly references: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Validate and canonicalize `CourseConfig.disclosureDefaults`. Absent (or
|
|
146
|
+
* an absent sub-field) resolves to closed, the engine's own default. */
|
|
147
|
+
export function normalizeDisclosureDefaults(
|
|
148
|
+
config: CourseConfig,
|
|
149
|
+
): NormalizedDisclosureDefaults {
|
|
150
|
+
return {
|
|
151
|
+
notation: config.disclosureDefaults?.notation === true,
|
|
152
|
+
references: config.disclosureDefaults?.references === true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
119
156
|
/** Optional, build-time lesson artifacts exposed to readers. */
|
|
120
157
|
export interface LessonExportsConfig {
|
|
121
158
|
/** Emit one self-contained, offline-capable HTML file per lesson. */
|
|
@@ -162,6 +199,9 @@ export interface CourseConfig {
|
|
|
162
199
|
readonly abbreviations?: CourseAbbreviations;
|
|
163
200
|
/** Per-lesson export actions and downloadable artifacts. Disabled when omitted. */
|
|
164
201
|
readonly lessonExports?: LessonExportsConfig;
|
|
202
|
+
/** Initial open/closed state for the engine's own built-in collapsible
|
|
203
|
+
* sections ("Page notation", "References"). Closed when omitted. */
|
|
204
|
+
readonly disclosureDefaults?: CourseDisclosureDefaults;
|
|
165
205
|
}
|
|
166
206
|
|
|
167
207
|
/** Validate and canonicalize the build-facing lesson-export configuration. */
|
|
@@ -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
|
+
}
|
package/src/compiler/manifest.ts
CHANGED
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
gateProseNotationInAssessments,
|
|
89
89
|
} from '../reference/gate-prose-notation';
|
|
90
90
|
import { gateAbbreviations } from '../reference/gate-abbreviations';
|
|
91
|
+
import { gateHeadingLength } from '../reference/gate-heading-length';
|
|
91
92
|
import {
|
|
92
93
|
splitMixedContent,
|
|
93
94
|
type ContentSegment,
|
|
@@ -109,8 +110,10 @@ import type {
|
|
|
109
110
|
import {
|
|
110
111
|
coursePartsOf,
|
|
111
112
|
normalizeLessonExports,
|
|
113
|
+
normalizeDisclosureDefaults,
|
|
112
114
|
type CourseConfig,
|
|
113
115
|
type NormalizedLessonExports,
|
|
116
|
+
type NormalizedDisclosureDefaults,
|
|
114
117
|
} from './course-config';
|
|
115
118
|
import {
|
|
116
119
|
resolveKnowledgeVersion,
|
|
@@ -177,8 +180,18 @@ import {
|
|
|
177
180
|
* validation and can never get a stable number or `[[…]]` reference; this
|
|
178
181
|
* flags it (warning-only — the manifest shape a client already reads is
|
|
179
182
|
* unchanged).
|
|
183
|
+
* v14 (disclosure defaults, additive): `disclosureDefaults` — the resolved
|
|
184
|
+
* initial open/closed state for the "Page notation" panel and the generated
|
|
185
|
+
* "References" section, from `CourseConfig.disclosureDefaults`. Both are
|
|
186
|
+
* engine-authored sections, not written by a lesson, so they get their own
|
|
187
|
+
* default independent of a course's `##` heading default; closed unless the
|
|
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.
|
|
180
193
|
*/
|
|
181
|
-
export const MANIFEST_SCHEMA_VERSION =
|
|
194
|
+
export const MANIFEST_SCHEMA_VERSION = 15;
|
|
182
195
|
|
|
183
196
|
const REPOSITORY_ROOT = process.cwd();
|
|
184
197
|
// Content is the top-level `content/` folder; the build output is `build/`
|
|
@@ -382,6 +395,8 @@ export interface Manifest {
|
|
|
382
395
|
readonly schemaVersion: number;
|
|
383
396
|
/** Normalized build/UI settings for opt-in per-lesson exports. */
|
|
384
397
|
readonly lessonExports: NormalizedLessonExports;
|
|
398
|
+
/** Initial open/closed state for "Page notation" and "References". */
|
|
399
|
+
readonly disclosureDefaults: NormalizedDisclosureDefaults;
|
|
385
400
|
/** Reader-facing identity + versions (the `[?]` About panel). */
|
|
386
401
|
readonly about: ManifestAbout;
|
|
387
402
|
readonly notation: {
|
|
@@ -442,11 +457,11 @@ export interface Manifest {
|
|
|
442
457
|
readonly notation: readonly NotationDiagnostic[];
|
|
443
458
|
readonly alignment: readonly NotationDiagnostic[];
|
|
444
459
|
readonly math: readonly MathGateDiagnostic[];
|
|
445
|
-
/** Tier-3 corpus consistency checks (D6). Warnings
|
|
460
|
+
/** Tier-3 corpus consistency checks (D6). Warnings and optional info. */
|
|
446
461
|
readonly consistency: readonly ConsistencyDiagnostic[];
|
|
447
462
|
/** Knowledge-version checks (C3): `about.version` vs `content/CHANGELOG.md`. Warnings only. */
|
|
448
463
|
readonly knowledge: readonly KnowledgeVersionDiagnostic[];
|
|
449
|
-
/** Equation
|
|
464
|
+
/** Equation identity: broken identities are errors; unused keys are info. */
|
|
450
465
|
readonly equations: readonly EquationDiagnostic[];
|
|
451
466
|
/** `<Table>` identity checks (F-tables-figures) — same codes, `tab-` prefixed. */
|
|
452
467
|
readonly tables: readonly TableDiagnostic[];
|
|
@@ -703,6 +718,7 @@ export async function compileManifest(
|
|
|
703
718
|
abbreviations: abbreviationsInput,
|
|
704
719
|
courseAbbreviations: courseConfig.abbreviations,
|
|
705
720
|
}),
|
|
721
|
+
...gateHeadingLength(notationInput.lessons),
|
|
706
722
|
];
|
|
707
723
|
|
|
708
724
|
// Pre-render every assessment `prompt` / `explanation` into text/math
|
|
@@ -1052,6 +1068,7 @@ export async function compileManifest(
|
|
|
1052
1068
|
return {
|
|
1053
1069
|
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
1054
1070
|
lessonExports: normalizeLessonExports(courseConfig),
|
|
1071
|
+
disclosureDefaults: normalizeDisclosureDefaults(courseConfig),
|
|
1055
1072
|
about,
|
|
1056
1073
|
notation: {
|
|
1057
1074
|
definitions: registry.definitions,
|