explico 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +30 -4
  6. package/dist/compiler/cli.js +79 -10
  7. package/dist/compiler/collections.js +16 -3
  8. package/dist/compiler/exports/standalone-html.js +13 -0
  9. package/dist/compiler/logging.d.ts +119 -0
  10. package/dist/compiler/logging.js +108 -0
  11. package/dist/compiler/manifest.d.ts +7 -3
  12. package/dist/compiler/manifest.js +1 -1
  13. package/dist/reference/base-abbreviations.mjs +8 -2
  14. package/dist/reference/consistency.d.ts +8 -10
  15. package/dist/reference/consistency.js +3 -47
  16. package/dist/reference/delimiter-size.d.ts +48 -0
  17. package/dist/reference/delimiter-size.js +153 -0
  18. package/dist/reference/diagrams-validate.d.ts +1 -1
  19. package/dist/reference/equations-validate.d.ts +2 -2
  20. package/dist/reference/equations-validate.js +1 -1
  21. package/dist/reference/equations.d.mts +9 -3
  22. package/dist/reference/equations.mjs +7 -1
  23. package/dist/reference/figures-validate.d.ts +1 -1
  24. package/dist/reference/gate-abbreviations.js +2 -2
  25. package/dist/reference/gate-heading-length.js +2 -2
  26. package/dist/reference/gate-math.d.ts +25 -1
  27. package/dist/reference/gate-math.js +50 -9
  28. package/dist/reference/gate-prose-notation.d.ts +12 -1
  29. package/dist/reference/gate-prose-notation.js +5 -1
  30. package/dist/reference/katex-delimiters.d.mts +15 -0
  31. package/dist/reference/katex-delimiters.mjs +125 -0
  32. package/dist/reference/katex-options.mjs +4 -0
  33. package/dist/reference/keyed-float-validate.d.ts +2 -2
  34. package/dist/reference/keyed-float-validate.js +1 -1
  35. package/dist/reference/math-glyphs.mjs +16 -2
  36. package/dist/reference/references.js +2 -1
  37. package/dist/reference/registry.js +1 -1
  38. package/dist/reference/rehype-base-links.d.mts +23 -0
  39. package/dist/reference/rehype-base-links.mjs +40 -0
  40. package/dist/reference/remark-abbreviations.mjs +10 -12
  41. package/dist/reference/remark-citation.mjs +0 -0
  42. package/dist/reference/render-lab-math.d.ts +18 -1
  43. package/dist/reference/render-lab-math.js +11 -4
  44. package/dist/reference/tables-validate.d.ts +1 -1
  45. package/dist/reference/types.d.ts +8 -1
  46. package/docs/VERSIONING.md +2 -2
  47. package/docs/authoring/README.md +12 -2
  48. package/docs/authoring/agents.md +7 -3
  49. package/docs/authoring/migrating.md +514 -0
  50. package/docs/authoring/notation.md +157 -7
  51. package/docs/authoring/prose.md +64 -0
  52. package/docs/katex-bracket-scaling.md +191 -0
  53. package/package.json +2 -1
  54. package/src/compiler/astro.ts +89 -0
  55. package/src/compiler/cli.ts +167 -12
  56. package/src/compiler/collections.ts +33 -3
  57. package/src/compiler/exports/standalone-html.ts +15 -0
  58. package/src/compiler/logging.ts +259 -0
  59. package/src/compiler/manifest.ts +7 -3
  60. package/src/components/memo/Memo.astro +2 -5
  61. package/src/components/memo/ShortNote.astro +2 -5
  62. package/src/components/notation/NotationFormula.astro +199 -10
  63. package/src/components/notation/NotationGlossary.astro +100 -58
  64. package/src/components/notation/NotationLayer.astro +83 -81
  65. package/src/components/notation/render-notation-math.ts +111 -10
  66. package/src/components/scroll-animation.ts +160 -0
  67. package/src/components/starlight/AboutPanel.astro +17 -2
  68. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  69. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  70. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  71. package/src/components/starlight/LessonFooter.astro +2 -0
  72. package/src/reference/base-abbreviations.mjs +8 -2
  73. package/src/reference/consistency.ts +10 -66
  74. package/src/reference/delimiter-size.ts +279 -0
  75. package/src/reference/diagrams-validate.ts +1 -1
  76. package/src/reference/equations-validate.ts +3 -3
  77. package/src/reference/equations.mjs +7 -1
  78. package/src/reference/figures-validate.ts +1 -1
  79. package/src/reference/gate-abbreviations.ts +2 -2
  80. package/src/reference/gate-heading-length.ts +2 -2
  81. package/src/reference/gate-math.ts +91 -8
  82. package/src/reference/gate-prose-notation.ts +15 -1
  83. package/src/reference/katex-delimiters.mjs +125 -0
  84. package/src/reference/katex-options.mjs +4 -0
  85. package/src/reference/keyed-float-validate.ts +3 -3
  86. package/src/reference/math-glyphs.mjs +16 -2
  87. package/src/reference/references.ts +2 -1
  88. package/src/reference/registry.ts +1 -1
  89. package/src/reference/rehype-base-links.mjs +40 -0
  90. package/src/reference/remark-abbreviations.mjs +10 -12
  91. package/src/reference/remark-citation.mjs +0 -0
  92. package/src/reference/render-lab-math.ts +25 -2
  93. package/src/reference/tables-validate.ts +1 -1
  94. package/src/reference/types.ts +8 -1
  95. package/src/styles/global.css +119 -14
@@ -837,14 +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 its `\left`/`\right` sizing form, not the bare character.**
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** switches to the auto-sizing form: `\left(`/`\right)`, `\left[`/`\right]`,
847
- `\left\{`/`\right\}`, `\left|`/`\right|`.
847
+ nest** uses the auto-sizing form (`\left(`/`\right)`, `\left[`/`\right]`,
848
+ `\left\{`/`\right\}`, `\left|`/`\right|`) — and, per [N17](#n17), only
849
+ that form.
848
850
 
849
851
  ```text
850
852
  \sin(2(90\text{°}-\theta)) ✗ two bare ( nested
@@ -873,10 +875,10 @@ author on a case that can render invisibly wrong.
873
875
  _How to apply._
874
876
 
875
877
  - Reach for `\left(`/`\right)` (or the bracket/brace/pipe equivalents) the
876
- moment a second one of these four glyphs would end up inside the first —
877
- regardless of which of the four it is, and regardless of whether the outer
878
- one is already sized. Sizing the outer pair does not exempt an inner bare
879
- one; both levels of a two-level nest need their own `\left`/`\right`.
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.
880
882
  - Mixing types counts as nesting too: `(a + [b])`, `\{a \mid (b)\}`, and
881
883
  `(|a|)` all need every level sized, exactly like nesting the same character
882
884
  twice.
@@ -909,3 +911,151 @@ _Exceptions._ None. A quoted source nesting bare delimiters still renders
909
911
  sized in this course, the same way N13 reparenthesizes a quoted `\sin x` —
910
912
  the sizing is a typesetting choice, not a claim about the source's own
911
913
  notation.
914
+
915
+ ---
916
+
917
+ ## N16
918
+
919
+ **In AI-generated mathematical content, alternate delimiter types for nested
920
+ grouping when their shape is stylistic; preserve every conventional or
921
+ semantic delimiter shape.**
922
+
923
+ For a nest whose delimiters do nothing except group an expression, prefer this
924
+ cycle from the inside out:
925
+
926
+ 1. `( ... )`
927
+ 2. `[ ... ]`
928
+ 3. `\{ ... \}`
929
+ 4. repeat the cycle if another grouping level is genuinely necessary.
930
+
931
+ This is a preference for generated notation, not a mechanical rewrite. Do not
932
+ change or alternate a delimiter whose type is part of the mathematical
933
+ notation. Preserve, among other conventional forms:
934
+
935
+ - function application such as `f(x)`;
936
+ - expectation notation such as `\mathbb{E}[X]`;
937
+ - intervals such as `[a,b]` and `(a,b]`;
938
+ - sets and set-builder notation;
939
+ - tuples and coordinates;
940
+ - commutators and probability notation;
941
+ - matrices, vectors, indexing, norms, and absolute values.
942
+
943
+ When there is doubt about whether the delimiter is semantic, keep the
944
+ conventional notation. Do not alternate merely to complete the cycle.
945
+
946
+ _Rationale._ Repeating the same shape at every purely stylistic grouping level
947
+ makes the boundaries harder to scan. Alternation can expose the grouping
948
+ structure, but only mathematical context can distinguish a disposable grouping
949
+ mark from notation whose shape carries meaning. That judgment belongs to the
950
+ authoring agent or reviewer, not a syntax linter.
951
+
952
+ _How to apply._ Decide whether each pair is stylistic before choosing its
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.
956
+
957
+ ```text
958
+ \left[
959
+ a+
960
+ \left(
961
+ b+
962
+ \left[
963
+ c+
964
+ \left(d+e\right)
965
+ \right]
966
+ \right)
967
+ \right] ✓
968
+ ```
969
+
970
+ Semantic notation stays intact even when stylistic grouping surrounds it:
971
+
972
+ ```text
973
+ \left[
974
+ \mathbb{E}\!\left[
975
+ f\!\left(X\right)
976
+ \right]
977
+ +
978
+ \left(
979
+ a+b
980
+ \right)
981
+ \right] ✓
982
+ ```
983
+
984
+ In particular, never turn `\mathbb{E}[X]` into another delimiter type merely
985
+ to alternate shapes.
986
+
987
+ _Enforcement._ Review-only. Delimiter semantics cannot be inferred reliably
988
+ from LaTeX syntax, so no automated check enforces alternation. The N17 linter
989
+ checks sizing syntax only and never recommends changing a delimiter type.
990
+
991
+ _Exceptions._ Every conventional or semantically meaningful delimiter is
992
+ outside the alternation preference. Preserving established mathematical
993
+ notation takes precedence over the stylistic cycle.
994
+
995
+ ---
996
+
997
+ ## N17
998
+
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`.**
1002
+
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
+ ```
1007
+
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.
1025
+
1026
+ _How to apply._
1027
+
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
1046
+ `lintDelimiterSizes` (`src/reference/delimiter-size.ts`) through the normal
1047
+ math gate for lesson math, notation formulas, and assessment math. The linter
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.
1055
+
1056
+ The automated check deliberately does **not** infer mathematical semantics or
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,64 @@
1
+ # Prose rules
2
+
3
+ `P` rules govern how a lesson's sentences are worded — choices a reader
4
+ meets in running text rather than in a symbol, a figure, or a heading.
5
+
6
+ See [README.md](README.md) for what "strong recommendation", "Build-enforced",
7
+ and "Review-only" mean here, and how to amend a rule.
8
+
9
+ ---
10
+
11
+ ## P1
12
+
13
+ **Never use "or" to give a thing a second name.**
14
+
15
+ When a sentence introduces an alternative name, synonym, or equivalent
16
+ expression for something, do not join the two with "or". Use "i.e.,", "also
17
+ called", "also known as", or parentheses, whichever fits the sentence.
18
+
19
+ ```md
20
+ <!-- "or" reads as a choice between two different things. -->
21
+
22
+ The residual or error term is what the model leaves unexplained.
23
+ Fit the model by ordinary least squares or OLS.
24
+
25
+ <!-- Each names one thing, and says so. -->
26
+
27
+ The residual (also called the error term) is what the model leaves unexplained.
28
+ Fit the model by ordinary least squares (OLS).
29
+ ```
30
+
31
+ _Rationale._ "Or" has two jobs in English: it separates alternatives ("a
32
+ sample or a population"), and it introduces a second name for the same thing.
33
+ Nothing in the sentence says which job it is doing, so a reader can take "the
34
+ residual or error term" as two different quantities to choose between. In a
35
+ statistics course that misreading is expensive, because many pairs that look
36
+ like synonyms are not ("variance or standard deviation", "estimate or
37
+ estimator"). A reader who takes a real distinction for a synonym, or the
38
+ reverse, learns the wrong thing without noticing.
39
+
40
+ _How to apply._ Decide whether the two expressions name the same thing or
41
+ different things.
42
+
43
+ - **Same thing** — rewrite with one of:
44
+ - _i.e.,_ when the second expression restates or defines the first: "the
45
+ estimator is unbiased, i.e., its expected value equals the parameter".
46
+ - _also called_ / _also known as_ when it is a second name in use: "the
47
+ coefficient of determination, also known as $R^2$".
48
+ - _parentheses_ when the second name is short and the sentence carries
49
+ on: "the mean squared error (MSE)".
50
+ - **Different things** — "or" is correct, and stays. If the sentence could
51
+ still be read as a synonym, name the choice: "either a sample or a
52
+ population", "whether the variance or the standard deviation is reported".
53
+
54
+ An abbreviation introduced in parentheses is still subject to
55
+ [N14](notation.md#n14): it has to be catalogued or explained.
56
+
57
+ _Enforcement._ Review-only. Whether "or" is a logical alternative or a
58
+ second name depends on what the sentence means, so no gate can decide it; a
59
+ reviewer does. An automated "or" scan would flag every legitimate
60
+ alternative.
61
+
62
+ _Exceptions._ A quotation, a source title, or a code identifier that contains
63
+ "or" is left as written. So is "or" inside an assessment answer choice that
64
+ really offers alternatives.
@@ -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.6.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": {
@@ -13,6 +13,9 @@ import {
13
13
  } from './manifest';
14
14
  import { missingNotationPayloadKeys } from '../reference/page-notation';
15
15
  import { writeStandaloneLessonExports } from './exports/standalone-html';
16
+ import remarkAbbreviations from '../reference/remark-abbreviations.mjs';
17
+ import remarkCitation from '../reference/remark-citation.mjs';
18
+ import remarkNotation from '../reference/remark-notation.mjs';
16
19
 
17
20
  // Vite keeps its server object across a restart. Do not accumulate watcher
18
21
  // callbacks each time Astro reloads its configuration.
@@ -132,6 +135,91 @@ export function assertNotationPayloadHtml(page: string, html: string): void {
132
135
  );
133
136
  }
134
137
 
138
+ type MarkdownConfig = AstroConfig['markdown'];
139
+
140
+ /**
141
+ * Every configured remark plugin, regardless of which of Astro's two
142
+ * mutually-exclusive wiring surfaces a course uses: the deprecated flat
143
+ * `markdown.remarkPlugins` array, or a `markdown.processor` (e.g.
144
+ * `unified({ remarkPlugins })`) whose own `options.remarkPlugins` a
145
+ * non-`unified` processor (e.g. `satteri()`) may not have at all.
146
+ */
147
+ function configuredRemarkPlugins(markdown: MarkdownConfig): readonly unknown[] {
148
+ const direct = markdown?.remarkPlugins ?? [];
149
+ const processorOptions = markdown?.processor?.options as
150
+ | { remarkPlugins?: unknown[] }
151
+ | undefined;
152
+ return [...direct, ...(processorOptions?.remarkPlugins ?? [])];
153
+ }
154
+
155
+ function remarkPluginIsWired(remarkPlugins: readonly unknown[], plugin: unknown): boolean {
156
+ return remarkPlugins.some(
157
+ (entry) => entry === plugin || (Array.isArray(entry) && entry[0] === plugin),
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Fail the build when a manifest-driven remark plugin has catalogued content
163
+ * but was never registered in Astro's markdown config — the "half-migrated"
164
+ * failure mode `docs/authoring/migrating.md` documents but, until now, never
165
+ * checked: `pnpm validate:content` stays green because the content-side gate
166
+ * (e.g. `gate-abbreviations.ts`'s N14 check) only scans prose, not Astro
167
+ * config, so a skipped plugin registration renders every trigger as plain
168
+ * text with no signal anywhere.
169
+ */
170
+ export function assertRemarkPluginsWired(
171
+ manifest: Pick<Manifest, 'abbreviations' | 'sources' | 'notation'>,
172
+ markdown: MarkdownConfig,
173
+ ): void {
174
+ const remarkPlugins = configuredRemarkPlugins(markdown);
175
+ const missing: string[] = [];
176
+
177
+ if (
178
+ manifest.abbreviations.length > 0 &&
179
+ !remarkPluginIsWired(remarkPlugins, remarkAbbreviations)
180
+ ) {
181
+ missing.push(
182
+ `remarkAbbreviations is missing from markdown.remarkPlugins, but ` +
183
+ `manifest.abbreviations has ${manifest.abbreviations.length} ` +
184
+ `entr${manifest.abbreviations.length === 1 ? 'y' : 'ies'}. Import it from ` +
185
+ `'explico/reference/remark-abbreviations.mjs' and add ` +
186
+ `[remarkAbbreviations, { abbreviations: manifest.abbreviations }] to ` +
187
+ `markdown.remarkPlugins — see docs/authoring/migrating.md#4-rule-n14.`,
188
+ );
189
+ }
190
+
191
+ if (manifest.sources.length > 0 && !remarkPluginIsWired(remarkPlugins, remarkCitation)) {
192
+ missing.push(
193
+ `remarkCitation is missing from markdown.remarkPlugins, but ` +
194
+ `manifest.sources has ${manifest.sources.length} ` +
195
+ `entr${manifest.sources.length === 1 ? 'y' : 'ies'}. Import it from ` +
196
+ `'explico/reference/remark-citation.mjs' and add ` +
197
+ `[remarkCitation, { sources: manifest.sources }] to markdown.remarkPlugins.`,
198
+ );
199
+ }
200
+
201
+ if (
202
+ manifest.notation.raw.length > 0 &&
203
+ !remarkPluginIsWired(remarkPlugins, remarkNotation)
204
+ ) {
205
+ missing.push(
206
+ `remarkNotation is missing from markdown.remarkPlugins, but ` +
207
+ `manifest.notation.raw has ${manifest.notation.raw.length} ` +
208
+ `entr${manifest.notation.raw.length === 1 ? 'y' : 'ies'}. Import it from ` +
209
+ `'explico/reference/remark-notation.mjs' and add ` +
210
+ `[remarkNotation, createRemarkNotationOptions(manifest)] to markdown.remarkPlugins.`,
211
+ );
212
+ }
213
+
214
+ if (missing.length === 0) return;
215
+ throw new Error(
216
+ `Explico manifest-driven remark plugin${missing.length === 1 ? ' is' : 's are'} ` +
217
+ `not wired into Astro's markdown config:\n${missing
218
+ .map((line) => ` - ${line}`)
219
+ .join('\n')}`,
220
+ );
221
+ }
222
+
135
223
  async function validateGeneratedNotationPayloads(directory: URL): Promise<void> {
136
224
  const root = fileURLToPath(directory);
137
225
  const walk = async (path: string): Promise<void> => {
@@ -200,6 +288,7 @@ export function explicoContentRefresh(
200
288
  name: 'explico-content-refresh',
201
289
  hooks: {
202
290
  'astro:config:setup': ({ command, config }) => {
291
+ assertRemarkPluginsWired(manifest, config.markdown);
203
292
  if (command === 'dev') {
204
293
  developmentConfig = { root: config.root, base: config.base };
205
294
  }