explico 0.7.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 +171 -1
- package/dist/compiler/cli.d.ts +8 -1
- package/dist/compiler/cli.js +10 -2
- package/dist/compiler/collections.js +16 -3
- package/dist/compiler/logging.d.ts +11 -1
- package/dist/compiler/logging.js +2 -1
- package/dist/compiler/manifest.d.ts +1 -1
- package/dist/compiler/manifest.js +1 -1
- package/dist/reference/delimiter-size.d.ts +18 -9
- package/dist/reference/delimiter-size.js +15 -39
- package/dist/reference/equations.d.mts +9 -3
- package/dist/reference/equations.mjs +7 -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 +23 -1
- package/dist/reference/gate-math.js +23 -4
- 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/math-glyphs.mjs +16 -2
- package/dist/reference/render-lab-math.js +1 -1
- package/dist/reference/types.d.ts +7 -0
- package/docs/authoring/README.md +1 -1
- package/docs/authoring/migrating.md +228 -0
- package/docs/authoring/notation.md +80 -62
- package/docs/katex-bracket-scaling.md +191 -0
- package/package.json +2 -1
- package/src/compiler/cli.ts +23 -0
- package/src/compiler/collections.ts +33 -3
- package/src/compiler/logging.ts +14 -3
- package/src/compiler/manifest.ts +1 -1
- package/src/components/notation/NotationGlossary.astro +21 -10
- package/src/components/notation/render-notation-math.ts +111 -10
- package/src/components/starlight/AboutPanel.astro +17 -2
- package/src/reference/delimiter-size.ts +37 -59
- package/src/reference/equations.mjs +7 -1
- package/src/reference/gate-abbreviations.ts +2 -2
- package/src/reference/gate-heading-length.ts +2 -2
- package/src/reference/gate-math.ts +50 -2
- 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/math-glyphs.mjs +16 -2
- package/src/reference/render-lab-math.ts +1 -1
- package/src/reference/types.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,175 @@ under **Breaking** below; a PATCH never breaks.
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.8.0] - 2026-09-19
|
|
18
|
+
|
|
19
|
+
### Breaking
|
|
20
|
+
|
|
21
|
+
- **Rule N17 is inverted: a nested delimiter is sized by the engine, never by
|
|
22
|
+
hand.** The engine now installs its own KaTeX delimiter sizing
|
|
23
|
+
(`src/reference/katex-delimiters.mjs`), changing one constant in
|
|
24
|
+
`makeLeftRightDelim` — `delimiterExtend`, TeX's `\delimitershortfall`, from
|
|
25
|
+
`5.0` to `-1.0` points.
|
|
26
|
+
Stock TeX and stock KaTeX let a `\left … \right` pair fall up to 5pt short of
|
|
27
|
+
its content, so two pairs at different depths around similar content round to
|
|
28
|
+
the same glyph and the nesting vanishes; N17 used to repair that by making
|
|
29
|
+
authors hand-pick `\bigl` → `\Bigl` → `\biggl` → `\Biggl` outward. Those are
|
|
30
|
+
*fixed* glyphs: they stop tracking the content they wrap, they run out after
|
|
31
|
+
four levels, and editing an inner expression silently invalidates every size
|
|
32
|
+
above it. With a negative shortfall a pair is always strictly taller than its
|
|
33
|
+
content, so each enclosing level grows on its own, at any depth, by exactly
|
|
34
|
+
what it needs. `\left … \right` at every level is now the rule, and
|
|
35
|
+
hand-sizing a pair inside a nest is the warning.
|
|
36
|
+
|
|
37
|
+
Consequences for a course:
|
|
38
|
+
- The Tier-3 diagnostic code is renamed `delimiter-size-hierarchy` →
|
|
39
|
+
**`delimiter-manual-size`**; a `lint-ignore.yml` entry or a script matching
|
|
40
|
+
the old code must be updated. Its diagnostic shape changed too: the
|
|
41
|
+
`outerSize`/`nestedSize` fields are replaced by a single `size` (the
|
|
42
|
+
hand-picked size found), and `suggestion` is now always present and always
|
|
43
|
+
a `\left`/`\right` pair — it no longer carries a `size` field.
|
|
44
|
+
- Math that was N17-compliant now warns. A ladder such as
|
|
45
|
+
`\Biggl[a+\biggl(b+\Bigl[c+\left(d+e\right)\Bigr]\biggr)\Biggr]` reports one
|
|
46
|
+
finding per hand-sized level; the repair is `\left`/`\right` at every level.
|
|
47
|
+
A *lone* hand-sized pair that is not part of a nest — `\bigl(x,\ y\bigr)`
|
|
48
|
+
for a tuple — is deliberately left alone.
|
|
49
|
+
- **A course needs no install step for this.** `delimiterExtend` is a
|
|
50
|
+
function-local `const`, so nothing KaTeX exports reaches it and the change
|
|
51
|
+
has to happen in the module's own source. Rather than a `patches/` file —
|
|
52
|
+
which pnpm records per project and so cannot travel inside the npm artifact
|
|
53
|
+
— the engine reads KaTeX's ESM bundle, rewrites that constant, and moves
|
|
54
|
+
the render entry points onto the shared `katex` object. `rehype-katex`
|
|
55
|
+
reads `katex.renderToString` at call time and KaTeX's default export is
|
|
56
|
+
mutable, so lesson-body math inherits it without the plugin being forked or
|
|
57
|
+
aliased. `astro.config.mjs` already imports
|
|
58
|
+
`explico/reference/katex-options.mjs`, which installs it, so an existing
|
|
59
|
+
course picks this up on upgrade with no edit. The installer verifies itself
|
|
60
|
+
— a KaTeX upgrade that moves the constant, or anything that renders first,
|
|
61
|
+
fails the build with an explicit message instead of silently reverting to
|
|
62
|
+
stock spacing. [`docs/katex-bracket-scaling.md`](docs/katex-bracket-scaling.md)
|
|
63
|
+
derives the change and documents the mechanism and its trade-offs.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **A math diagnostic now names the equation it belongs to.** Every finding
|
|
68
|
+
the math gate reports from a `$$…$$` block that carries a `\label{eq:…}`
|
|
69
|
+
gains an `equation` field — `{ key, href }`, where `href` is the anchor the
|
|
70
|
+
rendered page gives that equation (`/l1-ar/ar1-basics/#eq-ar1-variance`) —
|
|
71
|
+
and `diagnosticLine()` appends it, so the console line, the run log, and
|
|
72
|
+
`pnpm content diagnostics` all read
|
|
73
|
+
`[code] file:line:column: message (eq:<key> -> <href>)`. A size-only
|
|
74
|
+
`delimiter-manual-size` finding inside a six-line `aligned` block was
|
|
75
|
+
otherwise anonymous: the message names delimiters, never the result they
|
|
76
|
+
belong to, so an author had to reconstruct which equation was meant from a
|
|
77
|
+
line number alone. `CliDiagnostic` carries the same optional field, so a
|
|
78
|
+
course's own `scripts/content.ts` gets it without change. The manifest
|
|
79
|
+
schema is now **v16** (additive: v15 → v16); nothing existing changed shape.
|
|
80
|
+
|
|
81
|
+
- **The About panel publishes the identity it shows.** `AboutPanel.astro` now
|
|
82
|
+
stamps `data-knowledge-version`, `data-knowledge-date` (omitted when the
|
|
83
|
+
content changelog has no date) and `data-engine-version` on its
|
|
84
|
+
`details[data-about-panel]` element, and marks the rendered version line with
|
|
85
|
+
`data-knowledge-line`. The two versions the panel exists to publish were
|
|
86
|
+
readable only as prose — and as a `version · date` join — so a course's build
|
|
87
|
+
check had to match that text, which pins the check to one version string and
|
|
88
|
+
turns every routine bump into a test edit; this repo's own
|
|
89
|
+
`playground/tests/e2e/about.spec.ts` was asserting `0.4.0` against a
|
|
90
|
+
playground long since at `0.5.2`. A bare version substring is not scoped to
|
|
91
|
+
the panel either, so the engine version printed one row below can satisfy it.
|
|
92
|
+
The attributes are additive: nothing rendered changed, and the existing
|
|
93
|
+
`data-about-panel` hook is untouched.
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
|
|
97
|
+
- **A glossary heading's accessible name is the term again.** The notation
|
|
98
|
+
glossary's per-entry anchor link moved *inside* its `<h2>` when the entry
|
|
99
|
+
header was rebuilt for smooth scrolling, so the `aria-label` on that link
|
|
100
|
+
("Link to “Velocity”") joined the heading's accessible name — a screen reader
|
|
101
|
+
announced "Velocity Link to Velocity", and any check for the heading by name
|
|
102
|
+
stopped matching. `NotationGlossary.astro` now wraps the heading and its
|
|
103
|
+
anchor in a `.glossary-heading` flex row, the shape
|
|
104
|
+
`rehype-heading-anchors` already gives a lesson heading (`<h2>` and
|
|
105
|
+
`a.sl-anchor-link` as siblings). Nothing visual changed; the anchor still
|
|
106
|
+
reveals on hover and focus.
|
|
107
|
+
|
|
108
|
+
- **A diagnostic's `line` now addresses the file it names.** Every gate scans
|
|
109
|
+
`lesson.body`, which Astro's `parseFrontmatter` hands over with the
|
|
110
|
+
frontmatter already removed, but each finding is reported against
|
|
111
|
+
`lesson.source.file` — so every `file:line:column` the engine has ever
|
|
112
|
+
emitted for a lesson was short by the height of that lesson's frontmatter.
|
|
113
|
+
It failed silently in the worst way: the number stayed plausible, so an
|
|
114
|
+
editor, a VS Code problem matcher, or an author following
|
|
115
|
+
`pnpm content diagnostics` jumped confidently to the wrong line, and a
|
|
116
|
+
size-only N17 finding could not be located at all. `parseDocument` now
|
|
117
|
+
records the body's start line, `NotationLessonInput` carries it as an
|
|
118
|
+
optional `bodyLine`, and the new `lessonLine()` helper applies it — so
|
|
119
|
+
`gate-math` (including every `delimiter-manual-size` column),
|
|
120
|
+
`gate-prose-notation` (N9), `gate-abbreviations` (N14), and
|
|
121
|
+
`gate-heading-length` (S1) are all corrected at once. A hand-built lesson
|
|
122
|
+
input that omits `bodyLine` behaves exactly as before.
|
|
123
|
+
|
|
124
|
+
- **`equationAnchorId({ key })` type-checks without a `number`.** Its JSDoc
|
|
125
|
+
required both properties although `number` is read only when there is no
|
|
126
|
+
`key`, so a keyed caller had to pass a value the function ignores.
|
|
127
|
+
|
|
128
|
+
- **A notation `formula` can use a manual delimiter size, an accent, a box, or
|
|
129
|
+
a color without failing the build.** `render-notation-math.ts` sanitizes
|
|
130
|
+
KaTeX's output against an attribute allowlist that had only ever grown to
|
|
131
|
+
cover what the corpus happened to use, so ordinary math threw
|
|
132
|
+
`Unexpected KaTeX property` with no author-side workaround: `\Bigl`/`\biggl`/
|
|
133
|
+
`\Biggl` (`minsize`, `maxsize`), `\xrightarrow` (`minsize`), `\boxed`
|
|
134
|
+
(`notation`), and `\colorbox` (`voffset`, `mathbackground`). Rule N17 asks
|
|
135
|
+
authors for exactly those delimiter commands, so a card that followed the
|
|
136
|
+
engine's own advice could not build. The allowlist is now KaTeX's whole inert
|
|
137
|
+
presentation surface — `columnlines`, `depth`, `display`, `largeop`,
|
|
138
|
+
`linebreak`, `linethickness`, `mathbackground`, `mathcolor`, `mathsize`,
|
|
139
|
+
`maxsize`, `minsize`, `notation`, `rowlines`, `valign`, and `voffset` join the
|
|
140
|
+
set — with `alt`, `href`, `src`, and `title` explicitly denied, and a new test
|
|
141
|
+
scans KaTeX's own `setAttribute` calls so a KaTeX upgrade that adds an
|
|
142
|
+
attribute fails here instead of in a downstream course. Hex colors are
|
|
143
|
+
accepted in an attribute or style value; no attribute that can carry a URL is
|
|
144
|
+
allowed through.
|
|
145
|
+
|
|
146
|
+
- **`\vec` (and every other accent) renders again.** The style sanitizer
|
|
147
|
+
required each CSS declaration to end in `;`, but KaTeX does not always
|
|
148
|
+
terminate the last one — `\vec` emits `style="width:0.471em"` — and the
|
|
149
|
+
rejection then fell through to the misleading
|
|
150
|
+
`Unexpected KaTeX property: style`. Declarations are now validated one at a
|
|
151
|
+
time, and an unsafe `style` or `d` value says so instead of reporting an
|
|
152
|
+
unknown property.
|
|
153
|
+
|
|
154
|
+
- **A macro in a formula no longer invents a source span.** KaTeX gives a node
|
|
155
|
+
it produced by expanding a macro a `loc` from the lexer over the *macro
|
|
156
|
+
body*, so those offsets index that body and not the source being scanned —
|
|
157
|
+
and they are still valid indices into a longer source, so the glyph resolver
|
|
158
|
+
read them as a real span over unrelated text. `\vdots` expands to
|
|
159
|
+
`{\varvdots\rule{0pt}{15pt}}`, whose atom claims characters 1–10: in
|
|
160
|
+
`M=\begin{pmatrix}…` that is `"=\begin{p"`, and because no author can wrap
|
|
161
|
+
that slice in an `\explain`, a matrix showing a column ellipsis failed the
|
|
162
|
+
build with `Unresolved notation ""`. Where the stray span instead collided
|
|
163
|
+
with a glyph in scope, the binding was injected at the macro body's offsets
|
|
164
|
+
and split the source mid-command — `\varSigma=\operatorname{Cov}(X)` became
|
|
165
|
+
`\varSigm\explain{cov-matrix}{a=\ope}ratorname{Cov}(X)`, a corrupt formula no
|
|
166
|
+
gate rejected. `nodeSpan` now believes a `loc` only when it belongs to the
|
|
167
|
+
string this pass parsed. 79 of KaTeX's 282 macros emit such a node, among
|
|
168
|
+
them `\argmax`, `\argmin`, `\plim`, `\limsup`, `\liminf`, `\bmod`, `\pmod`,
|
|
169
|
+
`\R`, `\N`, `\Z` and every upright `\varGamma`-style capital. A macro's
|
|
170
|
+
*argument* keeps the author's own locs and still binds normally. One limit
|
|
171
|
+
remains: a macro-expanded glyph has no authored span, so canonical matching
|
|
172
|
+
and the completeness gate cannot see it — annotate it with an explicit
|
|
173
|
+
`\explain{key}{\varSigma}`, which works and is unaffected by this change.
|
|
174
|
+
|
|
175
|
+
- **A malformed `\explain` / `\slot` key fails the build instead of reaching
|
|
176
|
+
the reader.** KaTeX does not treat a denied `trust` callback as a parse
|
|
177
|
+
error: it renders the command name in `errorColor` and continues, so
|
|
178
|
+
`\explain{Not A Key}{\Omega}` produced red `\htmlData` text on the page with
|
|
179
|
+
the build green. (Until now this was masked by `mathcolor` — the attribute
|
|
180
|
+
KaTeX's error rendering carries — not being in the allowlist.)
|
|
181
|
+
`renderNotationMath` now checks both marker macros against
|
|
182
|
+
`trustNotationMarker` before KaTeX runs and throws naming the offending key.
|
|
183
|
+
`renderNotationFormula` still degrades to inert-but-correct math rather than
|
|
184
|
+
costing a reader the equation.
|
|
185
|
+
|
|
17
186
|
## [0.7.0] - 2026-09-18
|
|
18
187
|
|
|
19
188
|
### Breaking
|
|
@@ -722,7 +891,8 @@ under **Breaking** below; a PATCH never breaks.
|
|
|
722
891
|
system with its completeness gate, the curriculum-graph validator, the
|
|
723
892
|
Astro/Starlight UI, the LMS / session / analytics seams, and the `content` CLI.
|
|
724
893
|
|
|
725
|
-
[Unreleased]: https://github.com/danieltuzes/explico/compare/v0.
|
|
894
|
+
[Unreleased]: https://github.com/danieltuzes/explico/compare/v0.8.0...HEAD
|
|
895
|
+
[0.8.0]: https://github.com/danieltuzes/explico/compare/v0.7.0...v0.8.0
|
|
726
896
|
[0.7.0]: https://github.com/danieltuzes/explico/compare/v0.6.0...v0.7.0
|
|
727
897
|
[0.6.0]: https://github.com/danieltuzes/explico/compare/v0.5.0...v0.6.0
|
|
728
898
|
[0.5.0]: https://github.com/danieltuzes/explico/compare/v0.4.0...v0.5.0
|
package/dist/compiler/cli.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DiagnosticSeverity } from '../reference/types.js';
|
|
2
|
+
import { EquationLink } from '../reference/gate-math.js';
|
|
2
3
|
import { Manifest, ManifestLesson } from './manifest.js';
|
|
3
4
|
import '../curriculum/validation.js';
|
|
4
5
|
import '../reference/consistency.js';
|
|
@@ -12,7 +13,6 @@ import '../reference/figures-validate.js';
|
|
|
12
13
|
import '../reference/diagrams-validate.js';
|
|
13
14
|
import '../reference/knowledge-checks-validate.js';
|
|
14
15
|
import '../reference/raw-floats-validate.js';
|
|
15
|
-
import '../reference/gate-math.js';
|
|
16
16
|
import '../reference/mixed-content.js';
|
|
17
17
|
import '../reference/gloss.js';
|
|
18
18
|
import './knowledge-version.js';
|
|
@@ -61,6 +61,13 @@ interface CliDiagnostic {
|
|
|
61
61
|
readonly file?: string;
|
|
62
62
|
readonly line?: number;
|
|
63
63
|
readonly column?: number;
|
|
64
|
+
/**
|
|
65
|
+
* The keyed equation the finding sits in, when the math carries a
|
|
66
|
+
* `\label{eq:…}` — `{ key, href }`, the href being the anchor on the
|
|
67
|
+
* rendered page. A size-only finding inside a long `aligned` block is
|
|
68
|
+
* otherwise anonymous.
|
|
69
|
+
*/
|
|
70
|
+
readonly equation?: EquationLink;
|
|
64
71
|
}
|
|
65
72
|
/**
|
|
66
73
|
* Every manifest diagnostic that names this lesson (by id or file), normalized
|
package/dist/compiler/cli.js
CHANGED
|
@@ -76,7 +76,8 @@ function lessonDiagnostics(manifest, lesson) {
|
|
|
76
76
|
message: diagnostic.message,
|
|
77
77
|
file: diagnostic.file,
|
|
78
78
|
...diagnostic.line === void 0 ? {} : { line: diagnostic.line },
|
|
79
|
-
...diagnostic.column === void 0 ? {} : { column: diagnostic.column }
|
|
79
|
+
...diagnostic.column === void 0 ? {} : { column: diagnostic.column },
|
|
80
|
+
...diagnostic.equation === void 0 ? {} : { equation: diagnostic.equation }
|
|
80
81
|
});
|
|
81
82
|
}
|
|
82
83
|
for (const diagnostic of manifest.diagnostics.equations) {
|
|
@@ -365,6 +366,11 @@ function contentStatus(manifest) {
|
|
|
365
366
|
}
|
|
366
367
|
};
|
|
367
368
|
}
|
|
369
|
+
function equationLink(value) {
|
|
370
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
371
|
+
const { key, href } = value;
|
|
372
|
+
return typeof key === "string" && typeof href === "string" ? { key, href } : void 0;
|
|
373
|
+
}
|
|
368
374
|
function numericLocation(value) {
|
|
369
375
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
|
|
370
376
|
}
|
|
@@ -394,13 +400,15 @@ function contentDiagnostics(manifest, minimumSeverity = "info") {
|
|
|
394
400
|
const line = numericLocation(diagnostic.line) ?? numericLocation(source?.line);
|
|
395
401
|
const column = numericLocation(diagnostic.column) ?? numericLocation(source?.column);
|
|
396
402
|
const detail = typeof diagnostic.code === "string" ? diagnostic.code : typeof diagnostic.kind === "string" ? diagnostic.kind : "diagnostic";
|
|
403
|
+
const equation = equationLink(diagnostic.equation);
|
|
397
404
|
diagnostics.push({
|
|
398
405
|
code: `${group}:${detail}`,
|
|
399
406
|
severity,
|
|
400
407
|
message: String(diagnostic.message ?? detail),
|
|
401
408
|
...file === void 0 ? {} : { file },
|
|
402
409
|
...line === void 0 ? {} : { line },
|
|
403
|
-
...column === void 0 ? {} : { column }
|
|
410
|
+
...column === void 0 ? {} : { column },
|
|
411
|
+
...equation === void 0 ? {} : { equation }
|
|
404
412
|
});
|
|
405
413
|
}
|
|
406
414
|
}
|
|
@@ -25,12 +25,24 @@ function filesBelow(directory, extensions) {
|
|
|
25
25
|
return entry.isFile() && extensions.some((extension) => entry.name.endsWith(extension)) ? [path] : [];
|
|
26
26
|
}).sort();
|
|
27
27
|
}
|
|
28
|
+
function bodyStartLine(raw, body) {
|
|
29
|
+
const offset = raw.endsWith(body) ? raw.length - body.length : raw.indexOf(body);
|
|
30
|
+
if (offset <= 0) return 1;
|
|
31
|
+
let line = 1;
|
|
32
|
+
for (let index = 0; index < offset; index += 1) {
|
|
33
|
+
if (raw.charCodeAt(index) === 10) line += 1;
|
|
34
|
+
}
|
|
35
|
+
return line;
|
|
36
|
+
}
|
|
28
37
|
function parseDocument(path) {
|
|
29
|
-
const
|
|
38
|
+
const raw = readFileSync(path, "utf8");
|
|
39
|
+
const parsed = parseFrontmatter(raw);
|
|
40
|
+
const body = parsed.content.replace(/^\n/, "");
|
|
30
41
|
return {
|
|
31
42
|
file: relative(REPOSITORY_ROOT, path),
|
|
32
43
|
data: parsed.frontmatter ?? {},
|
|
33
|
-
body
|
|
44
|
+
body,
|
|
45
|
+
bodyLine: bodyStartLine(raw, body)
|
|
34
46
|
};
|
|
35
47
|
}
|
|
36
48
|
function record(value, label) {
|
|
@@ -322,7 +334,7 @@ function notationLessonEntries() {
|
|
|
322
334
|
for (const path of filesBelow(join(CONTENT_ROOT, "docs"), [".md", ".mdx"])) {
|
|
323
335
|
const slug = docSlug(path);
|
|
324
336
|
if (!isLessonSlug(slug)) continue;
|
|
325
|
-
const { file, data, body } = parseDocument(path);
|
|
337
|
+
const { file, data, body, bodyLine } = parseDocument(path);
|
|
326
338
|
const notation = record(data.notation ?? {}, `${file} notation`);
|
|
327
339
|
const local = Array.isArray(notation.local) ? notation.local : [];
|
|
328
340
|
lessons.push({
|
|
@@ -333,6 +345,7 @@ function notationLessonEntries() {
|
|
|
333
345
|
),
|
|
334
346
|
references: extractNotationReferences(body, file, void 0),
|
|
335
347
|
body,
|
|
348
|
+
bodyLine,
|
|
336
349
|
source: { file }
|
|
337
350
|
});
|
|
338
351
|
}
|
|
@@ -67,6 +67,11 @@ interface AnyDiagnostic {
|
|
|
67
67
|
readonly file?: string;
|
|
68
68
|
readonly line?: number;
|
|
69
69
|
readonly column?: number;
|
|
70
|
+
/** `{ key, href }` when the finding belongs to a `\label{eq:…}` equation. */
|
|
71
|
+
readonly equation?: {
|
|
72
|
+
readonly key: string;
|
|
73
|
+
readonly href: string;
|
|
74
|
+
};
|
|
70
75
|
}
|
|
71
76
|
/** `manifest.diagnostics`, structurally — one array per subsystem. */
|
|
72
77
|
type DiagnosticGroups = Readonly<Record<string, readonly AnyDiagnostic[]>>;
|
|
@@ -89,7 +94,12 @@ interface RunLog {
|
|
|
89
94
|
/** Refreshes `latest` and reports what the run recorded. */
|
|
90
95
|
close(): RunLogSummary;
|
|
91
96
|
}
|
|
92
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* `[code] file:line:column: message`, matching what the console has always
|
|
99
|
+
* printed, with ` (eq:<key> -> <href>)` appended when the finding belongs to a
|
|
100
|
+
* keyed equation — so a size-only finding inside a long `aligned` block names
|
|
101
|
+
* the result it is about, not just its coordinates.
|
|
102
|
+
*/
|
|
93
103
|
declare function diagnosticLine(diagnostic: AnyDiagnostic): string;
|
|
94
104
|
interface RunLogOptions {
|
|
95
105
|
/** Overridden in tests; defaults to `build/logs` under the repository root. */
|
package/dist/compiler/logging.js
CHANGED
|
@@ -38,7 +38,8 @@ function diagnosticLine(diagnostic) {
|
|
|
38
38
|
const code = diagnostic.code ?? diagnostic.kind;
|
|
39
39
|
const at = [diagnostic.file, diagnostic.line, diagnostic.column].filter((part) => part !== void 0).join(":");
|
|
40
40
|
const prefix = code === void 0 ? "" : `[${code}] `;
|
|
41
|
-
|
|
41
|
+
const equation = diagnostic.equation === void 0 ? "" : ` (eq:${diagnostic.equation.key} -> ${diagnostic.equation.href})`;
|
|
42
|
+
return at === "" ? `${prefix}${diagnostic.message}${equation}` : `${prefix}${at}: ${diagnostic.message}${equation}`;
|
|
42
43
|
}
|
|
43
44
|
function createRunLog(options = {}) {
|
|
44
45
|
const root = options.root ?? LOGS_DIR;
|
|
@@ -101,7 +101,7 @@ import '../reference/keyed-float-validate.js';
|
|
|
101
101
|
* from `warning` to `info`; their diagnostic codes and container fields stay
|
|
102
102
|
* unchanged.
|
|
103
103
|
*/
|
|
104
|
-
declare const MANIFEST_SCHEMA_VERSION =
|
|
104
|
+
declare const MANIFEST_SCHEMA_VERSION = 16;
|
|
105
105
|
declare const MANIFEST_PATH: string;
|
|
106
106
|
interface ManifestCitation {
|
|
107
107
|
readonly id: string;
|
|
@@ -75,7 +75,7 @@ import {
|
|
|
75
75
|
isLessonSlug,
|
|
76
76
|
lessonIdFromSlug
|
|
77
77
|
} from "./lesson-derivation.js";
|
|
78
|
-
const MANIFEST_SCHEMA_VERSION =
|
|
78
|
+
const MANIFEST_SCHEMA_VERSION = 16;
|
|
79
79
|
const REPOSITORY_ROOT = process.cwd();
|
|
80
80
|
const CONTENT_ROOT = join(REPOSITORY_ROOT, "content");
|
|
81
81
|
const MANIFEST_PATH = join(REPOSITORY_ROOT, "build", "manifest.json");
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rule N17: nested scalable
|
|
2
|
+
* Rule N17: a nested scalable delimiter is sized by the engine, never by hand.
|
|
3
|
+
*
|
|
4
|
+
* The engine installs a KaTeX whose `\delimitershortfall` is `-1pt`
|
|
5
|
+
* (`katex-delimiters.mjs`), so a `\left`/`\right` pair is always
|
|
6
|
+
* strictly taller than what it wraps. Nesting depth therefore shows up on its
|
|
7
|
+
* own, and a hand-picked `\bigl`…`\Biggr` inside a nest only replaces a size
|
|
8
|
+
* that tracks the content with a fixed glyph that does not.
|
|
3
9
|
*
|
|
4
10
|
* This deliberately parses only sizing syntax. It does not decide whether a
|
|
5
11
|
* parenthesis, bracket, or brace is semantic, and it never recommends changing
|
|
6
12
|
* one delimiter shape into another. N16's alternating-shape preference remains
|
|
7
13
|
* a review-only authoring concern.
|
|
8
14
|
*/
|
|
9
|
-
declare const DELIMITER_SIZE_DIAGNOSTIC_CODE: "delimiter-size
|
|
15
|
+
declare const DELIMITER_SIZE_DIAGNOSTIC_CODE: "delimiter-manual-size";
|
|
10
16
|
type DelimiterSizeName = 'automatic' | 'big' | 'Big' | 'bigg' | 'Bigg';
|
|
11
17
|
interface DelimiterSizeSuggestion {
|
|
12
|
-
readonly size: Exclude<DelimiterSizeName, 'automatic' | 'big'>;
|
|
13
18
|
readonly open: string;
|
|
14
19
|
readonly close: string;
|
|
15
20
|
readonly latex: string;
|
|
@@ -20,15 +25,19 @@ interface DelimiterSizeDiagnostic {
|
|
|
20
25
|
readonly offset: number;
|
|
21
26
|
readonly line: number;
|
|
22
27
|
readonly column: number;
|
|
23
|
-
|
|
24
|
-
readonly
|
|
25
|
-
readonly suggestion
|
|
28
|
+
/** The hand-picked size that should become `\left`/`\right`. */
|
|
29
|
+
readonly size: Exclude<DelimiterSizeName, 'automatic'>;
|
|
30
|
+
readonly suggestion: DelimiterSizeSuggestion;
|
|
26
31
|
readonly message: string;
|
|
27
32
|
}
|
|
28
33
|
/**
|
|
29
|
-
* Warn when a
|
|
30
|
-
*
|
|
31
|
-
*
|
|
34
|
+
* Warn when a pair that takes part in a nest of scalable delimiters is sized
|
|
35
|
+
* by hand instead of by `\left`/`\right`.
|
|
36
|
+
*
|
|
37
|
+
* A pair takes part in a nest when it contains, or sits inside, another pair
|
|
38
|
+
* this linter tracks. A lone hand-sized pair around otherwise bare content —
|
|
39
|
+
* `\bigl(x(t),\ y(t)\bigr)` for a tuple — is a deliberate typographic
|
|
40
|
+
* choice at a single level, has no hierarchy to get wrong, and is left alone.
|
|
32
41
|
*
|
|
33
42
|
* Only `()`, `[]`, and `\{\}` are parsed. Bare delimiters are intentionally
|
|
34
43
|
* ignored: N15 owns that separate error, while ordinary semantic notation
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const DELIMITER_SIZE_DIAGNOSTIC_CODE = "delimiter-size
|
|
1
|
+
const DELIMITER_SIZE_DIAGNOSTIC_CODE = "delimiter-manual-size";
|
|
2
2
|
const SIZE_SPECS = [
|
|
3
3
|
{
|
|
4
4
|
name: "automatic",
|
|
@@ -40,9 +40,6 @@ const COMMAND_SPECS = SIZE_SPECS.flatMap((spec) => [
|
|
|
40
40
|
{ ...spec, command: spec.openCommand, tokenKind: "open" },
|
|
41
41
|
{ ...spec, command: spec.closeCommand, tokenKind: "close" }
|
|
42
42
|
]).sort((left, right) => right.command.length - left.command.length);
|
|
43
|
-
const SUGGESTION_BY_RANK = new Map(
|
|
44
|
-
SIZE_SPECS.filter((spec) => spec.rank >= 2).map((spec) => [spec.rank, spec])
|
|
45
|
-
);
|
|
46
43
|
function delimiterAt(latex, offset, kind) {
|
|
47
44
|
const candidates = kind === "open" ? ["\\{", "(", "["] : ["\\}", ")", "]"];
|
|
48
45
|
for (const delimiter of candidates) {
|
|
@@ -120,55 +117,34 @@ function lineAndColumn(source, offset) {
|
|
|
120
117
|
column: offset - lastNewline
|
|
121
118
|
};
|
|
122
119
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const spec = SUGGESTION_BY_RANK.get(requiredRank);
|
|
129
|
-
if (!close || !spec || spec.name === "automatic" || spec.name === "big") {
|
|
130
|
-
return void 0;
|
|
131
|
-
}
|
|
132
|
-
return {
|
|
133
|
-
size: spec.name,
|
|
134
|
-
open: `${spec.openCommand}${pair.open.delimiter}`,
|
|
135
|
-
close: `${spec.closeCommand}${close.delimiter}`,
|
|
136
|
-
latex: `${spec.openCommand}${pair.open.delimiter} ... ${spec.closeCommand}${close.delimiter}`
|
|
137
|
-
};
|
|
120
|
+
const SPEC_BY_SIZE = new Map(SIZE_SPECS.map((spec) => [spec.name, spec]));
|
|
121
|
+
function suggestionFor(pair) {
|
|
122
|
+
const open = `\\left${pair.open.delimiter}`;
|
|
123
|
+
const close = `\\right${pair.close?.delimiter ?? pair.open.delimiter}`;
|
|
124
|
+
return { open, close, latex: `${open} ... ${close}` };
|
|
138
125
|
}
|
|
139
126
|
function lintDelimiterSizes(latex) {
|
|
140
127
|
const roots = parseDelimiterPairs(latex);
|
|
141
128
|
if (!roots) return [];
|
|
142
129
|
const diagnostics = [];
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
for (const child of pair.children) {
|
|
147
|
-
const childRequired = assess(child);
|
|
148
|
-
if (childRequired > largestNestedRequirement) {
|
|
149
|
-
largestNestedRequirement = childRequired;
|
|
150
|
-
representativeNestedSize = sizeName(childRequired);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
const requiredRank = largestNestedRequirement === 0 ? pair.open.rank : largestNestedRequirement + 1;
|
|
154
|
-
pair.requiredRank = Math.max(pair.open.rank, requiredRank);
|
|
155
|
-
if (largestNestedRequirement > 0 && pair.open.rank < requiredRank) {
|
|
130
|
+
const visit = (pair, nested) => {
|
|
131
|
+
const inNest = nested || pair.children.length > 0;
|
|
132
|
+
if (inNest && pair.open.pairKind === "manual") {
|
|
156
133
|
const location = lineAndColumn(latex, pair.open.offset);
|
|
157
|
-
const suggestion = suggestionFor(pair
|
|
134
|
+
const suggestion = suggestionFor(pair);
|
|
158
135
|
diagnostics.push({
|
|
159
136
|
code: DELIMITER_SIZE_DIAGNOSTIC_CODE,
|
|
160
137
|
severity: "warning",
|
|
161
138
|
offset: pair.open.offset,
|
|
162
139
|
...location,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
message: suggestion ? `Outer delimiters are not larger than their nested pair. Use ${suggestion.latex}; preserve the original opening and closing delimiter shapes (rule N17).` : `Outer delimiters are not larger than their nested pair, and no larger standard manual delimiter size remains. Preserve the original opening and closing shapes and restructure the expression if necessary (rule N17).`
|
|
140
|
+
size: pair.open.size,
|
|
141
|
+
suggestion,
|
|
142
|
+
message: `A nested delimiter pair is sized by hand with ${SPEC_BY_SIZE.get(pair.open.size)?.openCommand ?? pair.open.size}${pair.open.delimiter}. Use ${suggestion.latex} and let the engine size the nest; preserve the original opening and closing delimiter shapes (rule N17).`
|
|
167
143
|
});
|
|
168
144
|
}
|
|
169
|
-
|
|
145
|
+
for (const child of pair.children) visit(child, true);
|
|
170
146
|
};
|
|
171
|
-
for (const root of roots)
|
|
147
|
+
for (const root of roots) visit(root, false);
|
|
172
148
|
return diagnostics.sort((left, right) => left.offset - right.offset);
|
|
173
149
|
}
|
|
174
150
|
export {
|
|
@@ -35,10 +35,16 @@ export function displayEquationNumber({ prefix, offset, section, indexInSection,
|
|
|
35
35
|
indexInSection: any;
|
|
36
36
|
indexInLesson: any;
|
|
37
37
|
}): string;
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* A display equation's anchor id: `eq-<key>` when it carries a
|
|
40
|
+
* `\label{eq:…}`, else the positional form, e.g. `2.4` → `eq-2-4`.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ key?: string, number?: string | number }} equation - `number` is
|
|
43
|
+
* read only when there is no `key`, so a keyed caller may omit it.
|
|
44
|
+
*/
|
|
39
45
|
export function equationAnchorId({ key, number }: {
|
|
40
|
-
key
|
|
41
|
-
number
|
|
46
|
+
key?: string;
|
|
47
|
+
number?: string | number;
|
|
42
48
|
}): string;
|
|
43
49
|
export function stripFencedCode(markdown: any): string;
|
|
44
50
|
/**
|
|
@@ -106,7 +106,13 @@ export function displayEquationNumber({
|
|
|
106
106
|
: `${prefix}.${offset + indexInLesson}`;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* A display equation's anchor id: `eq-<key>` when it carries a
|
|
111
|
+
* `\label{eq:…}`, else the positional form, e.g. `2.4` → `eq-2-4`.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ key?: string, number?: string | number }} equation - `number` is
|
|
114
|
+
* read only when there is no `key`, so a keyed caller may omit it.
|
|
115
|
+
*/
|
|
110
116
|
export function equationAnchorId({ key, number }) {
|
|
111
117
|
return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
|
|
112
118
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { collectOperators, collectRomanLabels } from "./math-glyphs.mjs";
|
|
2
2
|
import { isBaseAbbreviation } from "./base-abbreviations.mjs";
|
|
3
3
|
import { pageScope } from "./gate-math.js";
|
|
4
|
-
import {
|
|
4
|
+
import { lessonLine, proseOnly } from "./gate-prose-notation.js";
|
|
5
5
|
const CAPS_RUN = /\b([A-Z]{2,})(?:'s|s)?\b/g;
|
|
6
6
|
const DOTTED_ABBR = /\b(?:[A-Za-z]\.){2,}/g;
|
|
7
7
|
function findAbbreviationLikeTokens(text) {
|
|
@@ -51,7 +51,7 @@ function gateAbbreviations(input) {
|
|
|
51
51
|
context: "abbreviation",
|
|
52
52
|
severity: "error",
|
|
53
53
|
file: lesson.source.file,
|
|
54
|
-
line:
|
|
54
|
+
line: lessonLine(lesson, index),
|
|
55
55
|
token,
|
|
56
56
|
message: `Unexplained abbreviation ${JSON.stringify(token)} outside math. ${FIX}`
|
|
57
57
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stripFencedCode } from "./equations.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { lessonLine } from "./gate-prose-notation.js";
|
|
3
3
|
const HEADING_LENGTH_LIMIT = 65;
|
|
4
4
|
const HEADING_LINE = /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm;
|
|
5
5
|
const TITLE_ATTRIBUTE_BY_TAG = {
|
|
@@ -65,7 +65,7 @@ function gateHeadingLength(lessons) {
|
|
|
65
65
|
context: "structure",
|
|
66
66
|
severity: "error",
|
|
67
67
|
file: lesson.source.file,
|
|
68
|
-
line:
|
|
68
|
+
line: lessonLine(lesson, finding.index),
|
|
69
69
|
token: finding.token,
|
|
70
70
|
message: finding.message
|
|
71
71
|
});
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { NotationRegistryInput, NotationLessonInput } from './types.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The equation a finding sits in, when its math carries a `\label{eq:…}`.
|
|
5
|
+
*
|
|
6
|
+
* A location tells you where to edit; this tells you *what you are looking
|
|
7
|
+
* at*. A size-only N17 finding inside a six-line `aligned` block is otherwise
|
|
8
|
+
* anonymous — the message names delimiters, not the result they belong to —
|
|
9
|
+
* so the key and the rendered page's anchor both travel with the diagnostic.
|
|
10
|
+
*/
|
|
11
|
+
interface EquationLink {
|
|
12
|
+
/** The label's key, without the `eq:` prefix. */
|
|
13
|
+
readonly key: string;
|
|
14
|
+
/** Where the reader sees it, e.g. `/l1-ar/ar1-basics/#eq-ar1-variance`. */
|
|
15
|
+
readonly href: string;
|
|
16
|
+
}
|
|
3
17
|
interface MathGateDiagnostic {
|
|
4
18
|
readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
|
|
5
19
|
/**
|
|
@@ -31,6 +45,8 @@ interface MathGateDiagnostic {
|
|
|
31
45
|
readonly line?: number;
|
|
32
46
|
readonly column?: number;
|
|
33
47
|
readonly token?: string;
|
|
48
|
+
/** Present when the offending math carries a `\label{eq:…}`. */
|
|
49
|
+
readonly equation?: EquationLink;
|
|
34
50
|
readonly message: string;
|
|
35
51
|
}
|
|
36
52
|
interface AssessmentItemLike {
|
|
@@ -58,6 +74,12 @@ type ScopeEntry = {
|
|
|
58
74
|
* hand and risk drifting from what the completeness gate actually checks.
|
|
59
75
|
*/
|
|
60
76
|
declare function pageScope(lesson: NotationLessonInput, sharedByKey: ReadonlyMap<string, ScopeEntry>): ScopeEntry[];
|
|
77
|
+
/**
|
|
78
|
+
* The `\label{eq:…}` a math source carries, as a key plus the anchor the
|
|
79
|
+
* rendered page gives it. `EQ_LABEL_PATTERN` is global, so its `lastIndex` is
|
|
80
|
+
* reset before use — a stale one would skip the first label of the next span.
|
|
81
|
+
*/
|
|
82
|
+
declare function equationLinkFor(lessonId: string, latex: string): EquationLink | undefined;
|
|
61
83
|
/**
|
|
62
84
|
* Resolve every rendered-math context against the page glyph table. Returns one
|
|
63
85
|
* diagnostic per unresolved token; an empty array means the gate passed.
|
|
@@ -68,4 +90,4 @@ declare function gateContentMath(input: {
|
|
|
68
90
|
readonly lessonAssessments: readonly LessonAssessmentLink[];
|
|
69
91
|
}): MathGateDiagnostic[];
|
|
70
92
|
|
|
71
|
-
export { type MathGateDiagnostic, type ScopeEntry, gateContentMath, pageScope };
|
|
93
|
+
export { type EquationLink, type MathGateDiagnostic, type ScopeEntry, equationLinkFor, gateContentMath, pageScope };
|