explico 0.6.0 → 0.8.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 +350 -1
- 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 +30 -4
- package/dist/compiler/cli.js +79 -10
- package/dist/compiler/collections.js +16 -3
- package/dist/compiler/exports/standalone-html.js +13 -0
- package/dist/compiler/logging.d.ts +119 -0
- package/dist/compiler/logging.js +108 -0
- package/dist/compiler/manifest.d.ts +7 -3
- package/dist/compiler/manifest.js +1 -1
- 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 +48 -0
- package/dist/reference/delimiter-size.js +153 -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/equations.d.mts +9 -3
- package/dist/reference/equations.mjs +7 -1
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/gate-abbreviations.js +2 -2
- package/dist/reference/gate-heading-length.js +2 -2
- package/dist/reference/gate-math.d.ts +25 -1
- package/dist/reference/gate-math.js +50 -9
- package/dist/reference/gate-prose-notation.d.ts +12 -1
- package/dist/reference/gate-prose-notation.js +5 -1
- package/dist/reference/katex-delimiters.d.mts +15 -0
- package/dist/reference/katex-delimiters.mjs +125 -0
- package/dist/reference/katex-options.mjs +4 -0
- package/dist/reference/keyed-float-validate.d.ts +2 -2
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/math-glyphs.mjs +16 -2
- 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.mjs +0 -0
- package/dist/reference/render-lab-math.d.ts +18 -1
- package/dist/reference/render-lab-math.js +11 -4
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +8 -1
- package/docs/VERSIONING.md +2 -2
- package/docs/authoring/README.md +12 -2
- package/docs/authoring/agents.md +7 -3
- package/docs/authoring/migrating.md +514 -0
- package/docs/authoring/notation.md +157 -7
- package/docs/authoring/prose.md +64 -0
- package/docs/katex-bracket-scaling.md +191 -0
- package/package.json +2 -1
- package/src/compiler/astro.ts +89 -0
- package/src/compiler/cli.ts +167 -12
- package/src/compiler/collections.ts +33 -3
- package/src/compiler/exports/standalone-html.ts +15 -0
- package/src/compiler/logging.ts +259 -0
- package/src/compiler/manifest.ts +7 -3
- 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 +100 -58
- package/src/components/notation/NotationLayer.astro +83 -81
- package/src/components/notation/render-notation-math.ts +111 -10
- package/src/components/scroll-animation.ts +160 -0
- package/src/components/starlight/AboutPanel.astro +17 -2
- 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 +2 -0
- package/src/reference/base-abbreviations.mjs +8 -2
- package/src/reference/consistency.ts +10 -66
- package/src/reference/delimiter-size.ts +279 -0
- package/src/reference/diagrams-validate.ts +1 -1
- package/src/reference/equations-validate.ts +3 -3
- package/src/reference/equations.mjs +7 -1
- package/src/reference/figures-validate.ts +1 -1
- package/src/reference/gate-abbreviations.ts +2 -2
- package/src/reference/gate-heading-length.ts +2 -2
- package/src/reference/gate-math.ts +91 -8
- package/src/reference/gate-prose-notation.ts +15 -1
- package/src/reference/katex-delimiters.mjs +125 -0
- package/src/reference/katex-options.mjs +4 -0
- package/src/reference/keyed-float-validate.ts +3 -3
- package/src/reference/math-glyphs.mjs +16 -2
- 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 +25 -2
- package/src/reference/tables-validate.ts +1 -1
- package/src/reference/types.ts +8 -1
- package/src/styles/global.css +119 -14
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,8 @@ 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';
|
|
52
|
+
import type { EquationLink } from '../reference/gate-math';
|
|
48
53
|
import type { Manifest, ManifestLesson } from './manifest';
|
|
49
54
|
import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
|
|
50
55
|
|
|
@@ -81,10 +86,22 @@ export class ContentCliError extends Error {
|
|
|
81
86
|
/** One normalized diagnostic. `code` is `<source>:<kind>` and always stable. */
|
|
82
87
|
export interface CliDiagnostic {
|
|
83
88
|
readonly code: string;
|
|
84
|
-
readonly severity:
|
|
89
|
+
readonly severity: DiagnosticSeverity;
|
|
85
90
|
readonly message: string;
|
|
86
91
|
readonly file?: string;
|
|
87
92
|
readonly line?: number;
|
|
93
|
+
readonly column?: number;
|
|
94
|
+
/**
|
|
95
|
+
* The keyed equation the finding sits in, when the math carries a
|
|
96
|
+
* `\label{eq:…}` — `{ key, href }`, the href being the anchor on the
|
|
97
|
+
* rendered page. A size-only finding inside a long `aligned` block is
|
|
98
|
+
* otherwise anonymous.
|
|
99
|
+
*/
|
|
100
|
+
readonly equation?: EquationLink;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function diagnosticSeverity(value: unknown): DiagnosticSeverity {
|
|
104
|
+
return value === 'info' || value === 'warning' ? value : 'error';
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
function mentions(message: string, lessonId: string, file: string): boolean {
|
|
@@ -115,7 +132,7 @@ export function lessonDiagnostics(
|
|
|
115
132
|
if (!mentions(issue.message, id, file)) continue;
|
|
116
133
|
out.push({
|
|
117
134
|
code: `curriculum:${issue.kind}`,
|
|
118
|
-
severity: issue.severity
|
|
135
|
+
severity: diagnosticSeverity(issue.severity),
|
|
119
136
|
message: issue.message,
|
|
120
137
|
});
|
|
121
138
|
}
|
|
@@ -135,7 +152,7 @@ export function lessonDiagnostics(
|
|
|
135
152
|
}
|
|
136
153
|
out.push({
|
|
137
154
|
code: `${group}:${entry.code}`,
|
|
138
|
-
severity: entry.severity
|
|
155
|
+
severity: diagnosticSeverity(entry.severity),
|
|
139
156
|
message: entry.message,
|
|
140
157
|
...withFile(entryFile),
|
|
141
158
|
});
|
|
@@ -150,11 +167,15 @@ export function lessonDiagnostics(
|
|
|
150
167
|
));
|
|
151
168
|
if (!forLesson) continue;
|
|
152
169
|
out.push({
|
|
153
|
-
code: `math:${diagnostic.context}`,
|
|
170
|
+
code: `math:${diagnostic.code ?? diagnostic.context}`,
|
|
154
171
|
severity: diagnostic.severity,
|
|
155
172
|
message: diagnostic.message,
|
|
156
173
|
file: diagnostic.file,
|
|
157
174
|
...(diagnostic.line === undefined ? {} : { line: diagnostic.line }),
|
|
175
|
+
...(diagnostic.column === undefined ? {} : { column: diagnostic.column }),
|
|
176
|
+
...(diagnostic.equation === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: { equation: diagnostic.equation }),
|
|
158
179
|
});
|
|
159
180
|
}
|
|
160
181
|
|
|
@@ -168,7 +189,7 @@ export function lessonDiagnostics(
|
|
|
168
189
|
}
|
|
169
190
|
out.push({
|
|
170
191
|
code: `equations:${diagnostic.code}`,
|
|
171
|
-
severity: diagnostic.severity
|
|
192
|
+
severity: diagnosticSeverity(diagnostic.severity),
|
|
172
193
|
message: diagnostic.message,
|
|
173
194
|
...withFile(diagnostic.file),
|
|
174
195
|
});
|
|
@@ -190,7 +211,7 @@ export function lessonDiagnostics(
|
|
|
190
211
|
}
|
|
191
212
|
out.push({
|
|
192
213
|
code: diagnostic.code,
|
|
193
|
-
severity: diagnostic.severity
|
|
214
|
+
severity: diagnosticSeverity(diagnostic.severity),
|
|
194
215
|
message: diagnostic.message,
|
|
195
216
|
...withFile(diagnostic.file),
|
|
196
217
|
});
|
|
@@ -200,7 +221,7 @@ export function lessonDiagnostics(
|
|
|
200
221
|
if (diagnostic.file !== file && diagnostic.lessonId !== id) continue;
|
|
201
222
|
out.push({
|
|
202
223
|
code: `consistency:${diagnostic.code}`,
|
|
203
|
-
severity:
|
|
224
|
+
severity: diagnostic.severity,
|
|
204
225
|
message: diagnostic.message,
|
|
205
226
|
...withFile(diagnostic.file),
|
|
206
227
|
});
|
|
@@ -500,6 +521,7 @@ export interface StatusReport {
|
|
|
500
521
|
readonly diagnostics: {
|
|
501
522
|
readonly errors: number;
|
|
502
523
|
readonly warnings: number;
|
|
524
|
+
readonly infos: number;
|
|
503
525
|
};
|
|
504
526
|
}
|
|
505
527
|
|
|
@@ -517,10 +539,7 @@ export function contentStatus(manifest: Manifest): StatusReport {
|
|
|
517
539
|
.filter((diagnostic) => diagnostic.code === 'unused-definition')
|
|
518
540
|
.map((diagnostic) => ({
|
|
519
541
|
code: `notation:${diagnostic.code}`,
|
|
520
|
-
severity:
|
|
521
|
-
diagnostic.severity === 'warning'
|
|
522
|
-
? ('warning' as const)
|
|
523
|
-
: ('error' as const),
|
|
542
|
+
severity: diagnosticSeverity(diagnostic.severity),
|
|
524
543
|
message: diagnostic.message,
|
|
525
544
|
...(diagnostic.source?.file === undefined
|
|
526
545
|
? {}
|
|
@@ -539,7 +558,7 @@ export function contentStatus(manifest: Manifest): StatusReport {
|
|
|
539
558
|
...manifest.diagnostics.diagrams.map((d) => d.severity),
|
|
540
559
|
...manifest.diagnostics.checks.map((d) => d.severity),
|
|
541
560
|
...manifest.diagnostics.rawFloats.map((d) => d.severity),
|
|
542
|
-
...manifest.diagnostics.consistency.map(() =>
|
|
561
|
+
...manifest.diagnostics.consistency.map((d) => d.severity),
|
|
543
562
|
...manifest.diagnostics.knowledge.map((d) => d.severity),
|
|
544
563
|
];
|
|
545
564
|
|
|
@@ -583,10 +602,146 @@ export function contentStatus(manifest: Manifest): StatusReport {
|
|
|
583
602
|
errors: allDiagnostics.filter((severity) => severity === 'error').length,
|
|
584
603
|
warnings: allDiagnostics.filter((severity) => severity === 'warning')
|
|
585
604
|
.length,
|
|
605
|
+
infos: allDiagnostics.filter((severity) => severity === 'info').length,
|
|
586
606
|
},
|
|
587
607
|
};
|
|
588
608
|
}
|
|
589
609
|
|
|
610
|
+
// --- content diagnostics -------------------------------------------------
|
|
611
|
+
|
|
612
|
+
export type DiagnosticThreshold = DiagnosticSeverity;
|
|
613
|
+
|
|
614
|
+
export interface DiagnosticsReport {
|
|
615
|
+
readonly minimumSeverity: DiagnosticThreshold;
|
|
616
|
+
readonly count: number;
|
|
617
|
+
readonly diagnostics: readonly CliDiagnostic[];
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export interface WarningsReport {
|
|
621
|
+
readonly count: number;
|
|
622
|
+
readonly warnings: readonly CliDiagnostic[];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
type DiagnosticRecord = Record<string, unknown> & {
|
|
626
|
+
readonly code?: unknown;
|
|
627
|
+
readonly kind?: unknown;
|
|
628
|
+
readonly severity?: unknown;
|
|
629
|
+
readonly message?: unknown;
|
|
630
|
+
readonly file?: unknown;
|
|
631
|
+
readonly lessonId?: unknown;
|
|
632
|
+
readonly line?: unknown;
|
|
633
|
+
readonly column?: unknown;
|
|
634
|
+
readonly source?: unknown;
|
|
635
|
+
readonly equation?: unknown;
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
/** `{ key, href }` as it survives the untyped manifest walk above. */
|
|
639
|
+
function equationLink(value: unknown): EquationLink | undefined {
|
|
640
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
641
|
+
const { key, href } = value as Record<string, unknown>;
|
|
642
|
+
return typeof key === 'string' && typeof href === 'string'
|
|
643
|
+
? { key, href }
|
|
644
|
+
: undefined;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function numericLocation(value: unknown): number | undefined {
|
|
648
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0
|
|
649
|
+
? value
|
|
650
|
+
: undefined;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const SEVERITY_RANK: Readonly<Record<DiagnosticSeverity, number>> = {
|
|
654
|
+
info: 0,
|
|
655
|
+
warning: 1,
|
|
656
|
+
error: 2,
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
/** Manifest diagnostics at or above a severity, normalized for tooling. */
|
|
660
|
+
export function contentDiagnostics(
|
|
661
|
+
manifest: Manifest,
|
|
662
|
+
minimumSeverity: DiagnosticThreshold = 'info',
|
|
663
|
+
): DiagnosticsReport {
|
|
664
|
+
const lessonFileByHint = new Map<string, string>();
|
|
665
|
+
for (const lesson of manifest.lessons) {
|
|
666
|
+
lessonFileByHint.set(lesson.id, lesson.file);
|
|
667
|
+
lessonFileByHint.set(lesson.slug, lesson.file);
|
|
668
|
+
lessonFileByHint.set(lesson.file, lesson.file);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const groups = Object.entries(manifest.diagnostics) as readonly [
|
|
672
|
+
string,
|
|
673
|
+
readonly unknown[],
|
|
674
|
+
][];
|
|
675
|
+
const diagnostics: CliDiagnostic[] = [];
|
|
676
|
+
for (const [group, values] of groups) {
|
|
677
|
+
for (const value of values) {
|
|
678
|
+
const diagnostic = value as DiagnosticRecord;
|
|
679
|
+
const severity = diagnosticSeverity(diagnostic.severity);
|
|
680
|
+
if (SEVERITY_RANK[severity] < SEVERITY_RANK[minimumSeverity]) continue;
|
|
681
|
+
const source =
|
|
682
|
+
diagnostic.source !== null && typeof diagnostic.source === 'object'
|
|
683
|
+
? (diagnostic.source as Record<string, unknown>)
|
|
684
|
+
: undefined;
|
|
685
|
+
const rawFile =
|
|
686
|
+
typeof diagnostic.file === 'string'
|
|
687
|
+
? diagnostic.file
|
|
688
|
+
: typeof source?.file === 'string'
|
|
689
|
+
? source.file
|
|
690
|
+
: undefined;
|
|
691
|
+
const lessonId =
|
|
692
|
+
typeof diagnostic.lessonId === 'string'
|
|
693
|
+
? diagnostic.lessonId
|
|
694
|
+
: undefined;
|
|
695
|
+
const file =
|
|
696
|
+
(rawFile === undefined ? undefined : lessonFileByHint.get(rawFile)) ??
|
|
697
|
+
(lessonId === undefined ? undefined : lessonFileByHint.get(lessonId)) ??
|
|
698
|
+
rawFile;
|
|
699
|
+
const line =
|
|
700
|
+
numericLocation(diagnostic.line) ?? numericLocation(source?.line);
|
|
701
|
+
const column =
|
|
702
|
+
numericLocation(diagnostic.column) ?? numericLocation(source?.column);
|
|
703
|
+
const detail =
|
|
704
|
+
typeof diagnostic.code === 'string'
|
|
705
|
+
? diagnostic.code
|
|
706
|
+
: typeof diagnostic.kind === 'string'
|
|
707
|
+
? diagnostic.kind
|
|
708
|
+
: 'diagnostic';
|
|
709
|
+
const equation = equationLink(diagnostic.equation);
|
|
710
|
+
diagnostics.push({
|
|
711
|
+
code: `${group}:${detail}`,
|
|
712
|
+
severity,
|
|
713
|
+
message: String(diagnostic.message ?? detail),
|
|
714
|
+
...(file === undefined ? {} : { file }),
|
|
715
|
+
...(line === undefined ? {} : { line }),
|
|
716
|
+
...(column === undefined ? {} : { column }),
|
|
717
|
+
...(equation === undefined ? {} : { equation }),
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
diagnostics.sort(
|
|
723
|
+
(left, right) =>
|
|
724
|
+
(left.file ?? '').localeCompare(right.file ?? '') ||
|
|
725
|
+
(left.line ?? 0) - (right.line ?? 0) ||
|
|
726
|
+
(left.column ?? 0) - (right.column ?? 0) ||
|
|
727
|
+
left.code.localeCompare(right.code) ||
|
|
728
|
+
left.message.localeCompare(right.message),
|
|
729
|
+
);
|
|
730
|
+
return {
|
|
731
|
+
minimumSeverity,
|
|
732
|
+
count: diagnostics.length,
|
|
733
|
+
diagnostics,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Every warning exactly; errors and informational advice are excluded. */
|
|
738
|
+
export function contentWarnings(manifest: Manifest): WarningsReport {
|
|
739
|
+
const warnings = contentDiagnostics(manifest, 'warning').diagnostics.filter(
|
|
740
|
+
(diagnostic) => diagnostic.severity === 'warning',
|
|
741
|
+
);
|
|
742
|
+
return { count: warnings.length, warnings };
|
|
743
|
+
}
|
|
744
|
+
|
|
590
745
|
// --- content context ---------------------------------------------------
|
|
591
746
|
|
|
592
747
|
export interface ContextReport {
|
|
@@ -80,6 +80,32 @@ interface ParsedDocument {
|
|
|
80
80
|
readonly data: Record<string, unknown>;
|
|
81
81
|
/** Markdown body, byte-identical to the previous `gray-matter` output. */
|
|
82
82
|
readonly body: string;
|
|
83
|
+
/**
|
|
84
|
+
* 1-based line *in the file* where {@link body} starts — the frontmatter
|
|
85
|
+
* block's height plus one. Every gate scans the body, so a finding's index
|
|
86
|
+
* is body-relative while the `file` it is reported against is not; without
|
|
87
|
+
* this offset a `file:line:column` diagnostic points at the wrong line (by
|
|
88
|
+
* the height of the frontmatter) and no editor can jump to it.
|
|
89
|
+
*/
|
|
90
|
+
readonly bodyLine: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The line `body` starts on in `raw`. `parseFrontmatter` returns the body as a
|
|
95
|
+
* suffix of the file, so the offset is what precedes it; the `indexOf` branch
|
|
96
|
+
* is a guard for a future parser that normalizes the text rather than slicing.
|
|
97
|
+
*/
|
|
98
|
+
function bodyStartLine(raw: string, body: string): number {
|
|
99
|
+
const offset = raw.endsWith(body)
|
|
100
|
+
? raw.length - body.length
|
|
101
|
+
: raw.indexOf(body);
|
|
102
|
+
if (offset <= 0) return 1;
|
|
103
|
+
|
|
104
|
+
let line = 1;
|
|
105
|
+
for (let index = 0; index < offset; index += 1) {
|
|
106
|
+
if (raw.charCodeAt(index) === 10) line += 1;
|
|
107
|
+
}
|
|
108
|
+
return line;
|
|
83
109
|
}
|
|
84
110
|
|
|
85
111
|
/**
|
|
@@ -87,11 +113,14 @@ interface ParsedDocument {
|
|
|
87
113
|
* did not; strip it so body offsets in diagnostics are unchanged.
|
|
88
114
|
*/
|
|
89
115
|
function parseDocument(path: string): ParsedDocument {
|
|
90
|
-
const
|
|
116
|
+
const raw = readFileSync(path, 'utf8');
|
|
117
|
+
const parsed = parseFrontmatter(raw);
|
|
118
|
+
const body = parsed.content.replace(/^\n/, '');
|
|
91
119
|
return {
|
|
92
120
|
file: relative(REPOSITORY_ROOT, path),
|
|
93
121
|
data: (parsed.frontmatter ?? {}) as Record<string, unknown>,
|
|
94
|
-
body
|
|
122
|
+
body,
|
|
123
|
+
bodyLine: bodyStartLine(raw, body),
|
|
95
124
|
};
|
|
96
125
|
}
|
|
97
126
|
|
|
@@ -484,7 +513,7 @@ function notationLessonEntries(): NotationLessonInput[] {
|
|
|
484
513
|
for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
|
|
485
514
|
const slug = docSlug(path);
|
|
486
515
|
if (!isLessonSlug(slug)) continue;
|
|
487
|
-
const { file, data, body } = parseDocument(path);
|
|
516
|
+
const { file, data, body, bodyLine } = parseDocument(path);
|
|
488
517
|
const notation = record(data.notation ?? {}, `${file} notation`);
|
|
489
518
|
const local = Array.isArray(notation.local) ? notation.local : [];
|
|
490
519
|
lessons.push({
|
|
@@ -495,6 +524,7 @@ function notationLessonEntries(): NotationLessonInput[] {
|
|
|
495
524
|
),
|
|
496
525
|
references: extractNotationReferences(body, file, undefined),
|
|
497
526
|
body,
|
|
527
|
+
bodyLine,
|
|
498
528
|
source: { file },
|
|
499
529
|
});
|
|
500
530
|
}
|
|
@@ -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,259 @@
|
|
|
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
|
+
/** `{ key, href }` when the finding belongs to a `\label{eq:…}` equation. */
|
|
134
|
+
readonly equation?: { readonly key: string; readonly href: string };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `manifest.diagnostics`, structurally — one array per subsystem. */
|
|
138
|
+
export type DiagnosticGroups = Readonly<
|
|
139
|
+
Record<string, readonly AnyDiagnostic[]>
|
|
140
|
+
>;
|
|
141
|
+
|
|
142
|
+
export interface RunLog {
|
|
143
|
+
/** Absolute path of this run's directory. */
|
|
144
|
+
readonly directory: string;
|
|
145
|
+
/** Records at `ERROR` and returns `message`. */
|
|
146
|
+
error(source: string, message: string): string;
|
|
147
|
+
/** Records at `WARNING` and returns `message`. */
|
|
148
|
+
warning(source: string, message: string): string;
|
|
149
|
+
/** Records at `INFO` and returns `message`. */
|
|
150
|
+
info(source: string, message: string): string;
|
|
151
|
+
/**
|
|
152
|
+
* Sweeps every severity-`info` diagnostic in a manifest's `diagnostics` into
|
|
153
|
+
* `info.log`. Errors and warnings are deliberately **not** taken from here:
|
|
154
|
+
* they are recorded at the call site that also prints the console line, so
|
|
155
|
+
* the two can never drift.
|
|
156
|
+
*/
|
|
157
|
+
infoDiagnostics(diagnostics: DiagnosticGroups): number;
|
|
158
|
+
/** Refreshes `latest` and reports what the run recorded. */
|
|
159
|
+
close(): RunLogSummary;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `[code] file:line:column: message`, matching what the console has always
|
|
164
|
+
* printed, with ` (eq:<key> -> <href>)` appended when the finding belongs to a
|
|
165
|
+
* keyed equation — so a size-only finding inside a long `aligned` block names
|
|
166
|
+
* the result it is about, not just its coordinates.
|
|
167
|
+
*/
|
|
168
|
+
export function diagnosticLine(diagnostic: AnyDiagnostic): string {
|
|
169
|
+
const code = diagnostic.code ?? diagnostic.kind;
|
|
170
|
+
const at = [diagnostic.file, diagnostic.line, diagnostic.column]
|
|
171
|
+
.filter((part) => part !== undefined)
|
|
172
|
+
.join(':');
|
|
173
|
+
const prefix = code === undefined ? '' : `[${code}] `;
|
|
174
|
+
const equation =
|
|
175
|
+
diagnostic.equation === undefined
|
|
176
|
+
? ''
|
|
177
|
+
: ` (eq:${diagnostic.equation.key} -> ${diagnostic.equation.href})`;
|
|
178
|
+
return at === ''
|
|
179
|
+
? `${prefix}${diagnostic.message}${equation}`
|
|
180
|
+
: `${prefix}${at}: ${diagnostic.message}${equation}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface RunLogOptions {
|
|
184
|
+
/** Overridden in tests; defaults to `build/logs` under the repository root. */
|
|
185
|
+
readonly root?: string;
|
|
186
|
+
/** Overridden in tests so the directory name is deterministic. */
|
|
187
|
+
readonly now?: Date;
|
|
188
|
+
/** Set `false` to skip the `latest` symlink. */
|
|
189
|
+
readonly latest?: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Opens a run directory and returns its log. The directory and its three files
|
|
194
|
+
* are created up front, so a run that crashes before {@link RunLog.close} still
|
|
195
|
+
* leaves everything recorded up to that point on disk.
|
|
196
|
+
*/
|
|
197
|
+
export function createRunLog(options: RunLogOptions = {}): RunLog {
|
|
198
|
+
const root = options.root ?? LOGS_DIR;
|
|
199
|
+
const started = options.now ?? new Date();
|
|
200
|
+
const stamp = formatRunStamp(started);
|
|
201
|
+
|
|
202
|
+
// Two runs inside the same second get `…-2`, `…-3`, rather than collide.
|
|
203
|
+
let directory = join(root, stamp);
|
|
204
|
+
for (let suffix = 2; existsSync(directory); suffix += 1) {
|
|
205
|
+
directory = join(root, `${stamp}-${suffix}`);
|
|
206
|
+
}
|
|
207
|
+
mkdirSync(directory, { recursive: true });
|
|
208
|
+
for (const file of FILES) writeFileSync(join(directory, file.name), '');
|
|
209
|
+
|
|
210
|
+
const counts: Record<LogLevel, number> = { ERROR: 0, WARNING: 0, INFO: 0 };
|
|
211
|
+
|
|
212
|
+
const record = (level: LogLevel, source: string, message: string): string => {
|
|
213
|
+
counts[level] += 1;
|
|
214
|
+
const line = `${formatRecord(level, source, message, new Date())}\n`;
|
|
215
|
+
for (const file of FILES) {
|
|
216
|
+
if (includes(file.level, level)) {
|
|
217
|
+
appendFileSync(join(directory, file.name), line);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return message;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
directory,
|
|
225
|
+
error: (source, message) => record('ERROR', source, message),
|
|
226
|
+
warning: (source, message) => record('WARNING', source, message),
|
|
227
|
+
info: (source, message) => record('INFO', source, message),
|
|
228
|
+
infoDiagnostics(diagnostics) {
|
|
229
|
+
let written = 0;
|
|
230
|
+
for (const [source, group] of Object.entries(diagnostics)) {
|
|
231
|
+
if (!Array.isArray(group)) continue;
|
|
232
|
+
for (const diagnostic of group) {
|
|
233
|
+
if (diagnostic.severity !== 'info') continue;
|
|
234
|
+
record('INFO', source, diagnosticLine(diagnostic));
|
|
235
|
+
written += 1;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return written;
|
|
239
|
+
},
|
|
240
|
+
close() {
|
|
241
|
+
if (options.latest !== false) {
|
|
242
|
+
// Convenience only — a filesystem that refuses symlinks must not fail
|
|
243
|
+
// a content build that otherwise passed.
|
|
244
|
+
const link = join(root, 'latest');
|
|
245
|
+
try {
|
|
246
|
+
rmSync(link, { force: true });
|
|
247
|
+
symlinkSync(directory, link, 'dir');
|
|
248
|
+
} catch {
|
|
249
|
+
/* a missing `latest` is not worth failing a run over */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
directory,
|
|
254
|
+
relativeDirectory: relative(REPOSITORY_ROOT, directory),
|
|
255
|
+
counts: { ...counts },
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
package/src/compiler/manifest.ts
CHANGED
|
@@ -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 =
|
|
194
|
+
export const MANIFEST_SCHEMA_VERSION = 16;
|
|
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
|
|
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
|
|
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
|
|
446
|
-
behavior: reduceMotion.matches ? 'auto' : 'smooth',
|
|
447
|
-
block: 'start',
|
|
448
|
-
});
|
|
445
|
+
animatedScrollIntoView(details, { block: 'start' });
|
|
449
446
|
});
|
|
450
447
|
});
|
|
451
448
|
}
|