explico 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +350 -1
- package/README.md +16 -2
- package/dist/compiler/astro.d.ts +13 -2
- package/dist/compiler/astro.js +39 -0
- package/dist/compiler/cli.d.ts +30 -4
- package/dist/compiler/cli.js +79 -10
- package/dist/compiler/collections.js +16 -3
- package/dist/compiler/exports/standalone-html.js +13 -0
- package/dist/compiler/logging.d.ts +119 -0
- package/dist/compiler/logging.js +108 -0
- package/dist/compiler/manifest.d.ts +7 -3
- package/dist/compiler/manifest.js +1 -1
- package/dist/reference/base-abbreviations.mjs +8 -2
- package/dist/reference/consistency.d.ts +8 -10
- package/dist/reference/consistency.js +3 -47
- package/dist/reference/delimiter-size.d.ts +48 -0
- package/dist/reference/delimiter-size.js +153 -0
- package/dist/reference/diagrams-validate.d.ts +1 -1
- package/dist/reference/equations-validate.d.ts +2 -2
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +9 -3
- package/dist/reference/equations.mjs +7 -1
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/gate-abbreviations.js +2 -2
- package/dist/reference/gate-heading-length.js +2 -2
- package/dist/reference/gate-math.d.ts +25 -1
- package/dist/reference/gate-math.js +50 -9
- package/dist/reference/gate-prose-notation.d.ts +12 -1
- package/dist/reference/gate-prose-notation.js +5 -1
- package/dist/reference/katex-delimiters.d.mts +15 -0
- package/dist/reference/katex-delimiters.mjs +125 -0
- package/dist/reference/katex-options.mjs +4 -0
- package/dist/reference/keyed-float-validate.d.ts +2 -2
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/math-glyphs.mjs +16 -2
- package/dist/reference/references.js +2 -1
- package/dist/reference/registry.js +1 -1
- package/dist/reference/rehype-base-links.d.mts +23 -0
- package/dist/reference/rehype-base-links.mjs +40 -0
- package/dist/reference/remark-abbreviations.mjs +10 -12
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/render-lab-math.d.ts +18 -1
- package/dist/reference/render-lab-math.js +11 -4
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +8 -1
- package/docs/VERSIONING.md +2 -2
- package/docs/authoring/README.md +12 -2
- package/docs/authoring/agents.md +7 -3
- package/docs/authoring/migrating.md +514 -0
- package/docs/authoring/notation.md +157 -7
- package/docs/authoring/prose.md +64 -0
- package/docs/katex-bracket-scaling.md +191 -0
- package/package.json +2 -1
- package/src/compiler/astro.ts +89 -0
- package/src/compiler/cli.ts +167 -12
- package/src/compiler/collections.ts +33 -3
- package/src/compiler/exports/standalone-html.ts +15 -0
- package/src/compiler/logging.ts +259 -0
- package/src/compiler/manifest.ts +7 -3
- package/src/components/memo/Memo.astro +2 -5
- package/src/components/memo/ShortNote.astro +2 -5
- package/src/components/notation/NotationFormula.astro +199 -10
- package/src/components/notation/NotationGlossary.astro +100 -58
- package/src/components/notation/NotationLayer.astro +83 -81
- package/src/components/notation/render-notation-math.ts +111 -10
- package/src/components/scroll-animation.ts +160 -0
- package/src/components/starlight/AboutPanel.astro +17 -2
- package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
- package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
- package/src/components/starlight/LessonFooter.astro +2 -0
- package/src/reference/base-abbreviations.mjs +8 -2
- package/src/reference/consistency.ts +10 -66
- package/src/reference/delimiter-size.ts +279 -0
- package/src/reference/diagrams-validate.ts +1 -1
- package/src/reference/equations-validate.ts +3 -3
- package/src/reference/equations.mjs +7 -1
- package/src/reference/figures-validate.ts +1 -1
- package/src/reference/gate-abbreviations.ts +2 -2
- package/src/reference/gate-heading-length.ts +2 -2
- package/src/reference/gate-math.ts +91 -8
- package/src/reference/gate-prose-notation.ts +15 -1
- package/src/reference/katex-delimiters.mjs +125 -0
- package/src/reference/katex-options.mjs +4 -0
- package/src/reference/keyed-float-validate.ts +3 -3
- package/src/reference/math-glyphs.mjs +16 -2
- package/src/reference/references.ts +2 -1
- package/src/reference/registry.ts +1 -1
- package/src/reference/rehype-base-links.mjs +40 -0
- package/src/reference/remark-abbreviations.mjs +10 -12
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/render-lab-math.ts +25 -2
- package/src/reference/tables-validate.ts +1 -1
- package/src/reference/types.ts +8 -1
- package/src/styles/global.css +119 -14
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
* a single diagnostic shape; `astro build` re-runs the same resolver on the
|
|
8
8
|
* render path as a backstop.
|
|
9
9
|
*
|
|
10
|
-
* Every
|
|
10
|
+
* Every finding names its source and the fix. N17's size-only findings are
|
|
11
|
+
* warnings and include a column when the math came from lesson/assessment text.
|
|
11
12
|
*/
|
|
12
13
|
import { resolveMathGlyphs, GlyphResolutionError } from './math-glyphs.mjs';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
EQ_LABEL_PATTERN,
|
|
16
|
+
equationAnchorId,
|
|
17
|
+
stripEquationLabels,
|
|
18
|
+
} from './equations.mjs';
|
|
19
|
+
import { lintDelimiterSizes } from './delimiter-size';
|
|
14
20
|
import { formulaGlyphScope, unknownFormulaScopeKeys } from './gloss';
|
|
15
21
|
import type { GlyphScopeEntry } from './gloss';
|
|
16
22
|
import type {
|
|
@@ -19,6 +25,21 @@ import type {
|
|
|
19
25
|
SharedNotationDefinitionInput,
|
|
20
26
|
} from './types';
|
|
21
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The equation a finding sits in, when its math carries a `\label{eq:…}`.
|
|
30
|
+
*
|
|
31
|
+
* A location tells you where to edit; this tells you *what you are looking
|
|
32
|
+
* at*. A size-only N17 finding inside a six-line `aligned` block is otherwise
|
|
33
|
+
* anonymous — the message names delimiters, not the result they belong to —
|
|
34
|
+
* so the key and the rendered page's anchor both travel with the diagnostic.
|
|
35
|
+
*/
|
|
36
|
+
export interface EquationLink {
|
|
37
|
+
/** The label's key, without the `eq:` prefix. */
|
|
38
|
+
readonly key: string;
|
|
39
|
+
/** Where the reader sees it, e.g. `/l1-ar/ar1-basics/#eq-ar1-variance`. */
|
|
40
|
+
readonly href: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
export interface MathGateDiagnostic {
|
|
23
44
|
readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
|
|
24
45
|
/**
|
|
@@ -45,9 +66,13 @@ export interface MathGateDiagnostic {
|
|
|
45
66
|
* than adding a second array the CLI has to know about.
|
|
46
67
|
*/
|
|
47
68
|
readonly severity: 'error' | 'warning';
|
|
69
|
+
readonly code?: string;
|
|
48
70
|
readonly file: string;
|
|
49
71
|
readonly line?: number;
|
|
72
|
+
readonly column?: number;
|
|
50
73
|
readonly token?: string;
|
|
74
|
+
/** Present when the offending math carries a `\label{eq:…}`. */
|
|
75
|
+
readonly equation?: EquationLink;
|
|
51
76
|
readonly message: string;
|
|
52
77
|
}
|
|
53
78
|
|
|
@@ -92,6 +117,7 @@ function lineOf(text: string, index: number): number {
|
|
|
92
117
|
interface MathSpan {
|
|
93
118
|
readonly latex: string;
|
|
94
119
|
readonly line: number;
|
|
120
|
+
readonly column: number;
|
|
95
121
|
}
|
|
96
122
|
|
|
97
123
|
function mathSpans(body: string): MathSpan[] {
|
|
@@ -101,10 +127,14 @@ function mathSpans(body: string): MathSpan[] {
|
|
|
101
127
|
pattern.lastIndex = 0;
|
|
102
128
|
let match: RegExpExecArray | null;
|
|
103
129
|
while ((match = pattern.exec(searchable))) {
|
|
130
|
+
const latex = match[1] ?? '';
|
|
131
|
+
const latexOffset = match.index + match[0].indexOf(latex);
|
|
132
|
+
const before = body.slice(0, latexOffset);
|
|
133
|
+
const lastNewline = before.lastIndexOf('\n');
|
|
104
134
|
spans.push({
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
135
|
+
latex,
|
|
136
|
+
line: lineOf(body, latexOffset),
|
|
137
|
+
column: latexOffset - lastNewline,
|
|
108
138
|
});
|
|
109
139
|
}
|
|
110
140
|
}
|
|
@@ -144,18 +174,62 @@ export function pageScope(
|
|
|
144
174
|
return scope;
|
|
145
175
|
}
|
|
146
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The `\label{eq:…}` a math source carries, as a key plus the anchor the
|
|
179
|
+
* rendered page gives it. `EQ_LABEL_PATTERN` is global, so its `lastIndex` is
|
|
180
|
+
* reset before use — a stale one would skip the first label of the next span.
|
|
181
|
+
*/
|
|
182
|
+
export function equationLinkFor(
|
|
183
|
+
lessonId: string,
|
|
184
|
+
latex: string,
|
|
185
|
+
): EquationLink | undefined {
|
|
186
|
+
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
187
|
+
const match = EQ_LABEL_PATTERN.exec(latex);
|
|
188
|
+
EQ_LABEL_PATTERN.lastIndex = 0;
|
|
189
|
+
if (!match) return undefined;
|
|
190
|
+
|
|
191
|
+
const key = match[1];
|
|
192
|
+
const slug = lessonId.replace(/\./g, '/');
|
|
193
|
+
return { key, href: `/${slug}/#${equationAnchorId({ key })}` };
|
|
194
|
+
}
|
|
195
|
+
|
|
147
196
|
function resolveInto(
|
|
148
197
|
latex: string,
|
|
149
198
|
scope: readonly ScopeEntry[],
|
|
150
199
|
context: MathGateDiagnostic['context'],
|
|
151
200
|
file: string,
|
|
152
201
|
line: number | undefined,
|
|
202
|
+
column: number | undefined,
|
|
153
203
|
fix: string,
|
|
154
204
|
out: MathGateDiagnostic[],
|
|
205
|
+
equation?: EquationLink,
|
|
155
206
|
): void {
|
|
207
|
+
const at = equation === undefined ? {} : { equation };
|
|
208
|
+
for (const diagnostic of lintDelimiterSizes(latex)) {
|
|
209
|
+
const locatedLine =
|
|
210
|
+
line === undefined ? undefined : line + diagnostic.line - 1;
|
|
211
|
+
const locatedColumn =
|
|
212
|
+
column === undefined
|
|
213
|
+
? undefined
|
|
214
|
+
: diagnostic.line === 1
|
|
215
|
+
? column + diagnostic.column - 1
|
|
216
|
+
: diagnostic.column;
|
|
217
|
+
out.push({
|
|
218
|
+
context,
|
|
219
|
+
code: diagnostic.code,
|
|
220
|
+
severity: 'warning',
|
|
221
|
+
file,
|
|
222
|
+
...(locatedLine === undefined ? {} : { line: locatedLine }),
|
|
223
|
+
...(locatedColumn === undefined ? {} : { column: locatedColumn }),
|
|
224
|
+
...at,
|
|
225
|
+
message: diagnostic.message,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
156
229
|
let result;
|
|
157
230
|
try {
|
|
158
|
-
|
|
231
|
+
// D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
|
|
232
|
+
result = resolveMathGlyphs(stripEquationLabels(latex), [...scope]);
|
|
159
233
|
} catch (error) {
|
|
160
234
|
out.push({
|
|
161
235
|
context,
|
|
@@ -178,6 +252,7 @@ function resolveInto(
|
|
|
178
252
|
file,
|
|
179
253
|
line,
|
|
180
254
|
token,
|
|
255
|
+
...at,
|
|
181
256
|
message: `Unresolved notation ${JSON.stringify(token)} in ${context} math. ${fix}`,
|
|
182
257
|
});
|
|
183
258
|
}
|
|
@@ -276,15 +351,20 @@ export function gateContentMath(input: {
|
|
|
276
351
|
const scope = pageScope(lesson, sharedByKey);
|
|
277
352
|
const file = lesson.source.file;
|
|
278
353
|
|
|
354
|
+
// `mathSpans` indexes the body, which starts after the frontmatter, so
|
|
355
|
+
// every span line is short by the frontmatter's height until corrected.
|
|
356
|
+
const bodyOffset = (lesson.bodyLine ?? 1) - 1;
|
|
279
357
|
for (const span of mathSpans(lesson.body)) {
|
|
280
358
|
resolveInto(
|
|
281
359
|
span.latex,
|
|
282
360
|
scope,
|
|
283
361
|
'body',
|
|
284
362
|
file,
|
|
285
|
-
span.line,
|
|
363
|
+
span.line + bodyOffset,
|
|
364
|
+
span.column,
|
|
286
365
|
BODY_FIX,
|
|
287
366
|
diagnostics,
|
|
367
|
+
equationLinkFor(lesson.lessonId, span.latex),
|
|
288
368
|
);
|
|
289
369
|
}
|
|
290
370
|
|
|
@@ -305,6 +385,7 @@ export function gateContentMath(input: {
|
|
|
305
385
|
'formula',
|
|
306
386
|
file,
|
|
307
387
|
undefined,
|
|
388
|
+
undefined,
|
|
308
389
|
FORMULA_FIX,
|
|
309
390
|
diagnostics,
|
|
310
391
|
);
|
|
@@ -330,6 +411,7 @@ export function gateContentMath(input: {
|
|
|
330
411
|
'formula',
|
|
331
412
|
definition.source.file,
|
|
332
413
|
undefined,
|
|
414
|
+
undefined,
|
|
333
415
|
SHARED_FORMULA_FIX,
|
|
334
416
|
diagnostics,
|
|
335
417
|
);
|
|
@@ -358,7 +440,8 @@ export function gateContentMath(input: {
|
|
|
358
440
|
scope,
|
|
359
441
|
'assessment',
|
|
360
442
|
`${assessmentId} (${item.id ?? '?'})`,
|
|
361
|
-
|
|
443
|
+
span.line,
|
|
444
|
+
span.column,
|
|
362
445
|
ASSESSMENT_FIX,
|
|
363
446
|
diagnostics,
|
|
364
447
|
);
|
|
@@ -71,6 +71,20 @@ export function lineOf(text: string, index: number): number {
|
|
|
71
71
|
return text.slice(0, index).split('\n').length;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* {@link lineOf} corrected to the file the lesson came from. A gate scans
|
|
76
|
+
* `lesson.body`, which starts *after* the frontmatter, so a raw body line is
|
|
77
|
+
* short by the frontmatter's height and points at the wrong source line —
|
|
78
|
+
* silently, because the number is still plausible. Every gate that reports a
|
|
79
|
+
* `line` against `lesson.source.file` must go through this.
|
|
80
|
+
*/
|
|
81
|
+
export function lessonLine(
|
|
82
|
+
lesson: { readonly body: string; readonly bodyLine?: number },
|
|
83
|
+
index: number,
|
|
84
|
+
): number {
|
|
85
|
+
return lineOf(lesson.body, index) + (lesson.bodyLine ?? 1) - 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
// Symbolic/abbreviated unit tokens that must be typeset in `$…$`, longest
|
|
75
89
|
// first so the alternation can't match a prefix of a longer unit.
|
|
76
90
|
const UNIT = String.raw`m/s²|m/s|km/h|rad/s|m²/s²|J/kg|m²|s⁻¹|kg|N|J|W|Hz|deg|°|rad|m|s`;
|
|
@@ -178,7 +192,7 @@ export function gateProseNotation(
|
|
|
178
192
|
context: 'prose',
|
|
179
193
|
severity: 'error',
|
|
180
194
|
file: lesson.source.file,
|
|
181
|
-
line:
|
|
195
|
+
line: lessonLine(lesson, finding.index),
|
|
182
196
|
token: finding.token,
|
|
183
197
|
message: finding.message,
|
|
184
198
|
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule N17's engine half: make `\left`/`\right` grow strictly outward.
|
|
3
|
+
*
|
|
4
|
+
* Importing this module for its side effect installs a KaTeX whose
|
|
5
|
+
* `\delimitershortfall` is `-1pt` instead of the stock `5pt`, so a scalable
|
|
6
|
+
* delimiter pair is always *strictly* taller than what it wraps and nesting
|
|
7
|
+
* depth shows up on its own. See `docs/katex-bracket-scaling.md` for the
|
|
8
|
+
* derivation and `docs/authoring/notation.md#n17` for the authoring rule.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this is not a `patches/` file
|
|
11
|
+
*
|
|
12
|
+
* `delimiterExtend` is a function-local `const` inside `makeLeftRightDelim`.
|
|
13
|
+
* Nothing KaTeX exports reaches it — `__defineMacro`, `__defineFunction`,
|
|
14
|
+
* `__defineSymbol`, `__setFontMetrics` and the settings schema all sit above
|
|
15
|
+
* the layout engine — so the constant can only be changed in the module's own
|
|
16
|
+
* source. A `pnpm patch` does that, but a patch is project-local: it cannot
|
|
17
|
+
* travel inside the published `explico` artifact, so every downstream course
|
|
18
|
+
* would have to reapply it or silently render flat nests.
|
|
19
|
+
*
|
|
20
|
+
* So the engine does it in code instead. It reads KaTeX's own ESM bundle,
|
|
21
|
+
* rewrites that one constant, imports the result as a module, and moves the
|
|
22
|
+
* rendering entry points onto the shared `katex` object every consumer in the
|
|
23
|
+
* process already imported. KaTeX's default export is a plain mutable object
|
|
24
|
+
* and `rehype-katex` reads `katex.renderToString` at call time, so the swap
|
|
25
|
+
* reaches lesson-body math without forking the plugin.
|
|
26
|
+
*
|
|
27
|
+
* ## Why this is safe to rely on
|
|
28
|
+
*
|
|
29
|
+
* The assumptions above (a matching constant, a mutable export, a bundle with
|
|
30
|
+
* no imports of its own) are all checked here, and `assertDelimiterGrowth()`
|
|
31
|
+
* renders a known nest afterwards and throws when the sizes do not actually
|
|
32
|
+
* increase. A course therefore either renders with engine-sized delimiters or
|
|
33
|
+
* fails its build saying so — it cannot quietly get the stock behaviour.
|
|
34
|
+
*/
|
|
35
|
+
import { readFileSync } from 'node:fs';
|
|
36
|
+
import { createRequire } from 'node:module';
|
|
37
|
+
import katex from 'katex';
|
|
38
|
+
|
|
39
|
+
/** TeX's `\delimitershortfall`, in points, as KaTeX hard-codes it. */
|
|
40
|
+
const STOCK_SHORTFALL = '5.0';
|
|
41
|
+
/** The negative shortfall that forces strict outward growth. */
|
|
42
|
+
const GROWN_SHORTFALL = '-1.0';
|
|
43
|
+
|
|
44
|
+
const needle = (points) =>
|
|
45
|
+
`var delimiterExtend = ${points} / options.fontMetrics().ptPerEm;`;
|
|
46
|
+
|
|
47
|
+
/** The entry points that build a delimiter and must come from the rewrite. */
|
|
48
|
+
const RENDER_EXPORTS = [
|
|
49
|
+
'render',
|
|
50
|
+
'renderToString',
|
|
51
|
+
'__renderToDomTree',
|
|
52
|
+
'__renderToHTMLTree',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Exported for the test that pins both failure messages — a KaTeX upgrade that
|
|
57
|
+
* moves the constant, and a leftover `patches/` file, are the two ways this
|
|
58
|
+
* stops being able to install itself.
|
|
59
|
+
*/
|
|
60
|
+
export function rewriteBundle(source) {
|
|
61
|
+
const stock = needle(STOCK_SHORTFALL);
|
|
62
|
+
if (!source.includes(stock)) {
|
|
63
|
+
// Either the constant moved or someone already rewrote it. Both mean the
|
|
64
|
+
// assumption this module rests on no longer holds for this KaTeX build.
|
|
65
|
+
const alreadyGrown = source.includes(needle(GROWN_SHORTFALL));
|
|
66
|
+
throw new Error(
|
|
67
|
+
alreadyGrown
|
|
68
|
+
? `explico: katex ${katex.version} already carries the -1pt delimiter ` +
|
|
69
|
+
'shortfall (a patches/ file?). Remove that patch — the engine ' +
|
|
70
|
+
'installs it in code (src/reference/katex-delimiters.mjs).'
|
|
71
|
+
: `explico: cannot install the N17 delimiter sizing into katex ` +
|
|
72
|
+
`${katex.version}: the \\delimitershortfall constant ` +
|
|
73
|
+
`(${stock}) is not in its ESM bundle. Re-derive the one-line ` +
|
|
74
|
+
'change against the new bundle — see docs/katex-bracket-scaling.md.',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return source.replace(stock, needle(GROWN_SHORTFALL));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function loadGrownKatex() {
|
|
81
|
+
const require = createRequire(import.meta.url);
|
|
82
|
+
const source = readFileSync(require.resolve('katex/dist/katex.mjs'), 'utf8');
|
|
83
|
+
// The bundle is self-contained, so it can be re-imported from a data URL
|
|
84
|
+
// with no base to resolve relative specifiers against.
|
|
85
|
+
const rewritten = rewriteBundle(source);
|
|
86
|
+
const module = await import(
|
|
87
|
+
`data:text/javascript;base64,${Buffer.from(rewritten).toString('base64')}`
|
|
88
|
+
);
|
|
89
|
+
return module.default;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Render a known nest and confirm the outer pair really is larger than the
|
|
94
|
+
* inner one. Cheap, and it turns every way this could go wrong — an upstream
|
|
95
|
+
* refactor, a frozen export, an import that beat us to it — into one loud
|
|
96
|
+
* failure at the point of installation.
|
|
97
|
+
*/
|
|
98
|
+
export function assertDelimiterGrowth(instance = katex) {
|
|
99
|
+
const html = instance.renderToString(
|
|
100
|
+
String.raw`\left[1+\left(\frac{a}{b}\right)\right]`,
|
|
101
|
+
{ displayMode: true, output: 'html' },
|
|
102
|
+
);
|
|
103
|
+
const sizes = [...html.matchAll(/delimsizing size(\d)/g)].map((m) =>
|
|
104
|
+
Number(m[1]),
|
|
105
|
+
);
|
|
106
|
+
// Two pairs, so four glyphs: outer, inner, inner, outer.
|
|
107
|
+
const grew = sizes.length === 4 && sizes[0] > sizes[1];
|
|
108
|
+
if (!grew) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
'explico: the N17 delimiter sizing did not take effect — a nested ' +
|
|
111
|
+
`\\left…\\right pair rendered as [${sizes.join(', ')}] instead of ` +
|
|
112
|
+
'growing outward. Something rendered math before ' +
|
|
113
|
+
"'explico/reference/katex-delimiters.mjs' was imported, or katex " +
|
|
114
|
+
'changed shape. See docs/katex-bracket-scaling.md.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const grown = await loadGrownKatex();
|
|
120
|
+
for (const name of RENDER_EXPORTS) {
|
|
121
|
+
if (typeof grown[name] === 'function') katex[name] = grown[name];
|
|
122
|
+
}
|
|
123
|
+
assertDelimiterGrowth();
|
|
124
|
+
|
|
125
|
+
export default katex;
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// Side-effect import: installs rule N17's delimiter sizing onto the shared
|
|
2
|
+
// `katex` module before anything renders. A course wires this file into its
|
|
3
|
+
// `astro.config.mjs`, so `rehype-katex` inherits it without a patch.
|
|
4
|
+
import './katex-delimiters.mjs';
|
|
1
5
|
import { NOTATION_KEY_PATTERN } from './remark-notation.mjs';
|
|
2
6
|
|
|
3
7
|
export const EXPLAIN_MACRO = String.raw`\htmlData{notation-key=#1}{#2}`;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - `<prefix>-duplicate-key` (error): an `id` is unique per lesson.
|
|
8
8
|
* - `<prefix>-ref-resolves` (error): every `[[<prefix>-key]]` /
|
|
9
9
|
* `[[slug#<prefix>-key]]` points at the matching keyed float with that `id`.
|
|
10
|
-
* - `<prefix>-key-unused` (
|
|
10
|
+
* - `<prefix>-key-unused` (info): a keyed float is never referenced.
|
|
11
11
|
*
|
|
12
12
|
* `tables-validate.ts`, `figures-validate.ts`, and `diagrams-validate.ts` are
|
|
13
13
|
* thin wrappers over this so callers import a kind-specific validator with
|
|
@@ -48,7 +48,7 @@ export interface KeyedFloatRefRecord {
|
|
|
48
48
|
|
|
49
49
|
export interface KeyedFloatDiagnostic {
|
|
50
50
|
readonly code: string;
|
|
51
|
-
readonly severity: 'error' | 'warning';
|
|
51
|
+
readonly severity: 'error' | 'warning' | 'info';
|
|
52
52
|
readonly message: string;
|
|
53
53
|
readonly file?: string;
|
|
54
54
|
readonly lessonId?: string;
|
|
@@ -190,7 +190,7 @@ export function validateKeyedFloats(
|
|
|
190
190
|
if (referenced.has(`${label.slug}#${label.key}`)) continue;
|
|
191
191
|
diagnostics.push({
|
|
192
192
|
code: `${prefix}-key-unused`,
|
|
193
|
-
severity: '
|
|
193
|
+
severity: 'info',
|
|
194
194
|
message: `${label.lessonId} has a ${noun} id="${label.key}" (${label.number}) but nothing references it with [[${prefix}-${label.key}]]`,
|
|
195
195
|
file: label.slug,
|
|
196
196
|
lessonId: label.lessonId,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import katex from 'katex';
|
|
1
|
+
import katex from './katex-delimiters.mjs';
|
|
2
2
|
|
|
3
3
|
import { BASE_LIBRARY } from './base-library.mjs';
|
|
4
4
|
import { PARENTHESIZED_FUNCTIONS } from './base-operators.mjs';
|
|
@@ -386,6 +386,19 @@ function trimTrailingWhitespace(latex, start, end) {
|
|
|
386
386
|
return trimmed;
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
+
/**
|
|
390
|
+
* KaTeX gives a node it produced by expanding a macro a `loc` from the lexer
|
|
391
|
+
* over the *macro body*, not over the source being scanned: `\vdots` expands
|
|
392
|
+
* to `{\varvdots\rule{0pt}{15pt}}`, and its `\varvdots` atom arrives with
|
|
393
|
+
* `loc.start = 1`, `loc.end = 10` — offsets into that 27-character body.
|
|
394
|
+
* Those numbers are valid indices into any longer source, so they read as a
|
|
395
|
+
* real span pointing at unrelated text. Every `loc` therefore has to prove it
|
|
396
|
+
* belongs to the string this pass parsed before its offsets are believed.
|
|
397
|
+
*/
|
|
398
|
+
function isAuthoredLocation(loc, latex) {
|
|
399
|
+
return loc?.lexer?.input === latex;
|
|
400
|
+
}
|
|
401
|
+
|
|
389
402
|
function nodeSpan(node, latex) {
|
|
390
403
|
let start = Number.POSITIVE_INFINITY;
|
|
391
404
|
let end = Number.NEGATIVE_INFINITY;
|
|
@@ -397,7 +410,8 @@ function nodeSpan(node, latex) {
|
|
|
397
410
|
if (
|
|
398
411
|
value.loc &&
|
|
399
412
|
Number.isInteger(value.loc.start) &&
|
|
400
|
-
Number.isInteger(value.loc.end)
|
|
413
|
+
Number.isInteger(value.loc.end) &&
|
|
414
|
+
isAuthoredLocation(value.loc, latex)
|
|
401
415
|
) {
|
|
402
416
|
start = Math.min(start, value.loc.start);
|
|
403
417
|
end = Math.max(end, value.loc.end);
|
|
@@ -214,7 +214,7 @@ export function buildNotationRegistry(
|
|
|
214
214
|
if (!reachableDefinitions.has(candidate.id)) {
|
|
215
215
|
diagnostics.push({
|
|
216
216
|
code: 'unused-definition',
|
|
217
|
-
severity: '
|
|
217
|
+
severity: 'info',
|
|
218
218
|
message: `${candidate.id} is not reachable from any lesson reference`,
|
|
219
219
|
key: candidate.input.key,
|
|
220
220
|
lessonId:
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefixes root-relative `<a href="/...">` links in rendered lesson content
|
|
3
|
+
* with the course's Astro `base` path.
|
|
4
|
+
*
|
|
5
|
+
* A course deployed under a subpath (any GitHub Pages *project* site, e.g.
|
|
6
|
+
* `base: '/my-course'`) gets that prefix applied automatically to Starlight's
|
|
7
|
+
* own generated navigation — sidebar, pagination, breadcrumbs — because those
|
|
8
|
+
* are rendered by Starlight's own `.astro` components, which call its
|
|
9
|
+
* internal `pathWithBase()` helper. They are **not** applied to anything
|
|
10
|
+
* produced by the Markdown/MDX → rehype pipeline: a lesson's own prose links
|
|
11
|
+
* (`[text](/other-lesson/)`), `remark-notation.mjs`'s equation cross-
|
|
12
|
+
* references, and `remark-citation.mjs`'s citation links are all root-
|
|
13
|
+
* relative absolute paths written or generated without the base in mind, so
|
|
14
|
+
* without this plugin every one of them 404s on a subpath deployment. A
|
|
15
|
+
* lesson's Starlight frontmatter (e.g. `hero.actions[].link`) is untouched
|
|
16
|
+
* either way — that YAML never enters this pipeline — so write those as
|
|
17
|
+
* paths relative to the current page instead.
|
|
18
|
+
*
|
|
19
|
+
* A no-op when the course has no base (`base` omitted or `'/'`, e.g. local
|
|
20
|
+
* dev or a GitHub *user/org* Pages site), so a course can wire this in
|
|
21
|
+
* unconditionally and it only does anything on the deployments that need it.
|
|
22
|
+
*/
|
|
23
|
+
export default function rehypeBaseLinks(base) {
|
|
24
|
+
const prefix = base && base !== '/' ? base.replace(/\/+$/, '') : '';
|
|
25
|
+
|
|
26
|
+
return function transform(tree) {
|
|
27
|
+
if (!prefix) return;
|
|
28
|
+
visit(tree);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function visit(node) {
|
|
32
|
+
if (node.type === 'element' && node.tagName === 'a') {
|
|
33
|
+
const href = node.properties?.href;
|
|
34
|
+
if (typeof href === 'string' && href.startsWith('/') && !href.startsWith('//')) {
|
|
35
|
+
node.properties.href = prefix + href;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(node.children)) node.children.forEach(visit);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* requires every abbreviation-shaped word in prose to either be universal or
|
|
6
6
|
* carry a `content/abbreviations/*.md` entry, so the literal written form
|
|
7
7
|
* (`abbr`, or one of its `aliases`) is itself the trigger. This plugin scans
|
|
8
|
-
* ordinary prose for that literal text and wraps
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* ordinary prose for that literal text and wraps **every** occurrence in a
|
|
9
|
+
* real link (`/glossary/#abbr-<key>`, so it still navigates with no JS)
|
|
10
|
+
* carrying the `data-abbr-key` an island script reads to render a hover/click
|
|
11
|
+
* card, exactly the way `remarkCitation` wraps `[@id]` markers. A reader who
|
|
12
|
+
* deep-links into a mid-page heading — the sidebar TOC, a heading anchor, an
|
|
13
|
+
* external link — sees the same hoverable cue no matter which occurrence they
|
|
14
|
+
* land on first, rather than only the earliest one in document order.
|
|
15
|
+
* `AbbreviationLayer.astro` already resolves every marker independently by
|
|
16
|
+
* `data-abbr-key`, so wrapping every occurrence needs no island-side change.
|
|
14
17
|
*
|
|
15
18
|
* Matches only ever come from the known table by construction, so unlike
|
|
16
19
|
* citation's `[@id]` there is no "unknown id" failure path here.
|
|
@@ -118,10 +121,6 @@ export default function remarkAbbreviations(options = {}) {
|
|
|
118
121
|
const { regex, keyByMatch } = buildMatcher(suppliedAbbreviations);
|
|
119
122
|
if (!regex) return;
|
|
120
123
|
|
|
121
|
-
// Fresh per file, same lifetime as citation's own `pairs` Map: only the
|
|
122
|
-
// first occurrence of a given key on this page gets wrapped.
|
|
123
|
-
const usedKeys = new Set();
|
|
124
|
-
|
|
125
124
|
const replaceText = (value) => {
|
|
126
125
|
regex.lastIndex = 0;
|
|
127
126
|
const output = [];
|
|
@@ -131,13 +130,12 @@ export default function remarkAbbreviations(options = {}) {
|
|
|
131
130
|
|
|
132
131
|
while ((match = regex.exec(value))) {
|
|
133
132
|
const key = keyByMatch.get(match[0]);
|
|
134
|
-
if (!key
|
|
133
|
+
if (!key) continue;
|
|
135
134
|
|
|
136
135
|
if (match.index > cursor) {
|
|
137
136
|
output.push({ type: 'text', value: value.slice(cursor, match.index) });
|
|
138
137
|
}
|
|
139
138
|
output.push(termNode(key, match[0], base));
|
|
140
|
-
usedKeys.add(key);
|
|
141
139
|
changed = true;
|
|
142
140
|
cursor = match.index + match[0].length;
|
|
143
141
|
}
|
|
Binary file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import katex from 'katex';
|
|
2
|
-
import { resolveMathGlyphs } from './math-glyphs.mjs';
|
|
1
|
+
import katex from './katex-delimiters.mjs';
|
|
2
|
+
import { resolveMathGlyphs, stripNotationMarkers } from './math-glyphs.mjs';
|
|
3
3
|
import { createNotationKatexOptions } from './katex-options.mjs';
|
|
4
4
|
|
|
5
5
|
export interface LabMathScopeDefinition {
|
|
@@ -14,6 +14,26 @@ export interface RenderedLabMath {
|
|
|
14
14
|
readonly slots: readonly string[];
|
|
15
15
|
/** Notation keys the template resolved to, for diagnostics. */
|
|
16
16
|
readonly keys: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Plain, copyable LaTeX — every explained glyph resolved to its bare
|
|
19
|
+
* symbol, every `\slot{name}{…}` replaced by `slotPlaceholder(name)` — so
|
|
20
|
+
* the island can substitute each slot's *current* live value in and keep
|
|
21
|
+
* `[data-notation-formula]`'s `data-latex` accurate as the reader drags a
|
|
22
|
+
* control, the same "click the equation to copy it" gesture a static
|
|
23
|
+
* lesson or glossary equation gets (`NotationLayer.astro`).
|
|
24
|
+
*/
|
|
25
|
+
readonly plainLatexTemplate: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A slot's placeholder inside `plainLatexTemplate` — distinct from the
|
|
30
|
+
* numeric sentinel `sentinelFor` below uses internally (that one only has to
|
|
31
|
+
* survive a single notation-binding pass at build time; this one is the
|
|
32
|
+
* stable, name-keyed token an island substitutes with a live value at
|
|
33
|
+
* runtime, so it's exported for that purpose).
|
|
34
|
+
*/
|
|
35
|
+
export function slotPlaceholder(name: string): string {
|
|
36
|
+
return `⟦${name}⟧`;
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
interface BindResult {
|
|
@@ -72,6 +92,7 @@ export function renderLabMath(
|
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
let bound = result.latex;
|
|
95
|
+
let copyBound = result.latex;
|
|
75
96
|
slots.forEach(({ name, fallback }, index) => {
|
|
76
97
|
const sentinel = sentinelFor(index);
|
|
77
98
|
if (!bound.includes(sentinel)) {
|
|
@@ -84,6 +105,7 @@ export function renderLabMath(
|
|
|
84
105
|
sentinel,
|
|
85
106
|
`\\htmlData{lab-slot=${name}}{${fallback}}`,
|
|
86
107
|
);
|
|
108
|
+
copyBound = copyBound.replace(sentinel, slotPlaceholder(name));
|
|
87
109
|
});
|
|
88
110
|
|
|
89
111
|
const html = katex.renderToString(
|
|
@@ -95,5 +117,6 @@ export function renderLabMath(
|
|
|
95
117
|
html,
|
|
96
118
|
slots: slots.map(({ name }) => name),
|
|
97
119
|
keys: [...new Set(result.bindings.map(({ key }) => key))],
|
|
120
|
+
plainLatexTemplate: stripNotationMarkers(copyBound),
|
|
98
121
|
};
|
|
99
122
|
}
|
|
@@ -15,7 +15,7 @@ export type TableDiagnosticCode =
|
|
|
15
15
|
|
|
16
16
|
export interface TableDiagnostic {
|
|
17
17
|
readonly code: TableDiagnosticCode;
|
|
18
|
-
readonly severity: 'error' | 'warning';
|
|
18
|
+
readonly severity: 'error' | 'warning' | 'info';
|
|
19
19
|
readonly message: string;
|
|
20
20
|
readonly file?: string;
|
|
21
21
|
readonly lessonId?: string;
|
package/src/reference/types.ts
CHANGED
|
@@ -88,6 +88,13 @@ export interface NotationLessonInput {
|
|
|
88
88
|
readonly references: readonly NotationReferenceInput[];
|
|
89
89
|
/** Original lesson body, retained for lossless loader adapters. */
|
|
90
90
|
readonly body: string;
|
|
91
|
+
/**
|
|
92
|
+
* 1-based line in `source.file` where `body` starts. Gates scan the body, so
|
|
93
|
+
* every finding's index is body-relative; this is what turns one into a real
|
|
94
|
+
* location in the file. Optional so a hand-built input (a test, an adapter)
|
|
95
|
+
* stays valid — it then behaves as 1, the pre-0.7.1 behavior.
|
|
96
|
+
*/
|
|
97
|
+
readonly bodyLine?: number;
|
|
91
98
|
readonly source: SourceSpan;
|
|
92
99
|
}
|
|
93
100
|
|
|
@@ -127,7 +134,7 @@ export type NotationDiagnosticCode =
|
|
|
127
134
|
| 'alignment-review-state'
|
|
128
135
|
| 'gloss-collides-with-card';
|
|
129
136
|
|
|
130
|
-
export type DiagnosticSeverity = 'error' | 'warning';
|
|
137
|
+
export type DiagnosticSeverity = 'error' | 'warning' | 'info';
|
|
131
138
|
|
|
132
139
|
export interface NotationDiagnostic {
|
|
133
140
|
readonly code: NotationDiagnosticCode;
|