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
|
@@ -837,15 +837,16 @@ change to that file.
|
|
|
837
837
|
## N15
|
|
838
838
|
|
|
839
839
|
**A nested visible delimiter — `(`, `[`, `\{`, or `|` inside another one of
|
|
840
|
-
them — must use
|
|
840
|
+
them — must use the scalable `\left`/`\right` form, not the bare
|
|
841
|
+
character.**
|
|
841
842
|
|
|
842
843
|
`(`, `[`, `\{` (an escaped, visible brace — bare `{`/`}` is invisible LaTeX
|
|
843
844
|
grouping and out of scope here), and `|` may be written bare only when they
|
|
844
845
|
are not nested — not inside one another, and not inside a second instance of
|
|
845
846
|
themselves. The moment one of them sits inside another, **every level of that
|
|
846
|
-
nest** uses
|
|
847
|
-
`\left
|
|
848
|
-
|
|
847
|
+
nest** uses the auto-sizing form (`\left(`/`\right)`, `\left[`/`\right]`,
|
|
848
|
+
`\left\{`/`\right\}`, `\left|`/`\right|`) — and, per [N17](#n17), only
|
|
849
|
+
that form.
|
|
849
850
|
|
|
850
851
|
```text
|
|
851
852
|
\sin(2(90\text{°}-\theta)) ✗ two bare ( nested
|
|
@@ -874,10 +875,10 @@ author on a case that can render invisibly wrong.
|
|
|
874
875
|
_How to apply._
|
|
875
876
|
|
|
876
877
|
- Reach for `\left(`/`\right)` (or the bracket/brace/pipe equivalents) the
|
|
877
|
-
moment a second one of these four glyphs would end up inside the first
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
878
|
+
moment a second one of these four glyphs would end up inside the first.
|
|
879
|
+
Sizing the outer pair does not exempt an inner bare one; both levels of a
|
|
880
|
+
two-level nest need a sizing command. [N17](#n17) then leaves every size to
|
|
881
|
+
the engine.
|
|
881
882
|
- Mixing types counts as nesting too: `(a + [b])`, `\{a \mid (b)\}`, and
|
|
882
883
|
`(|a|)` all need every level sized, exactly like nesting the same character
|
|
883
884
|
twice.
|
|
@@ -949,35 +950,35 @@ mark from notation whose shape carries meaning. That judgment belongs to the
|
|
|
949
950
|
authoring agent or reviewer, not a syntax linter.
|
|
950
951
|
|
|
951
952
|
_How to apply._ Decide whether each pair is stylistic before choosing its
|
|
952
|
-
shape. Alternate only the pairs that pass that test.
|
|
953
|
-
|
|
954
|
-
size
|
|
953
|
+
shape. Alternate only the pairs that pass that test. Sizes are not your
|
|
954
|
+
concern: [N17](#n17) hands every nested pair to the engine, which grows each
|
|
955
|
+
level automatically. Shape is an authoring decision; size is not.
|
|
955
956
|
|
|
956
957
|
```text
|
|
957
|
-
\
|
|
958
|
+
\left[
|
|
958
959
|
a+
|
|
959
|
-
\
|
|
960
|
+
\left(
|
|
960
961
|
b+
|
|
961
|
-
\
|
|
962
|
+
\left[
|
|
962
963
|
c+
|
|
963
964
|
\left(d+e\right)
|
|
964
|
-
\
|
|
965
|
-
\
|
|
966
|
-
\
|
|
965
|
+
\right]
|
|
966
|
+
\right)
|
|
967
|
+
\right] ✓
|
|
967
968
|
```
|
|
968
969
|
|
|
969
970
|
Semantic notation stays intact even when stylistic grouping surrounds it:
|
|
970
971
|
|
|
971
972
|
```text
|
|
972
|
-
\
|
|
973
|
-
\mathbb{E}\!\
|
|
973
|
+
\left[
|
|
974
|
+
\mathbb{E}\!\left[
|
|
974
975
|
f\!\left(X\right)
|
|
975
|
-
\
|
|
976
|
+
\right]
|
|
976
977
|
+
|
|
977
|
-
\
|
|
978
|
+
\left(
|
|
978
979
|
a+b
|
|
979
|
-
\
|
|
980
|
-
\
|
|
980
|
+
\right)
|
|
981
|
+
\right] ✓
|
|
981
982
|
```
|
|
982
983
|
|
|
983
984
|
In particular, never turn `\mathbb{E}[X]` into another delimiter type merely
|
|
@@ -985,7 +986,7 @@ to alternate shapes.
|
|
|
985
986
|
|
|
986
987
|
_Enforcement._ Review-only. Delimiter semantics cannot be inferred reliably
|
|
987
988
|
from LaTeX syntax, so no automated check enforces alternation. The N17 linter
|
|
988
|
-
checks
|
|
989
|
+
checks sizing syntax only and never recommends changing a delimiter type.
|
|
989
990
|
|
|
990
991
|
_Exceptions._ Every conventional or semantically meaningful delimiter is
|
|
991
992
|
outside the alternation preference. Preserving established mathematical
|
|
@@ -995,49 +996,66 @@ notation takes precedence over the stylistic cycle.
|
|
|
995
996
|
|
|
996
997
|
## N17
|
|
997
998
|
|
|
998
|
-
**
|
|
999
|
-
|
|
999
|
+
**A nest of scalable `()`, `[]`, or `\{\}` delimiters is sized by the engine:
|
|
1000
|
+
use `\left ... \right` at every level, and never hand-size a pair inside a nest
|
|
1001
|
+
with `\bigl`, `\Bigl`, `\biggl`, or `\Biggl`.**
|
|
1000
1002
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
2. next: `\Bigl ... \Bigr`;
|
|
1006
|
-
3. next: `\biggl ... \biggr`;
|
|
1007
|
-
4. outermost: `\Biggl ... \Biggr`.
|
|
1008
|
-
|
|
1009
|
-
The opening and closing commands change size only. Preserve each delimiter's
|
|
1010
|
-
shape: `[` remains `[`, `(` remains `(`, and `\{` remains `\{`.
|
|
1003
|
+
```text
|
|
1004
|
+
\left[a+\left(b+\left[c+\left(d+e\right)\right]\right)\right] ✓
|
|
1005
|
+
\Biggl[a+\biggl(b+\Bigl[c+\left(d+e\right)\Bigr]\biggr)\Biggr] ✗ hand-sized
|
|
1006
|
+
```
|
|
1011
1007
|
|
|
1012
|
-
_Rationale._
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1008
|
+
_Rationale._ In stock TeX and stock KaTeX a `\left ... \right` pair is allowed
|
|
1009
|
+
to fall up to `\delimitershortfall` (5pt) short of its content, so two pairs at
|
|
1010
|
+
different depths around similar content round to the *same* glyph and the
|
|
1011
|
+
nesting disappears. The historical repair was to hand-pick a larger size at
|
|
1012
|
+
each level, which trades one defect for another: `\bigl`–`\Biggl` are
|
|
1013
|
+
*fixed* glyphs, so they stop tracking the content they wrap, they run out after
|
|
1014
|
+
four levels, and an edit to the inner expression silently invalidates every
|
|
1015
|
+
size above it.
|
|
1016
|
+
|
|
1017
|
+
This engine removes the cause instead. `src/reference/katex-delimiters.mjs`
|
|
1018
|
+
installs a KaTeX whose `\delimitershortfall` is `-1pt` — the same one-constant
|
|
1019
|
+
change an author would make in a LaTeX preamble — see
|
|
1020
|
+
[`docs/katex-bracket-scaling.md`](../katex-bracket-scaling.md) for the
|
|
1021
|
+
derivation. A pair is then always *strictly* taller than what it wraps, so each
|
|
1022
|
+
enclosing level grows on its own, by exactly as much as its content needs, at
|
|
1023
|
+
any depth. Hand-sizing is no longer a repair; it is an override that throws the
|
|
1024
|
+
automatic hierarchy away.
|
|
1017
1025
|
|
|
1018
|
-
_How to apply._
|
|
1019
|
-
pair the next larger level. Four or fewer levels must have a strict increase at
|
|
1020
|
-
every level. If a valid expression needs more levels than the standard sizes
|
|
1021
|
-
can distinguish, restructure the expression rather than inventing a fifth
|
|
1022
|
-
size. This rule is independent of N16: semantic shapes stay semantic, but their
|
|
1023
|
-
sizes still increase when their scalable pairs are nested.
|
|
1026
|
+
_How to apply._
|
|
1024
1027
|
|
|
1025
|
-
|
|
1028
|
+
- Write `\left ... \right` at every level of a nest and leave the sizes alone.
|
|
1029
|
+
With the patched engine there is nothing further to do.
|
|
1030
|
+
- Do not "help" the engine with a manual size. If a nest still looks wrong,
|
|
1031
|
+
the expression is too deep to read — restructure it, or pull an inner group
|
|
1032
|
+
into its own display, rather than hand-sizing it.
|
|
1033
|
+
- This rule is independent of [N16](#n16): shapes still alternate where they
|
|
1034
|
+
are stylistic, and semantic shapes are still preserved. Only the *size*
|
|
1035
|
+
decision moved to the engine.
|
|
1036
|
+
- A *lone* hand-sized pair that is not part of a nest is untouched — a tuple
|
|
1037
|
+
written `\bigl(x(t),\ y(t)\bigr)` has no hierarchy to get wrong and stays a
|
|
1038
|
+
deliberate typographic choice.
|
|
1039
|
+
- Nothing to install. The sizing rides along with the engine: a course's
|
|
1040
|
+
`astro.config.mjs` already imports `explico/reference/katex-options.mjs`,
|
|
1041
|
+
which installs it before anything renders. If it ever fails to install, the
|
|
1042
|
+
build stops with an explicit error rather than quietly falling back to stock
|
|
1043
|
+
KaTeX.
|
|
1044
|
+
|
|
1045
|
+
_Enforcement._ Tier-3 warning `delimiter-manual-size`, emitted by
|
|
1026
1046
|
`lintDelimiterSizes` (`src/reference/delimiter-size.ts`) through the normal
|
|
1027
1047
|
math gate for lesson math, notation formulas, and assessment math. The linter
|
|
1028
|
-
uses a stack to parse arbitrarily deep `\left...\right`,
|
|
1029
|
-
`\
|
|
1030
|
-
`\
|
|
1031
|
-
pair's source line and column and
|
|
1032
|
-
|
|
1033
|
-
|
|
1048
|
+
uses a stack to parse arbitrarily deep `\left...\right`, `\bigl...\bigr`,
|
|
1049
|
+
`\Bigl...\Bigr`, `\biggl...\biggr`, and `\Biggl...\Biggr` pairs for `()`, `[]`,
|
|
1050
|
+
and `\{\}`, and warns for each hand-sized pair that contains, or sits inside,
|
|
1051
|
+
another pair it tracks. It reports that pair's source line and column and
|
|
1052
|
+
suggests the `\left`/`\right` replacement, repeating the exact opening and
|
|
1053
|
+
closing delimiter shapes it found. Malformed pairs remain KaTeX syntax errors;
|
|
1054
|
+
the linter does not guess at their structure.
|
|
1034
1055
|
|
|
1035
1056
|
The automated check deliberately does **not** infer mathematical semantics or
|
|
1036
|
-
lint delimiter-type alternation.
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
replacement; restructure the expression. Bare, non-nested semantic notation
|
|
1042
|
-
such as `f(x)` or `\mathbb{E}[X]` is outside this linter and remains governed by
|
|
1043
|
-
the mathematical convention and [N15](#n15)'s separate bare-nesting rule.
|
|
1057
|
+
lint delimiter-type alternation.
|
|
1058
|
+
|
|
1059
|
+
_Exceptions._ None inside a nest. Bare, non-nested semantic notation such as
|
|
1060
|
+
`f(x)` or `\mathbb{E}[X]` is outside this linter and remains governed by the
|
|
1061
|
+
mathematical convention and [N15](#n15)'s separate bare-nesting rule.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Auto-scaling brackets in KaTeX (and MathJax)
|
|
2
|
+
|
|
3
|
+
> **Status in this repo.** Solution 2 is implemented, but as *code* rather than
|
|
4
|
+
> as a `patches/` file: [`src/reference/katex-delimiters.mjs`](../src/reference/katex-delimiters.mjs)
|
|
5
|
+
> applies the same one-constant change at import time, so it ships inside the
|
|
6
|
+
> published `explico` package and a downstream course inherits it with no
|
|
7
|
+
> install step. Rule [N17](authoring/notation.md#n17) depends on it. The
|
|
8
|
+
> sections below are the derivation; see
|
|
9
|
+
> [How this repo applies it](#how-this-repo-applies-it).
|
|
10
|
+
|
|
11
|
+
## The problem
|
|
12
|
+
|
|
13
|
+
In LaTeX, `\left[ ... \right]` sizes a bracket to fit its content — but nested brackets often come out **the same size**, because the sizing rule only requires the delimiter to be *close enough* to the content's height, not strictly bigger.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
|
|
17
|
+
```latex
|
|
18
|
+
$$\left[ \left[ \sqrt{\frac{\int\limits_0^{10}\sqrt{a^2+b^2}}{2}} \right] + 1 \right]$$
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The outer `[` and inner `[` render at the same size, even though the outer one wraps strictly more content. The alternative — manually choosing `\bigl`, `\Bigl`, `\biggl`, `\Biggl` — fixes the sizing but abandons `\left`/`\right`'s automatic "match the height of everything inside" behavior, since those are fixed-size delimiters that don't adapt to content.
|
|
22
|
+
|
|
23
|
+
So there are two properties you want simultaneously, and the two standard tools each give you only one:
|
|
24
|
+
|
|
25
|
+
1. **Strict growth outward** — each nesting level's bracket must be visibly bigger than the one inside it.
|
|
26
|
+
2. **Full coverage** — every bracket must span the full height of its content, with no gap.
|
|
27
|
+
|
|
28
|
+
`\bigl`/`\Bigl`/`\biggl`/`\Biggl` give you (1) but not (2) (fixed sizes don't track arbitrary content). `\left`/`\right` give you (2) but not (1) (the sizing rule allows equal-looking sizes at different nesting depths).
|
|
29
|
+
|
|
30
|
+
## Why it happens: the underlying rule
|
|
31
|
+
|
|
32
|
+
TeX sizes a `\left`/`\right` pair using this rule (TeXbook, Appendix G, rule 19):
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
s_min = max( 2·δ₀ · f / 1000 , 2·δ₀ − l )
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
TeX then picks the smallest available delimiter glyph whose total size `s` satisfies `s ≥ s_min`.
|
|
39
|
+
|
|
40
|
+
| Symbol | Meaning |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `h` | height of the content: how far it reaches above the baseline |
|
|
43
|
+
| `d` | depth of the content: how far it reaches below the baseline |
|
|
44
|
+
| `a` | axis height — the line fraction bars and minus signs sit on; delimiters are centered on this line |
|
|
45
|
+
| `δ₀` | `max(h − a, d + a)` — how far the content reaches from the axis, on whichever side is farther |
|
|
46
|
+
| `f` | `\delimiterfactor`, default **901** |
|
|
47
|
+
| `l` | `\delimitershortfall`, default **5pt** |
|
|
48
|
+
| `s_min` | the smallest total bracket size TeX will accept |
|
|
49
|
+
| `s` | the size of the delimiter TeX actually picks |
|
|
50
|
+
|
|
51
|
+
With the default `l = 5pt`, `s_min` can be *smaller* than the content's own height (since `901/1000 ≈ 0.9`), so a delimiter that already matches the inner bracket's height passes the test for the outer bracket too — no forced growth. The same slack also means a delimiter can legally fall up to 5pt short of the content, i.e. requirement (2) isn't fully guaranteed by default either.
|
|
52
|
+
|
|
53
|
+
**The fix in real LaTeX:** set `\delimitershortfall` to a *negative* value (e.g. `-1pt`). Then `s_min = 2δ₀ + 1pt`, which is always larger than the content's own extent — forcing outward growth at every level, automatically, with no per-formula work. This is a document-wide setting, not something you write per equation:
|
|
54
|
+
|
|
55
|
+
```latex
|
|
56
|
+
\delimitershortfall=-1pt
|
|
57
|
+
\left[ \left[ \sqrt{\frac{\int\limits_0^{10}\sqrt{a^2+b^2}}{2}} \right] + 1 \right]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Verified by rendering with `pdflatex`: default nesting produces equal-sized brackets; the same source with `\delimitershortfall=-1pt` produces strictly increasing sizes at every depth, and small content (`(x)`, `((x))`, `(((x)))`) is unaffected since the correction is tiny relative to normal delimiter jumps.
|
|
61
|
+
|
|
62
|
+
## Why it doesn't carry over to the web
|
|
63
|
+
|
|
64
|
+
`\delimitershortfall` is a TeX engine primitive. **Neither KaTeX nor MathJax expose it or honor it** — I tested both directly:
|
|
65
|
+
|
|
66
|
+
- KaTeX: `\delimitershortfall=-1pt` throws `ParseError: Expected group after '\delimitershortfall'` (undefined control sequence).
|
|
67
|
+
- MathJax v3: the command is silently dropped/renders as an error span; no effect on sizing.
|
|
68
|
+
|
|
69
|
+
So the web renderers need a different fix.
|
|
70
|
+
|
|
71
|
+
## Solution 1: a `\grow` macro (portable, works in *any* KaTeX/MathJax page)
|
|
72
|
+
|
|
73
|
+
Since neither engine's *rule* can be changed from user-level TeX, the workaround changes the **content** being measured instead. Wrap everything inside a bracket pair (except the innermost level) in an invisible padding macro that makes the content measure as slightly taller and deeper than it visually is. Then the normal `\left`/`\right` sizing — which *does* work correctly in both engines — is forced to pick a bigger glyph at each level, because the content it measures really is bigger.
|
|
74
|
+
|
|
75
|
+
**Mechanism:** for content `#1`, stack a raised zero-width phantom copy 0.5em above it and a lowered zero-width phantom copy 0.5em below it. `\vphantom{#1}` takes zero horizontal space and is invisible, but claims the same height/depth as `#1`. Raising and lowering copies of it extends the *measured* bounding box by 0.5em on each side — 1em of forced growth per wrap — without changing what's visually drawn.
|
|
76
|
+
|
|
77
|
+
**KaTeX** (verified — `\raisebox` only accepts a text-mode argument, so the phantom must be re-entered as math and forced to `\displaystyle`; without that it silently shrinks to inline size and the trick does nothing):
|
|
78
|
+
|
|
79
|
+
```latex
|
|
80
|
+
\newcommand{\grow}[1]{#1\raisebox{0.5em}{$\displaystyle\vphantom{#1}$}\raisebox{-0.5em}{$\displaystyle\vphantom{#1}$}}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**MathJax** (verified — `\raisebox` doesn't exist here; plain-TeX `\raise`/`\lower` take a math argument directly and preserve the current style):
|
|
84
|
+
|
|
85
|
+
```latex
|
|
86
|
+
\newcommand{\grow}[1]{#1\raise{0.5em}{\vphantom{#1}}\lower{0.5em}{\vphantom{#1}}}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Usage** — wrap the content of every bracket pair except the innermost:
|
|
90
|
+
|
|
91
|
+
```latex
|
|
92
|
+
\left[ \grow{ \left[ \sqrt{\frac{\int\limits_0^{10}\sqrt{a^2+b^2}}{2}} \right] + 1 } \right]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Verified by rendering with real KaTeX 0.18.7 and MathJax 3.2.2 (headless Chromium screenshots): both show strictly increasing bracket sizes across 2–3 nesting levels, matching the pdfLaTeX reference.
|
|
96
|
+
|
|
97
|
+
**Trade-offs:**
|
|
98
|
+
- Manual — you place `\grow{}` yourself at each level; it isn't automatic like the LaTeX-engine setting.
|
|
99
|
+
- The two spellings aren't interchangeable: `\raisebox` errors out in MathJax, `\raise`/`\lower` don't exist in KaTeX. Pick the one matching your renderer.
|
|
100
|
+
- Can be defined once (in MathJax's `tex.macros` config, or KaTeX's `macros` render option) so you only write `\grow{...}` per formula, not the full definition each time.
|
|
101
|
+
|
|
102
|
+
## Solution 2: patch KaTeX directly (better fit for a site you build/host yourself)
|
|
103
|
+
|
|
104
|
+
Since you control the build (an Astro site rendering KaTeX server-side), you don't need the macro workaround at all — you can restore the actual `\delimitershortfall` behavior by editing the one place KaTeX hard-codes it.
|
|
105
|
+
|
|
106
|
+
**The code.** KaTeX's delimiter-sizing function (`makeLeftRightDelim`, in the compiled bundle since no per-file TS source ships in the npm package) contains:
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
const delimiterFactor = 901; // TeX's \delimiterfactor
|
|
110
|
+
const delimiterExtend = 5.0 / options.fontMetrics().ptPerEm; // TeX's \delimitershortfall = 5pt
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This is the exact same formula from the TeXbook rule above, with the same two constants. Changing `5.0` to `-1.0` is precisely `\delimitershortfall=-1pt` — `delimiterFactor` doesn't need to change, since a negative shortfall always makes the second term of the `max(...)` dominate anyway.
|
|
114
|
+
|
|
115
|
+
**Verified:** I applied this one-line edit to the installed `katex` package and rendered plain, unmodified `\left[...\right]` source (no `\grow` macro anywhere) through the patched build. Result: strict growth across 4 nesting levels, small content (`(x)`, `((x))`, `(((x)))`) unaffected, and — critically — the LaTeX source embedded in KaTeX's output for copy/paste (`<annotation encoding="application/x-tex">`) stays exactly what you typed, since only the *sizing*, not the *content*, is touched.
|
|
116
|
+
|
|
117
|
+
**Where the line lives** (npm package, KaTeX 0.16.47):
|
|
118
|
+
|
|
119
|
+
| File | Used by | Patched here |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `src/delimiter.ts` | the readable source the bundles are built from | yes |
|
|
122
|
+
| `dist/katex.mjs` | ESM import — what an Astro/Vite build resolves to | yes |
|
|
123
|
+
| `dist/katex.js` | CJS `require` | yes |
|
|
124
|
+
| `dist/katex.min.js` | only if KaTeX is also loaded client-side via `<script>` | **no** — see below |
|
|
125
|
+
|
|
126
|
+
`dist/katex.min.js` is left alone on purpose. explico renders every formula at
|
|
127
|
+
build time and ships only `katex.min.css` plus fonts to the browser, so nothing
|
|
128
|
+
here loads that bundle — and because it is a single ~270 KB line, including it
|
|
129
|
+
turns a 2 KB patch into a 550 KB one that no reviewer can read and no KaTeX
|
|
130
|
+
upgrade can rebase. A course that *does* render KaTeX in the browser has to
|
|
131
|
+
patch it too; the minified form of the same line is
|
|
132
|
+
`l=5/n.fontMetrics().ptPerEm` inside `makeLeftRightDelim`.
|
|
133
|
+
|
|
134
|
+
## How this repo applies it
|
|
135
|
+
|
|
136
|
+
A `pnpm patch` would work, and is what the section above describes, but it is
|
|
137
|
+
**project-local**: pnpm records it in the consuming repo's
|
|
138
|
+
`pnpm-workspace.yaml`, so it cannot travel inside the published `explico`
|
|
139
|
+
package. Every downstream course would have to reapply it, and a course that
|
|
140
|
+
forgot would render flat nests while its linter told authors the engine handled
|
|
141
|
+
sizing. Documenting that in a migration guide only narrows the window for the
|
|
142
|
+
mistake; it does not close it.
|
|
143
|
+
|
|
144
|
+
So the engine does the same edit in code, at import time, in
|
|
145
|
+
[`src/reference/katex-delimiters.mjs`](../src/reference/katex-delimiters.mjs):
|
|
146
|
+
|
|
147
|
+
1. read KaTeX's own ESM bundle off disk (`katex/dist/katex.mjs`);
|
|
148
|
+
2. rewrite that one constant, throwing if it is not found verbatim;
|
|
149
|
+
3. `import()` the rewritten source as a `data:` URL — the bundle has no imports
|
|
150
|
+
of its own, so it needs no base to resolve against;
|
|
151
|
+
4. move `render`, `renderToString`, `__renderToDomTree` and
|
|
152
|
+
`__renderToHTMLTree` onto the shared `katex` object every consumer in the
|
|
153
|
+
process already imported;
|
|
154
|
+
5. render a known nest and throw unless the sizes actually grew.
|
|
155
|
+
|
|
156
|
+
Step 4 is what makes it reach lesson-body math. `rehype-katex` does its own
|
|
157
|
+
`import katex from 'katex'`, but it calls `katex.renderToString(...)` — a
|
|
158
|
+
property read at call time — and KaTeX's default export is a plain mutable
|
|
159
|
+
object. Replacing the function on that object is therefore enough; the plugin
|
|
160
|
+
does not have to be forked or aliased.
|
|
161
|
+
|
|
162
|
+
**A course inherits this with no changes.** `astro.config.mjs` already imports
|
|
163
|
+
`createNotationKatexOptions` from `explico/reference/katex-options.mjs`, and
|
|
164
|
+
that module imports the installer for its side effect, so the sizing is in
|
|
165
|
+
place before Astro renders anything. The engine's own non-Astro paths
|
|
166
|
+
(`math-glyphs.mjs`, `render-lab-math.ts`, and therefore `validate:content`)
|
|
167
|
+
import it directly.
|
|
168
|
+
|
|
169
|
+
**It cannot fail quietly.** Steps 2 and 5 throw. A KaTeX upgrade that moves the
|
|
170
|
+
constant, or anything that renders before the installer runs, stops the build
|
|
171
|
+
with an explicit message instead of silently reverting to stock spacing — which
|
|
172
|
+
is the failure mode a patch file plus a migration note could not rule out.
|
|
173
|
+
|
|
174
|
+
**Trade-offs.** It is a monkey-patch of a third-party module object, so it
|
|
175
|
+
rests on three upstream properties: the constant is a literal in the shipped
|
|
176
|
+
bundle, the bundle is self-contained, and the default export is mutable. All
|
|
177
|
+
three are asserted at install time rather than assumed. It also holds a second
|
|
178
|
+
copy of the KaTeX module in memory (~600 KB of source, ~50 ms to load) for the
|
|
179
|
+
duration of a build, which is not worth optimising away.
|
|
180
|
+
|
|
181
|
+
## Why the patch is the better fit here
|
|
182
|
+
|
|
183
|
+
Your original idea — keep writing `\left[` everywhere, run a build-time preprocessing step that inserts `\grow{}` at each nesting level, then swap in the original source for a "copy" button — would work, but has two costs the patch avoids:
|
|
184
|
+
|
|
185
|
+
1. **The preprocessor needs a real TeX parser**, not a regex. It has to track bracket nesting depth correctly across `\text{...}`, comments, `\right.` (null right delimiters), and — critically — macros. If you ever define something like `\norm{...}` that *expands* to `\left\lVert...\right\rVert`, a source-level preprocessor can't see the nesting hidden inside the macro and will miscount levels. The patch operates after macro expansion, inside KaTeX's own layout engine, so it can't miss anything.
|
|
186
|
+
2. **The copy-button problem disappears.** KaTeX already embeds your literal input in the hidden MathML `<annotation>` tag — that's what the standard `copy-tex` extension reads. With preprocessing, that annotation would contain the `\grow`-polluted string, so you'd have to intercept the copy handler and substitute the original text yourself. With the patch, the *displayed* math and the *embedded source* never diverge in the first place, because nothing was ever inserted into the source.
|
|
187
|
+
|
|
188
|
+
## Caveats
|
|
189
|
+
|
|
190
|
+
- If your pipeline actually renders to **SVG** rather than HTML+CSS, you're using MathJax's `tex-svg` output, not KaTeX (KaTeX renders to HTML spans, not SVG). MathJax's stretchy-delimiter logic lives in the output stage rather than one constants block, so the equivalent patch would be far more invasive there — the `\grow` macro is the practical option for MathJax.
|
|
191
|
+
- On an Astro build, KaTeX runs at build time (via `remark-math`/`rehype-katex` or similar) and only sends finished HTML + `katex.css` + fonts to the browser — the patched JS never ships to visitors, it only needs to be present in your build environment.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explico",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Build-time engine for prerequisite-aware, notation-rigorous course content: one validated content model, a semantic notation/citation/equation system with a completeness gate, and a deterministic JSON manifest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"README.md",
|
|
31
31
|
"CHANGELOG.md",
|
|
32
32
|
"docs/VERSIONING.md",
|
|
33
|
+
"docs/katex-bracket-scaling.md",
|
|
33
34
|
"docs/authoring"
|
|
34
35
|
],
|
|
35
36
|
"dependencies": {
|
package/src/compiler/cli.ts
CHANGED
|
@@ -49,6 +49,7 @@ import remarkNotation, {
|
|
|
49
49
|
} from '../reference/remark-notation.mjs';
|
|
50
50
|
import remarkShortNote from '../reference/remark-short-note.mjs';
|
|
51
51
|
import type { DiagnosticSeverity } from '../reference/types';
|
|
52
|
+
import type { EquationLink } from '../reference/gate-math';
|
|
52
53
|
import type { Manifest, ManifestLesson } from './manifest';
|
|
53
54
|
import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
|
|
54
55
|
|
|
@@ -90,6 +91,13 @@ export interface CliDiagnostic {
|
|
|
90
91
|
readonly file?: string;
|
|
91
92
|
readonly line?: number;
|
|
92
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;
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
function diagnosticSeverity(value: unknown): DiagnosticSeverity {
|
|
@@ -165,6 +173,9 @@ export function lessonDiagnostics(
|
|
|
165
173
|
file: diagnostic.file,
|
|
166
174
|
...(diagnostic.line === undefined ? {} : { line: diagnostic.line }),
|
|
167
175
|
...(diagnostic.column === undefined ? {} : { column: diagnostic.column }),
|
|
176
|
+
...(diagnostic.equation === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: { equation: diagnostic.equation }),
|
|
168
179
|
});
|
|
169
180
|
}
|
|
170
181
|
|
|
@@ -621,8 +632,18 @@ type DiagnosticRecord = Record<string, unknown> & {
|
|
|
621
632
|
readonly line?: unknown;
|
|
622
633
|
readonly column?: unknown;
|
|
623
634
|
readonly source?: unknown;
|
|
635
|
+
readonly equation?: unknown;
|
|
624
636
|
};
|
|
625
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
|
+
|
|
626
647
|
function numericLocation(value: unknown): number | undefined {
|
|
627
648
|
return typeof value === 'number' && Number.isInteger(value) && value > 0
|
|
628
649
|
? value
|
|
@@ -685,6 +706,7 @@ export function contentDiagnostics(
|
|
|
685
706
|
: typeof diagnostic.kind === 'string'
|
|
686
707
|
? diagnostic.kind
|
|
687
708
|
: 'diagnostic';
|
|
709
|
+
const equation = equationLink(diagnostic.equation);
|
|
688
710
|
diagnostics.push({
|
|
689
711
|
code: `${group}:${detail}`,
|
|
690
712
|
severity,
|
|
@@ -692,6 +714,7 @@ export function contentDiagnostics(
|
|
|
692
714
|
...(file === undefined ? {} : { file }),
|
|
693
715
|
...(line === undefined ? {} : { line }),
|
|
694
716
|
...(column === undefined ? {} : { column }),
|
|
717
|
+
...(equation === undefined ? {} : { equation }),
|
|
695
718
|
});
|
|
696
719
|
}
|
|
697
720
|
}
|
|
@@ -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
|
}
|
package/src/compiler/logging.ts
CHANGED
|
@@ -130,6 +130,8 @@ export interface AnyDiagnostic {
|
|
|
130
130
|
readonly file?: string;
|
|
131
131
|
readonly line?: number;
|
|
132
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 };
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
/** `manifest.diagnostics`, structurally — one array per subsystem. */
|
|
@@ -157,16 +159,25 @@ export interface RunLog {
|
|
|
157
159
|
close(): RunLogSummary;
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
/**
|
|
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
|
+
*/
|
|
161
168
|
export function diagnosticLine(diagnostic: AnyDiagnostic): string {
|
|
162
169
|
const code = diagnostic.code ?? diagnostic.kind;
|
|
163
170
|
const at = [diagnostic.file, diagnostic.line, diagnostic.column]
|
|
164
171
|
.filter((part) => part !== undefined)
|
|
165
172
|
.join(':');
|
|
166
173
|
const prefix = code === undefined ? '' : `[${code}] `;
|
|
174
|
+
const equation =
|
|
175
|
+
diagnostic.equation === undefined
|
|
176
|
+
? ''
|
|
177
|
+
: ` (eq:${diagnostic.equation.key} -> ${diagnostic.equation.href})`;
|
|
167
178
|
return at === ''
|
|
168
|
-
? `${prefix}${diagnostic.message}`
|
|
169
|
-
: `${prefix}${at}: ${diagnostic.message}`;
|
|
179
|
+
? `${prefix}${diagnostic.message}${equation}`
|
|
180
|
+
: `${prefix}${at}: ${diagnostic.message}${equation}`;
|
|
170
181
|
}
|
|
171
182
|
|
|
172
183
|
export interface RunLogOptions {
|
package/src/compiler/manifest.ts
CHANGED
|
@@ -191,7 +191,7 @@ import {
|
|
|
191
191
|
* from `warning` to `info`; their diagnostic codes and container fields stay
|
|
192
192
|
* unchanged.
|
|
193
193
|
*/
|
|
194
|
-
export const MANIFEST_SCHEMA_VERSION =
|
|
194
|
+
export const MANIFEST_SCHEMA_VERSION = 16;
|
|
195
195
|
|
|
196
196
|
const REPOSITORY_ROOT = process.cwd();
|
|
197
197
|
// Content is the top-level `content/` folder; the build output is `build/`
|
|
@@ -159,13 +159,19 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
|
|
|
159
159
|
<NotationMath class="glossary-symbol" latex={entry.data.latex} />
|
|
160
160
|
</span>
|
|
161
161
|
<div>
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
{/* The anchor is a sibling of the heading, not a child — the
|
|
163
|
+
same shape `rehype-heading-anchors` gives a lesson heading.
|
|
164
|
+
Inside the `h2` its `aria-label` joins the heading's
|
|
165
|
+
accessible name ("Velocity Link to “Velocity”"). */}
|
|
166
|
+
<div class="glossary-heading">
|
|
167
|
+
<h2>
|
|
168
|
+
<a
|
|
169
|
+
class="glossary-term-link"
|
|
170
|
+
href={`#notation-${entry.data.key}`}
|
|
171
|
+
>
|
|
172
|
+
{labelOf(entry.data)}
|
|
173
|
+
</a>
|
|
174
|
+
</h2>
|
|
169
175
|
<a
|
|
170
176
|
class="glossary-anchor"
|
|
171
177
|
href={`#notation-${entry.data.key}`}
|
|
@@ -177,7 +183,7 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
|
|
|
177
183
|
</svg>
|
|
178
184
|
</span>
|
|
179
185
|
</a>
|
|
180
|
-
</
|
|
186
|
+
</div>
|
|
181
187
|
<code>{entry.data.key}</code>
|
|
182
188
|
</div>
|
|
183
189
|
<span class="glossary-status">{entry.data.editorialStatus}</span>
|
|
@@ -330,6 +336,11 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
|
|
|
330
336
|
font-size: var(--sl-text-xl);
|
|
331
337
|
}
|
|
332
338
|
|
|
339
|
+
.glossary-heading {
|
|
340
|
+
display: flex;
|
|
341
|
+
align-items: center;
|
|
342
|
+
}
|
|
343
|
+
|
|
333
344
|
.glossary-term-link {
|
|
334
345
|
color: inherit;
|
|
335
346
|
text-decoration: none;
|
|
@@ -396,8 +407,8 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
|
|
|
396
407
|
opacity: 0;
|
|
397
408
|
}
|
|
398
409
|
|
|
399
|
-
|
|
400
|
-
|
|
410
|
+
.glossary-heading:hover .glossary-anchor,
|
|
411
|
+
.glossary-heading:focus-within .glossary-anchor,
|
|
401
412
|
.glossary-anchor:focus-visible {
|
|
402
413
|
opacity: 1;
|
|
403
414
|
}
|