explico 0.4.0 → 0.6.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 +382 -1
- package/README.md +103 -8
- package/dist/compiler/astro.d.ts +9 -1
- package/dist/compiler/astro.js +182 -3
- package/dist/compiler/cli.d.ts +13 -0
- package/dist/compiler/cli.js +28 -4
- package/dist/compiler/collections.d.ts +3 -2
- package/dist/compiler/collections.js +28 -1
- package/dist/compiler/course-config.d.ts +88 -1
- package/dist/compiler/course-config.js +35 -1
- package/dist/compiler/exports/standalone-html.d.ts +34 -0
- package/dist/compiler/exports/standalone-html.js +803 -0
- package/dist/compiler/manifest.d.ts +98 -6
- package/dist/compiler/manifest.js +154 -17
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-abbreviations.d.mts +30 -0
- package/dist/reference/base-abbreviations.mjs +60 -0
- package/dist/reference/base-operators.d.mts +15 -0
- package/dist/reference/base-operators.mjs +40 -0
- package/dist/reference/base-roman-labels.d.mts +35 -0
- package/dist/reference/base-roman-labels.mjs +43 -0
- package/dist/reference/consistency.d.ts +8 -2
- package/dist/reference/consistency.js +50 -3
- package/dist/reference/diagrams-validate.d.ts +21 -0
- package/dist/reference/diagrams-validate.js +13 -0
- package/dist/reference/equations.d.mts +10 -15
- package/dist/reference/equations.mjs +67 -14
- package/dist/reference/gate-abbreviations.d.ts +17 -0
- package/dist/reference/gate-abbreviations.js +64 -0
- 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 +32 -5
- package/dist/reference/gate-math.js +6 -2
- package/dist/reference/gate-prose-notation.d.ts +60 -0
- package/dist/reference/gate-prose-notation.js +125 -0
- package/dist/reference/keyed-float-validate.d.ts +5 -5
- package/dist/reference/keyed-jsx-floats.d.mts +7 -12
- package/dist/reference/keyed-jsx-floats.mjs +50 -25
- package/dist/reference/knowledge-checks-validate.d.ts +35 -0
- package/dist/reference/knowledge-checks-validate.js +78 -0
- package/dist/reference/knowledge-checks.d.mts +25 -0
- package/dist/reference/knowledge-checks.mjs +45 -0
- package/dist/reference/lab-math-scope.d.ts +9 -1
- package/dist/reference/lab-math-scope.js +32 -4
- package/dist/reference/math-glyphs.d.mts +42 -0
- package/dist/reference/math-glyphs.mjs +548 -40
- package/dist/reference/mdx-attributes.d.mts +13 -0
- package/dist/reference/mdx-attributes.mjs +38 -0
- package/dist/reference/mixed-content.d.ts +17 -0
- package/dist/reference/mixed-content.js +29 -0
- package/dist/reference/page-notation.d.ts +52 -0
- package/dist/reference/page-notation.js +171 -0
- package/dist/reference/raw-floats-validate.d.ts +18 -0
- package/dist/reference/raw-floats-validate.js +53 -0
- package/dist/reference/raw-floats.d.mts +18 -0
- package/dist/reference/raw-floats.mjs +129 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -1
- package/dist/reference/rehype-heading-anchors.mjs +107 -1
- package/dist/reference/rehype-table-header-labels.d.mts +26 -0
- package/dist/reference/rehype-table-header-labels.mjs +152 -0
- package/dist/reference/remark-abbreviations.d.mts +7 -0
- package/dist/reference/remark-abbreviations.mjs +174 -0
- package/dist/reference/remark-citation.d.mts +5 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-memo.d.mts +16 -0
- package/dist/reference/remark-memo.mjs +339 -0
- package/dist/reference/remark-notation.d.mts +57 -9
- package/dist/reference/remark-notation.mjs +354 -42
- package/dist/reference/remark-short-note.d.mts +4 -0
- package/dist/reference/remark-short-note.mjs +237 -0
- package/dist/reference/types.d.ts +16 -1
- package/dist/reference/units-vocab.js +4 -0
- package/docs/VERSIONING.md +53 -4
- package/docs/authoring/README.md +10 -0
- package/docs/authoring/agents.md +17 -2
- package/docs/authoring/components.md +138 -0
- package/docs/authoring/migrating.md +275 -7
- package/docs/authoring/notation.md +615 -1
- package/docs/authoring/structure-rules.md +69 -0
- package/docs/authoring/structure.md +120 -8
- package/docs/authoring/visualizations.md +36 -3
- package/package.json +5 -2
- package/src/compiler/astro.ts +208 -3
- package/src/compiler/cli.ts +29 -2
- package/src/compiler/collections.ts +45 -4
- package/src/compiler/course-config.ts +132 -0
- package/src/compiler/exports/standalone-html.ts +963 -0
- package/src/compiler/manifest.ts +281 -22
- package/src/components/HoverPanel.astro +45 -4
- package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
- package/src/components/assessments/AssessmentRunner.tsx +52 -17
- package/src/components/assessments/AssessmentSet.astro +98 -14
- package/src/components/assessments/RenderedContent.tsx +55 -0
- package/src/components/assessments/assessment-randomization.ts +16 -0
- package/src/components/diagram/Diagram.astro +409 -103
- package/src/components/disclosure/hover-preview.ts +202 -0
- package/src/components/disclosure/persisted-disclosure.ts +66 -0
- package/src/components/examples/CompactExamples.astro +2 -0
- package/src/components/figure/Figure.astro +279 -10
- package/src/components/memo/Memo.astro +453 -0
- package/src/components/memo/ShortNote.astro +223 -0
- package/src/components/notation/NotationFormula.astro +18 -1
- package/src/components/notation/NotationLayer.astro +260 -83
- package/src/components/notation/render-notation-math.ts +39 -2
- package/src/components/notation/safe-math-to-react.ts +50 -0
- package/src/components/reference/FloatRefLayer.astro +233 -15
- package/src/components/starlight/CollapsibleSections.astro +461 -0
- package/src/components/starlight/DiagramEnhancer.astro +385 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
- package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
- package/src/components/starlight/LayoutHeader.astro +50 -0
- package/src/components/starlight/LayoutSocialIcons.astro +41 -2
- package/src/components/starlight/LessonExportActions.astro +436 -0
- package/src/components/starlight/LessonFooter.astro +30 -3
- package/src/components/starlight/section-parts.ts +31 -0
- package/src/components/table/Table.astro +293 -7
- package/src/components/table/TableOverflowExpander.astro +489 -0
- package/src/content-config.ts +36 -1
- package/src/reference/base-abbreviations.mjs +60 -0
- package/src/reference/base-operators.mjs +40 -0
- package/src/reference/base-roman-labels.mjs +43 -0
- package/src/reference/consistency.ts +73 -2
- package/src/reference/diagrams-validate.ts +40 -0
- package/src/reference/equations.mjs +67 -14
- package/src/reference/gate-abbreviations.ts +128 -0
- package/src/reference/gate-heading-length.ts +131 -0
- package/src/reference/gate-math.ts +32 -7
- package/src/reference/gate-prose-notation.ts +224 -0
- package/src/reference/keyed-float-validate.ts +5 -5
- package/src/reference/keyed-jsx-floats.mjs +50 -25
- package/src/reference/knowledge-checks-validate.ts +130 -0
- package/src/reference/knowledge-checks.mjs +45 -0
- package/src/reference/lab-math-scope.ts +45 -3
- package/src/reference/math-glyphs.mjs +548 -40
- package/src/reference/mdx-attributes.mjs +38 -0
- package/src/reference/mixed-content.ts +69 -0
- package/src/reference/page-notation.ts +252 -0
- package/src/reference/raw-floats-validate.ts +103 -0
- package/src/reference/raw-floats.mjs +129 -0
- package/src/reference/rehype-heading-anchors.mjs +107 -1
- package/src/reference/rehype-table-header-labels.mjs +152 -0
- package/src/reference/remark-abbreviations.mjs +174 -0
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-memo.mjs +339 -0
- package/src/reference/remark-notation.mjs +354 -42
- package/src/reference/remark-short-note.mjs +237 -0
- package/src/reference/types.ts +16 -0
- package/src/reference/units-vocab.ts +4 -0
- package/src/styles/global.css +996 -50
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base roman-label baseline: `\mathrm{…}` multi-letter labels every
|
|
3
|
+
* reader is assumed to know, so a lesson never has to introduce them with a
|
|
4
|
+
* notation entry. The `\operatorname{…}` counterpart is `base-operators.mjs`
|
|
5
|
+
* (rule N8); this is its `\mathrm{…}` twin (rule N10).
|
|
6
|
+
*
|
|
7
|
+
* Rationale: the completeness gate has no opinion about `\mathrm{…}` bodies —
|
|
8
|
+
* they are a typographic label to KaTeX, not a bindable symbol, and N3
|
|
9
|
+
* requires the format without asking whether the label is ever explained.
|
|
10
|
+
* The `roman-label-explained` consistency check fills that gap: a label a
|
|
11
|
+
* lesson uses is either on this list (or the course's additions to it) or it
|
|
12
|
+
* carries a notation entry the reader can hover.
|
|
13
|
+
*
|
|
14
|
+
* Kept intentionally small — unlike `sin`/`exp`, an upright label is almost
|
|
15
|
+
* always a course-specific named quantity (`KE`, `PV`, `Var`-as-a-label) that
|
|
16
|
+
* genuinely benefits from a hover card, so the shipped baseline holds only
|
|
17
|
+
* the qualifiers that function like an operator's extremum subscript
|
|
18
|
+
* (`x_{\mathrm{opt}}`) rather than naming a quantity of their own. A course
|
|
19
|
+
* adds a real one via `romanLabels.allow` in `course.config.ts`, the same
|
|
20
|
+
* shape as `operators.allow`.
|
|
21
|
+
*/
|
|
22
|
+
export const BASE_ROMAN_LABELS = Object.freeze(new Set(['opt']));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The roman labels a course treats as common, given its `course.config.ts`
|
|
26
|
+
* `romanLabels` block: the shipped baseline, plus `allow`, minus `deny`.
|
|
27
|
+
*
|
|
28
|
+
* @param {{ allow?: readonly string[], deny?: readonly string[] } | undefined} romanLabels
|
|
29
|
+
* @returns {Set<string>}
|
|
30
|
+
*/
|
|
31
|
+
export function effectiveRomanLabelAllowlist(romanLabels) {
|
|
32
|
+
const allow = romanLabels?.allow ?? [];
|
|
33
|
+
const deny = new Set(romanLabels?.deny ?? []);
|
|
34
|
+
const effective = new Set(BASE_ROMAN_LABELS);
|
|
35
|
+
for (const name of allow) effective.add(name);
|
|
36
|
+
for (const name of deny) effective.delete(name);
|
|
37
|
+
return effective;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when `name` needs no notation entry for this course. */
|
|
41
|
+
export function isBaseRomanLabel(name, romanLabels) {
|
|
42
|
+
return effectiveRomanLabelAllowlist(romanLabels).has(name);
|
|
43
|
+
}
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
* (h) operator-explained a named operator a lesson uses is on the
|
|
23
23
|
* shipped/course operator allowlist or
|
|
24
24
|
* carries a notation entry (rule N8)
|
|
25
|
+
* (i) roman-label-explained a `\mathrm{…}` multi-letter label a lesson
|
|
26
|
+
* uses is on the shipped/course roman-label
|
|
27
|
+
* allowlist or carries a notation entry
|
|
28
|
+
* (rule N10)
|
|
25
29
|
*
|
|
26
30
|
* Check (d) — the committed per-lesson resolution report — lives in
|
|
27
31
|
* `src/content/resolution-report.ts`.
|
|
@@ -30,12 +34,14 @@
|
|
|
30
34
|
import type {
|
|
31
35
|
CourseConventions,
|
|
32
36
|
CourseOperators,
|
|
37
|
+
CourseRomanLabels,
|
|
33
38
|
} from '../compiler/course-config';
|
|
34
39
|
import type { CurriculumCatalog } from '../curriculum/validation';
|
|
35
40
|
import type { NotationSpec } from '../compiler/collections';
|
|
36
41
|
import type { LintIgnore } from './lint-ignore';
|
|
37
42
|
import { effectiveOperatorAllowlist } from './base-operators.mjs';
|
|
38
|
-
import {
|
|
43
|
+
import { effectiveRomanLabelAllowlist } from './base-roman-labels.mjs';
|
|
44
|
+
import { collectOperators, collectRomanLabels } from './math-glyphs.mjs';
|
|
39
45
|
import { offVocabularyTokens } from './units-vocab';
|
|
40
46
|
import type {
|
|
41
47
|
NotationDefinitionRecord,
|
|
@@ -54,7 +60,8 @@ export type ConsistencyCode =
|
|
|
54
60
|
| 'gloss-wants-promoting'
|
|
55
61
|
| 'card-wants-demoting'
|
|
56
62
|
| 'gloss-name-shape'
|
|
57
|
-
| 'operator-explained'
|
|
63
|
+
| 'operator-explained'
|
|
64
|
+
| 'roman-label-explained';
|
|
58
65
|
|
|
59
66
|
export interface ConsistencyDiagnostic {
|
|
60
67
|
readonly code: ConsistencyCode;
|
|
@@ -79,6 +86,8 @@ export interface ConsistencyInput {
|
|
|
79
86
|
readonly conventions: CourseConventions;
|
|
80
87
|
/** This course's edits to the named-operator baseline (rule N8). */
|
|
81
88
|
readonly operators?: CourseOperators;
|
|
89
|
+
/** This course's edits to the roman-label baseline (rule N10). */
|
|
90
|
+
readonly romanLabels?: CourseRomanLabels;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
const CONVENTION_RE =
|
|
@@ -551,6 +560,66 @@ function checkOperatorExplained(
|
|
|
551
560
|
}
|
|
552
561
|
}
|
|
553
562
|
|
|
563
|
+
/**
|
|
564
|
+
* (i) roman-label-explained (rule N10) — the `\mathrm{…}` twin of (h)
|
|
565
|
+
* above: N3 requires the *format* (`\mathrm{KE}`, never bare `KE`) but has
|
|
566
|
+
* no opinion on whether the label is ever explained, because the glyph
|
|
567
|
+
* resolver skips a `\mathrm{…}` body whole. This is the check that looks at
|
|
568
|
+
* them; it reads `collectRomanLabels`, which parses with the same pinned
|
|
569
|
+
* KaTeX as the completeness gate.
|
|
570
|
+
*/
|
|
571
|
+
function checkRomanLabelExplained(
|
|
572
|
+
input: ConsistencyInput,
|
|
573
|
+
out: ConsistencyDiagnostic[],
|
|
574
|
+
): void {
|
|
575
|
+
const allowlist = effectiveRomanLabelAllowlist(input.romanLabels);
|
|
576
|
+
const byId = new Map<string, NotationDefinitionRecord>(
|
|
577
|
+
input.registry.definitions.map((definition) => [definition.id, definition]),
|
|
578
|
+
);
|
|
579
|
+
const bindingsByLesson = new Map(
|
|
580
|
+
input.registry.bundles.map((bundle) => [bundle.lessonId, bundle.bindings]),
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
for (const lesson of input.notationInput.lessons) {
|
|
584
|
+
// Labels the reader is shown on this page: the lesson's own
|
|
585
|
+
// `notation.local` entries, plus every shared card it binds with `[[key]]`.
|
|
586
|
+
const explained = new Set<string>();
|
|
587
|
+
const noteEntryLabels = (latex: string): void => {
|
|
588
|
+
for (const label of collectRomanLabels(latex)) {
|
|
589
|
+
explained.add(label.name);
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
for (const definition of lesson.localDefinitions) {
|
|
593
|
+
noteEntryLabels(definition.notation);
|
|
594
|
+
}
|
|
595
|
+
for (const binding of bindingsByLesson.get(lesson.lessonId) ?? []) {
|
|
596
|
+
const definition = byId.get(binding.definitionId);
|
|
597
|
+
if (definition) noteEntryLabels(definition.notation);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const reported = new Set<string>();
|
|
601
|
+
for (const span of mathSpans(lesson.body)) {
|
|
602
|
+
for (const label of collectRomanLabels(span.latex)) {
|
|
603
|
+
if (allowlist.has(label.name)) continue;
|
|
604
|
+
if (explained.has(label.name)) continue;
|
|
605
|
+
if (reported.has(label.name)) continue;
|
|
606
|
+
reported.add(label.name);
|
|
607
|
+
if (input.lintIgnore.matches('roman-label-explained', label.name)) {
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
out.push({
|
|
611
|
+
code: 'roman-label-explained',
|
|
612
|
+
severity: 'warning',
|
|
613
|
+
lessonId: lesson.lessonId,
|
|
614
|
+
file: lesson.source.file,
|
|
615
|
+
glyph: label.name,
|
|
616
|
+
message: `${lesson.lessonId}: roman label ${JSON.stringify(`\\mathrm{${label.name}}`)} is neither on the course roman-label allowlist nor explained by a notation entry; give it a card or notation.local entry (latex ${JSON.stringify(`\\mathrm{${label.name}}`)}) introduced with [[key]], or add ${JSON.stringify(label.name)} to romanLabels.allow in course.config.ts`,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
554
623
|
const CHECKS: ((
|
|
555
624
|
input: ConsistencyInput,
|
|
556
625
|
out: ConsistencyDiagnostic[],
|
|
@@ -565,6 +634,7 @@ const CHECKS: ((
|
|
|
565
634
|
checkCardWantsDemoting,
|
|
566
635
|
checkGlossNameShape,
|
|
567
636
|
checkOperatorExplained,
|
|
637
|
+
checkRomanLabelExplained,
|
|
568
638
|
];
|
|
569
639
|
|
|
570
640
|
/**
|
|
@@ -603,6 +673,7 @@ export function consistencyCounts(
|
|
|
603
673
|
'card-wants-demoting': 0,
|
|
604
674
|
'gloss-name-shape': 0,
|
|
605
675
|
'operator-explained': 0,
|
|
676
|
+
'roman-label-explained': 0,
|
|
606
677
|
} satisfies Record<ConsistencyCode, number>;
|
|
607
678
|
for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
|
|
608
679
|
return counts;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** `validateKeyedFloats` for `<Diagram>` — see `keyed-float-validate.ts`. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
validateKeyedFloats,
|
|
5
|
+
type KeyedFloatLabelRecord,
|
|
6
|
+
type KeyedFloatLessonInput,
|
|
7
|
+
type KeyedFloatValidation,
|
|
8
|
+
} from './keyed-float-validate';
|
|
9
|
+
|
|
10
|
+
export type DiagramDiagnosticCode =
|
|
11
|
+
| 'dia-invalid-key'
|
|
12
|
+
| 'dia-duplicate-key'
|
|
13
|
+
| 'dia-ref-resolves'
|
|
14
|
+
| 'dia-key-unused';
|
|
15
|
+
|
|
16
|
+
export interface DiagramDiagnostic {
|
|
17
|
+
readonly code: DiagramDiagnosticCode;
|
|
18
|
+
readonly severity: 'error' | 'warning';
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly file?: string;
|
|
21
|
+
readonly lessonId?: string;
|
|
22
|
+
readonly key?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DiagramLabelRecord = KeyedFloatLabelRecord;
|
|
26
|
+
export type DiagramLessonInput = KeyedFloatLessonInput;
|
|
27
|
+
|
|
28
|
+
export interface DiagramValidation extends KeyedFloatValidation {
|
|
29
|
+
readonly diagnostics: readonly DiagramDiagnostic[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateDiagrams(
|
|
33
|
+
lessons: readonly DiagramLessonInput[],
|
|
34
|
+
): DiagramValidation {
|
|
35
|
+
return validateKeyedFloats(lessons, {
|
|
36
|
+
tagName: 'Diagram',
|
|
37
|
+
prefix: 'dia',
|
|
38
|
+
noun: 'diagram',
|
|
39
|
+
}) as DiagramValidation;
|
|
40
|
+
}
|
|
@@ -128,9 +128,36 @@ export function stripFencedCode(markdown) {
|
|
|
128
128
|
* `keep`, when given, leaves a component of that name untouched instead of
|
|
129
129
|
* blanking it — `keyed-jsx-floats.mjs` uses this to blank every *other*
|
|
130
130
|
* component while still finding `<Table>`/`<Figure>` tags at the top level.
|
|
131
|
+
*
|
|
132
|
+
* `transparent`, when given, removes only those components' opening/closing
|
|
133
|
+
* tags and exposes their body to the later blanking pass. `<Memo>` and
|
|
134
|
+
* `<ShortNote>` are intentional uses: unlike a worked-example/presentation
|
|
135
|
+
* wrapper, their bodies are details in the lesson's reading order, so numbered
|
|
136
|
+
* equations/floats in them stay in the official sequence. Section headings in
|
|
137
|
+
* a transparent body do not change the surrounding page's section counter.
|
|
131
138
|
*/
|
|
132
|
-
export function stripJsxComponents(markdown, keep) {
|
|
133
|
-
|
|
139
|
+
export function stripJsxComponents(markdown, keep, transparent = []) {
|
|
140
|
+
let source = String(markdown);
|
|
141
|
+
const attributes = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'])*`;
|
|
142
|
+
for (const name of transparent) {
|
|
143
|
+
const component = new RegExp(
|
|
144
|
+
`<${name}\\b${attributes}>([\\s\\S]*?)<\\/${name}\\s*>`,
|
|
145
|
+
'g',
|
|
146
|
+
);
|
|
147
|
+
source = source.replace(component, (block, body) => {
|
|
148
|
+
const bodyStart = block.indexOf(body);
|
|
149
|
+
const visibleBody = body.replace(
|
|
150
|
+
/(^|\n)([ \t]{0,3})##(?=[ \t]+\S)/g,
|
|
151
|
+
(match) => match.replace(/#/g, ' '),
|
|
152
|
+
);
|
|
153
|
+
return (
|
|
154
|
+
block.slice(0, bodyStart).replace(/[^\n]/g, ' ') +
|
|
155
|
+
visibleBody +
|
|
156
|
+
block.slice(bodyStart + body.length).replace(/[^\n]/g, ' ')
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return source.replace(
|
|
134
161
|
/<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g,
|
|
135
162
|
(block, name) => (name === keep ? block : block.replace(/[^\n]/g, ' ')),
|
|
136
163
|
);
|
|
@@ -149,7 +176,10 @@ export function stripJsxComponents(markdown, keep) {
|
|
|
149
176
|
*/
|
|
150
177
|
export function scanDisplayEquations(markdown, numbering) {
|
|
151
178
|
const { prefix, offset } = resolveNumbering(numbering);
|
|
152
|
-
const text = stripJsxComponents(stripFencedCode(markdown)
|
|
179
|
+
const text = stripJsxComponents(stripFencedCode(markdown), undefined, [
|
|
180
|
+
'Memo',
|
|
181
|
+
'ShortNote',
|
|
182
|
+
]);
|
|
153
183
|
const tokens = [];
|
|
154
184
|
const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
|
|
155
185
|
const display = /\$\$([\s\S]+?)\$\$/g;
|
|
@@ -225,17 +255,13 @@ export function collectDisplayEquations(tree, numbering) {
|
|
|
225
255
|
let indexInSection = 0;
|
|
226
256
|
let indexInLesson = 0;
|
|
227
257
|
|
|
228
|
-
//
|
|
229
|
-
// `<CompactExample>` worked example, an `<Aside>`)
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (node
|
|
235
|
-
section += 1;
|
|
236
|
-
indexInSection = 0;
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
258
|
+
// Top-level blocks plus the bodies of `<Memo>` and `<ShortNote>`: other MDX
|
|
259
|
+
// components (a `<CompactExample>` worked example, an `<Aside>`) remain
|
|
260
|
+
// illustrative and outside the lesson's official equation sequence. These
|
|
261
|
+
// two detail components are deliberately transparent because they occupy an
|
|
262
|
+
// exact point in the reading order, even while visually collapsed.
|
|
263
|
+
const collectFromDetail = (node) => {
|
|
264
|
+
if (!node || typeof node !== 'object') return;
|
|
239
265
|
if (node.type === 'math') {
|
|
240
266
|
indexInSection += 1;
|
|
241
267
|
indexInLesson += 1;
|
|
@@ -254,6 +280,33 @@ export function collectDisplayEquations(tree, numbering) {
|
|
|
254
280
|
indexInSection,
|
|
255
281
|
node,
|
|
256
282
|
});
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (
|
|
286
|
+
node.type === 'mdxJsxFlowElement' &&
|
|
287
|
+
node.name !== 'Memo' &&
|
|
288
|
+
node.name !== 'ShortNote'
|
|
289
|
+
) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!Array.isArray(node.children)) return;
|
|
293
|
+
for (const child of node.children) collectFromDetail(child);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const children = Array.isArray(tree?.children) ? tree.children : [];
|
|
297
|
+
for (const node of children) {
|
|
298
|
+
if (!node || typeof node !== 'object') continue;
|
|
299
|
+
if (node.type === 'heading' && node.depth === 2) {
|
|
300
|
+
section += 1;
|
|
301
|
+
indexInSection = 0;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (node.type === 'math') collectFromDetail(node);
|
|
305
|
+
else if (
|
|
306
|
+
node.type === 'mdxJsxFlowElement' &&
|
|
307
|
+
(node.name === 'Memo' || node.name === 'ShortNote')
|
|
308
|
+
) {
|
|
309
|
+
collectFromDetail(node);
|
|
257
310
|
}
|
|
258
311
|
}
|
|
259
312
|
return equations;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The abbreviation-notation gate (rule N14): every acronym-shaped or
|
|
3
|
+
* dotted-abbreviation-shaped token sitting in plain lesson prose is either
|
|
4
|
+
* universal, explained by the abbreviations table, or already explained as a
|
|
5
|
+
* math quantity elsewhere on the same page.
|
|
6
|
+
*
|
|
7
|
+
* This is the prose-shaped twin of [N8](#n8)/[N10](#n10)
|
|
8
|
+
* (`checkOperatorExplained` / `checkRomanLabelExplained` in `consistency.ts`)
|
|
9
|
+
* rather than a rewrite of them: those two already require every
|
|
10
|
+
* `\operatorname{…}` / `\mathrm{…}` used *inside* `$…$` to be universal or
|
|
11
|
+
* explained, so an abbreviation that is also a math quantity — `MSE` written
|
|
12
|
+
* `\mathrm{MSE}` in an equation and bound to a notation entry — is already
|
|
13
|
+
* covered there. What N8/N10 cannot see is the same word sitting in ordinary
|
|
14
|
+
* prose ("the OLS estimator…") with no `$…$` around it at all; that is this
|
|
15
|
+
* gate's whole job, so it reuses `pageScope` plus `collectRomanLabels` /
|
|
16
|
+
* `collectOperators` (the exact functions N8/N10 already call) to recognize
|
|
17
|
+
* when a prose token is covered by that other mechanism instead of asking the
|
|
18
|
+
* author to explain the same term twice.
|
|
19
|
+
*
|
|
20
|
+
* Ships straight as an error, same as N9 (`gate-prose-notation.ts`): the
|
|
21
|
+
* corpus has no prior instances to absorb, so there is no Tier-3 warm-up
|
|
22
|
+
* period and no `lint-ignore.yml` entry.
|
|
23
|
+
*/
|
|
24
|
+
import { collectOperators, collectRomanLabels } from './math-glyphs.mjs';
|
|
25
|
+
import { isBaseAbbreviation } from './base-abbreviations.mjs';
|
|
26
|
+
import { pageScope } from './gate-math';
|
|
27
|
+
import type { MathGateDiagnostic, ScopeEntry } from './gate-math';
|
|
28
|
+
import { lineOf, proseOnly } from './gate-prose-notation';
|
|
29
|
+
import type {
|
|
30
|
+
AbbreviationDefinitionInput,
|
|
31
|
+
NotationRegistryInput,
|
|
32
|
+
} from './types';
|
|
33
|
+
import type { CourseAbbreviations } from '../compiler/course-config';
|
|
34
|
+
|
|
35
|
+
// An all-caps run of 2+ letters, an optional trailing plural/possessive `s`
|
|
36
|
+
// or `'s` (`OLSs`, `SSR's`) stripped from the reported token so it matches
|
|
37
|
+
// the table/baseline entry for the singular form.
|
|
38
|
+
const CAPS_RUN = /\b([A-Z]{2,})(?:'s|s)?\b/g;
|
|
39
|
+
// A run of 2+ single-letter-then-period groups: `w.r.t.`, `i.i.d.`, `e.g.`.
|
|
40
|
+
const DOTTED_ABBR = /\b(?:[A-Za-z]\.){2,}/g;
|
|
41
|
+
|
|
42
|
+
interface AbbreviationMatch {
|
|
43
|
+
readonly token: string;
|
|
44
|
+
readonly index: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every abbreviation-shaped token in an already-prose-only string, in source order. */
|
|
48
|
+
function findAbbreviationLikeTokens(text: string): AbbreviationMatch[] {
|
|
49
|
+
const out: AbbreviationMatch[] = [];
|
|
50
|
+
for (const match of text.matchAll(CAPS_RUN)) {
|
|
51
|
+
out.push({ token: match[1] ?? match[0], index: match.index ?? 0 });
|
|
52
|
+
}
|
|
53
|
+
for (const match of text.matchAll(DOTTED_ABBR)) {
|
|
54
|
+
out.push({ token: match[0], index: match.index ?? 0 });
|
|
55
|
+
}
|
|
56
|
+
return out.sort((a, b) => a.index - b.index);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const FIX =
|
|
60
|
+
'Add it to an abbreviations-table entry (content/abbreviations/*.md), ' +
|
|
61
|
+
'allowlist it in abbreviations.allow in course.config.ts if it is common, ' +
|
|
62
|
+
'non-technical usage, or — if it names a math quantity — introduce it as ' +
|
|
63
|
+
'\\mathrm{…} (or \\operatorname{…}) bound to a notation entry the reader ' +
|
|
64
|
+
'can hover. See docs/authoring/notation.md#n14.';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Scan every lesson body for an abbreviation-shaped token outside `$…$` that
|
|
68
|
+
* is neither universal nor explained. Returns one `MathGateDiagnostic`
|
|
69
|
+
* (context `'abbreviation'`, always an error) per unresolved token, deduped
|
|
70
|
+
* per lesson so a term used many times on one page is reported once.
|
|
71
|
+
*/
|
|
72
|
+
export function gateAbbreviations(input: {
|
|
73
|
+
readonly notationInput: NotationRegistryInput;
|
|
74
|
+
readonly abbreviations: readonly AbbreviationDefinitionInput[];
|
|
75
|
+
readonly courseAbbreviations?: CourseAbbreviations;
|
|
76
|
+
}): MathGateDiagnostic[] {
|
|
77
|
+
const { notationInput, abbreviations, courseAbbreviations } = input;
|
|
78
|
+
|
|
79
|
+
const tableTokens = new Set<string>();
|
|
80
|
+
for (const entry of abbreviations) {
|
|
81
|
+
tableTokens.add(entry.abbr);
|
|
82
|
+
for (const alias of entry.aliases) tableTokens.add(alias);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sharedByKey = new Map<string, ScopeEntry>(
|
|
86
|
+
notationInput.sharedDefinitions.map((definition) => [
|
|
87
|
+
definition.key,
|
|
88
|
+
{ key: definition.key, notation: definition.notation },
|
|
89
|
+
]),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const diagnostics: MathGateDiagnostic[] = [];
|
|
93
|
+
|
|
94
|
+
for (const lesson of notationInput.lessons) {
|
|
95
|
+
// Terms already explained as a math quantity on this page (N8/N10): every
|
|
96
|
+
// roman label / named operator carried by a bound local or `[[key]]`
|
|
97
|
+
// /`\explain`-referenced shared definition.
|
|
98
|
+
const mathQuantityNames = new Set<string>();
|
|
99
|
+
for (const entry of pageScope(lesson, sharedByKey)) {
|
|
100
|
+
for (const label of collectRomanLabels(entry.notation)) {
|
|
101
|
+
mathQuantityNames.add(label.name);
|
|
102
|
+
}
|
|
103
|
+
for (const operator of collectOperators(entry.notation)) {
|
|
104
|
+
mathQuantityNames.add(operator.name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const text = proseOnly(lesson.body);
|
|
109
|
+
const reported = new Set<string>();
|
|
110
|
+
for (const { token, index } of findAbbreviationLikeTokens(text)) {
|
|
111
|
+
if (reported.has(token)) continue;
|
|
112
|
+
if (isBaseAbbreviation(token, courseAbbreviations)) continue;
|
|
113
|
+
if (tableTokens.has(token)) continue;
|
|
114
|
+
if (mathQuantityNames.has(token)) continue;
|
|
115
|
+
reported.add(token);
|
|
116
|
+
diagnostics.push({
|
|
117
|
+
context: 'abbreviation',
|
|
118
|
+
severity: 'error',
|
|
119
|
+
file: lesson.source.file,
|
|
120
|
+
line: lineOf(lesson.body, index),
|
|
121
|
+
token,
|
|
122
|
+
message: `Unexplained abbreviation ${JSON.stringify(token)} outside math. ${FIX}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return diagnostics;
|
|
128
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The heading/title-length gate (rule S1): no Markdown heading (`##`
|
|
3
|
+
* through `######`) and no `<Figure title>`, `<Diagram label>`, `<Memo
|
|
4
|
+
* title>`, or `<ShortNote title>` may run past `HEADING_LENGTH_LIMIT`
|
|
5
|
+
* characters. A long heading or float title is usually a full sentence that
|
|
6
|
+
* belongs in the body paragraph (for a heading) or the `caption` (for a
|
|
7
|
+
* float) instead — see docs/authoring/structure-rules.md#s1.
|
|
8
|
+
*
|
|
9
|
+
* `<Table>` is deliberately not covered: it has only a `caption`, no
|
|
10
|
+
* separate `title`, and that caption is the LaTeX-convention full sentence
|
|
11
|
+
* shown above the table — capping it would fight the rule that puts it
|
|
12
|
+
* there. `<Figure>`'s `caption` is the same full sentence; only its short
|
|
13
|
+
* `title` (the text on the collapsible title bar) is in scope.
|
|
14
|
+
*
|
|
15
|
+
* A lesson's own `#` (its `frontmatter.title`, rendered as the page H1 by
|
|
16
|
+
* Starlight) is Review-only — it never appears in `lesson.body`, so this
|
|
17
|
+
* build-time gate, which only scans body text, cannot see it.
|
|
18
|
+
*
|
|
19
|
+
* Ships straight as an error, same as N9/N14: no partial corpus to absorb,
|
|
20
|
+
* no `lint-ignore.yml` entry.
|
|
21
|
+
*/
|
|
22
|
+
import { stripFencedCode } from './equations.mjs';
|
|
23
|
+
import type { MathGateDiagnostic } from './gate-math';
|
|
24
|
+
import { lineOf } from './gate-prose-notation';
|
|
25
|
+
import type { NotationLessonInput } from './types';
|
|
26
|
+
|
|
27
|
+
export const HEADING_LENGTH_LIMIT = 65;
|
|
28
|
+
|
|
29
|
+
// ATX heading: 1-6 `#`, then the text, with any trailing closing `#`s
|
|
30
|
+
// trimmed off (the `# text #` form CommonMark also allows).
|
|
31
|
+
const HEADING_LINE = /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm;
|
|
32
|
+
|
|
33
|
+
// The JSX attribute that reads as a float's own short title, per component.
|
|
34
|
+
// `Table` has no entry — see the module doc comment.
|
|
35
|
+
const TITLE_ATTRIBUTE_BY_TAG: Record<string, string> = {
|
|
36
|
+
Figure: 'title',
|
|
37
|
+
Diagram: 'label',
|
|
38
|
+
Memo: 'title',
|
|
39
|
+
ShortNote: 'title',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface Finding {
|
|
43
|
+
readonly index: number;
|
|
44
|
+
readonly token: string;
|
|
45
|
+
readonly message: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readAttribute(attributesSource: string, name: string): string | undefined {
|
|
49
|
+
const pattern = new RegExp(`\\b${name}\\s*=\\s*(["'])([^"']*)\\1`);
|
|
50
|
+
const match = pattern.exec(attributesSource);
|
|
51
|
+
return match ? match[2] : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function overLimitMessage(kind: string, text: string): string {
|
|
55
|
+
return (
|
|
56
|
+
`${kind} ${JSON.stringify(text)} is ${text.length} characters, over the ` +
|
|
57
|
+
`${HEADING_LENGTH_LIMIT}-character limit (rule S1). Shorten it and move ` +
|
|
58
|
+
'the rest into the paragraph or caption that follows.'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function headingFindings(text: string): Finding[] {
|
|
63
|
+
const out: Finding[] = [];
|
|
64
|
+
const re = new RegExp(HEADING_LINE.source, HEADING_LINE.flags);
|
|
65
|
+
let match: RegExpExecArray | null;
|
|
66
|
+
while ((match = re.exec(text))) {
|
|
67
|
+
const level = match[1]?.length ?? 1;
|
|
68
|
+
const title = (match[2] ?? '').trim();
|
|
69
|
+
if (title.length > HEADING_LENGTH_LIMIT) {
|
|
70
|
+
out.push({
|
|
71
|
+
index: match.index,
|
|
72
|
+
token: title,
|
|
73
|
+
message: overLimitMessage(`${'#'.repeat(level)} heading`, title),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function floatTitleFindings(text: string): Finding[] {
|
|
81
|
+
const out: Finding[] = [];
|
|
82
|
+
for (const [tagName, attribute] of Object.entries(TITLE_ATTRIBUTE_BY_TAG)) {
|
|
83
|
+
const opening = new RegExp(`<${tagName}\\b([^>]*)>`, 'g');
|
|
84
|
+
let match: RegExpExecArray | null;
|
|
85
|
+
while ((match = opening.exec(text))) {
|
|
86
|
+
const value = readAttribute(match[1] ?? '', attribute)?.trim();
|
|
87
|
+
if (value && value.length > HEADING_LENGTH_LIMIT) {
|
|
88
|
+
out.push({
|
|
89
|
+
index: match.index,
|
|
90
|
+
token: value,
|
|
91
|
+
message: overLimitMessage(`<${tagName} ${attribute}>`, value),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every S1 finding in one lesson body, in source order. */
|
|
100
|
+
function findingsIn(text: string): Finding[] {
|
|
101
|
+
return [...headingFindings(text), ...floatTitleFindings(text)].sort(
|
|
102
|
+
(a, b) => a.index - b.index,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Scan every lesson body for a heading or a keyed float's title running past
|
|
108
|
+
* `HEADING_LENGTH_LIMIT` characters. Returns one `MathGateDiagnostic`
|
|
109
|
+
* (context `'structure'`, always an error) per finding.
|
|
110
|
+
*/
|
|
111
|
+
export function gateHeadingLength(
|
|
112
|
+
lessons: readonly NotationLessonInput[],
|
|
113
|
+
): MathGateDiagnostic[] {
|
|
114
|
+
const diagnostics: MathGateDiagnostic[] = [];
|
|
115
|
+
|
|
116
|
+
for (const lesson of lessons) {
|
|
117
|
+
const text = stripFencedCode(lesson.body);
|
|
118
|
+
for (const finding of findingsIn(text)) {
|
|
119
|
+
diagnostics.push({
|
|
120
|
+
context: 'structure',
|
|
121
|
+
severity: 'error',
|
|
122
|
+
file: lesson.source.file,
|
|
123
|
+
line: lineOf(lesson.body, finding.index),
|
|
124
|
+
token: finding.token,
|
|
125
|
+
message: finding.message,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return diagnostics;
|
|
131
|
+
}
|
|
@@ -20,14 +20,29 @@ import type {
|
|
|
20
20
|
} from './types';
|
|
21
21
|
|
|
22
22
|
export interface MathGateDiagnostic {
|
|
23
|
-
readonly context: 'body' | 'formula' | 'assessment';
|
|
23
|
+
readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
|
|
24
24
|
/**
|
|
25
25
|
* Body / component-slot math blocks the build (the render path needs every
|
|
26
26
|
* glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
|
|
27
27
|
* a formula's glyphs are now rendered live and explorable, so an unresolved
|
|
28
28
|
* one is a hole the reader can see, and the corpus was brought to zero when
|
|
29
|
-
* the tier was promoted. `assessment`
|
|
30
|
-
*
|
|
29
|
+
* the tier was promoted. `assessment` joined the same way when
|
|
30
|
+
* `AssessmentRunner` started rendering `prompt` / `explanation` through
|
|
31
|
+
* KaTeX (`mixed-content.ts`) instead of showing them as raw text: an
|
|
32
|
+
* unresolved glyph is now a visible hole in the assessment UI, not an
|
|
33
|
+
* invisible warning, so the corpus was brought to zero and the tier
|
|
34
|
+
* promoted in the same change — see `CHANGELOG.md`. `prose` (N9,
|
|
35
|
+
* `gate-prose-notation.ts`) is the complementary direction — plain-text
|
|
36
|
+
* notation sitting *outside* `$…$` — and ships straight as an error: it has
|
|
37
|
+
* no partial corpus to absorb, since every prior instance was fixed in the
|
|
38
|
+
* same change that introduced the check. `abbreviation` (N14,
|
|
39
|
+
* `gate-abbreviations.ts`) is the same "ships straight as an error" story
|
|
40
|
+
* for a different shape of plain-text token — an unexplained acronym or
|
|
41
|
+
* dotted abbreviation, rather than a mis-typed quantity/unit. `structure`
|
|
42
|
+
* (S1, `gate-heading-length.ts`) is unrelated to notation — it caps how
|
|
43
|
+
* long a heading or a keyed float's title may run — but reuses this same
|
|
44
|
+
* diagnostic shape and the one `manifest.diagnostics.math` array rather
|
|
45
|
+
* than adding a second array the CLI has to know about.
|
|
31
46
|
*/
|
|
32
47
|
readonly severity: 'error' | 'warning';
|
|
33
48
|
readonly file: string;
|
|
@@ -39,7 +54,10 @@ export interface MathGateDiagnostic {
|
|
|
39
54
|
const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
|
|
40
55
|
body: 'error',
|
|
41
56
|
formula: 'error',
|
|
42
|
-
assessment: '
|
|
57
|
+
assessment: 'error',
|
|
58
|
+
prose: 'error',
|
|
59
|
+
abbreviation: 'error',
|
|
60
|
+
structure: 'error',
|
|
43
61
|
};
|
|
44
62
|
|
|
45
63
|
interface AssessmentItemLike {
|
|
@@ -93,11 +111,18 @@ function mathSpans(body: string): MathSpan[] {
|
|
|
93
111
|
return spans;
|
|
94
112
|
}
|
|
95
113
|
|
|
96
|
-
type ScopeEntry = { key: string; notation: string };
|
|
114
|
+
export type ScopeEntry = { key: string; notation: string };
|
|
97
115
|
|
|
98
|
-
|
|
116
|
+
/**
|
|
117
|
+
* A lesson's flat glyph table: its own `notation.local` entries plus every
|
|
118
|
+
* shared card it introduces with `[[key]]` / `\explain{key}`. Exported so a
|
|
119
|
+
* caller that needs the *same* scope for a different purpose — rendering an
|
|
120
|
+
* assessment's math (`mixed-content.ts`), say — doesn't reconstruct it by
|
|
121
|
+
* hand and risk drifting from what the completeness gate actually checks.
|
|
122
|
+
*/
|
|
123
|
+
export function pageScope(
|
|
99
124
|
lesson: NotationLessonInput,
|
|
100
|
-
sharedByKey: ReadonlyMap<string,
|
|
125
|
+
sharedByKey: ReadonlyMap<string, ScopeEntry>,
|
|
101
126
|
): ScopeEntry[] {
|
|
102
127
|
const local = lesson.localDefinitions.map((definition) => ({
|
|
103
128
|
key: definition.key,
|