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
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
<script>
|
|
39
39
|
import { HEADINGS, sectionParts } from './section-parts';
|
|
40
|
+
import { animatedScrollIntoView } from '../scroll-animation';
|
|
40
41
|
|
|
41
42
|
(() => {
|
|
42
43
|
const root = document.querySelector('.sl-markdown-content');
|
|
@@ -78,10 +79,7 @@
|
|
|
78
79
|
if (!heading) return;
|
|
79
80
|
const parts = sectionParts(heading);
|
|
80
81
|
if (doScroll) {
|
|
81
|
-
parts[0]
|
|
82
|
-
behavior: reduce.matches ? 'auto' : 'smooth',
|
|
83
|
-
block: 'start',
|
|
84
|
-
});
|
|
82
|
+
animatedScrollIntoView(parts[0], { block: 'start' });
|
|
85
83
|
}
|
|
86
84
|
for (const part of parts) flash(part);
|
|
87
85
|
heading.setAttribute('tabindex', '-1');
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
42
|
<script>
|
|
43
|
+
import { animatedScrollIntoView } from '../scroll-animation';
|
|
44
|
+
|
|
43
45
|
(() => {
|
|
44
46
|
const root = document.querySelector('.sl-markdown-content');
|
|
45
47
|
if (!root) return;
|
|
@@ -97,10 +99,7 @@
|
|
|
97
99
|
target.style.removeProperty('animation');
|
|
98
100
|
}
|
|
99
101
|
if (doScroll) {
|
|
100
|
-
target
|
|
101
|
-
behavior: reduce.matches ? 'auto' : 'smooth',
|
|
102
|
-
block: 'start',
|
|
103
|
-
});
|
|
102
|
+
animatedScrollIntoView(target, { block: 'start' });
|
|
104
103
|
}
|
|
105
104
|
target.setAttribute('tabindex', '-1');
|
|
106
105
|
try {
|
|
@@ -132,13 +131,15 @@
|
|
|
132
131
|
|
|
133
132
|
// "Copy LaTeX" for an equation, two ways: its own button (only a numbered
|
|
134
133
|
// display equation has one — `data-eq-copy`, `remark-notation.mjs`'s
|
|
135
|
-
// `equationNode`), or a click anywhere else in the
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
134
|
+
// `equationNode`), or a click anywhere else in the box — including the
|
|
135
|
+
// reserved gutter between the equation and its number/button, not just
|
|
136
|
+
// the rendered `.katex-html` — that isn't the number link or a symbol
|
|
137
|
+
// with its own notation card. The same gesture works on an inline
|
|
138
|
+
// equation too (`inlineEquationNode`), which has no room for a
|
|
139
|
+
// persistent button of its own without breaking the reading line. A
|
|
140
|
+
// notation-registry symbol keeps its existing click-to-pin behavior
|
|
141
|
+
// either way (`NotationLayer.astro`), so this skips anything under
|
|
142
|
+
// `[data-notation-key]`.
|
|
142
143
|
root.addEventListener('click', (event) => {
|
|
143
144
|
const origin = event.target instanceof Element ? event.target : null;
|
|
144
145
|
if (!origin) return;
|
|
@@ -150,9 +151,7 @@
|
|
|
150
151
|
const container = ownButton
|
|
151
152
|
? null
|
|
152
153
|
: origin.closest<HTMLElement>('.keyed-equation, .inline-equation');
|
|
153
|
-
if (!ownButton &&
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
154
|
+
if (!ownButton && !container) return;
|
|
156
155
|
|
|
157
156
|
const feedbackTarget =
|
|
158
157
|
ownButton ??
|
|
@@ -185,12 +184,14 @@
|
|
|
185
184
|
root.addEventListener('mouseover', (event) => {
|
|
186
185
|
const origin = event.target instanceof Element ? event.target : null;
|
|
187
186
|
if (!origin || origin.getAttribute('title') === 'Copy LaTeX') return;
|
|
188
|
-
if (origin.closest('[data-notation-key], [data-eq-copy]')) return;
|
|
189
187
|
if (
|
|
190
|
-
|
|
188
|
+
origin.closest(
|
|
189
|
+
'[data-notation-key], [data-eq-copy], a.keyed-equation__number',
|
|
190
|
+
)
|
|
191
191
|
) {
|
|
192
192
|
return;
|
|
193
193
|
}
|
|
194
|
+
if (!origin.closest('.keyed-equation, .inline-equation')) return;
|
|
194
195
|
origin.setAttribute('title', 'Copy LaTeX');
|
|
195
196
|
});
|
|
196
197
|
|
|
@@ -23,6 +23,7 @@ import AbbreviationLayer from '../abbreviation/AbbreviationLayer.astro';
|
|
|
23
23
|
import KeyedFloatEnhancer from './KeyedFloatEnhancer.astro';
|
|
24
24
|
import FloatRefLayer from '../reference/FloatRefLayer.astro';
|
|
25
25
|
import HeadingAnchorEnhancer from './HeadingAnchorEnhancer.astro';
|
|
26
|
+
import AnchorScrollEnhancer from './AnchorScrollEnhancer.astro';
|
|
26
27
|
import CollapsibleSections from './CollapsibleSections.astro';
|
|
27
28
|
import TableOverflowExpander from '../table/TableOverflowExpander.astro';
|
|
28
29
|
import DiagramEnhancer from './DiagramEnhancer.astro';
|
|
@@ -83,6 +84,7 @@ if (lessonId) {
|
|
|
83
84
|
---
|
|
84
85
|
|
|
85
86
|
<NotationLayer definitions={definitions} defaultOpen={notationDefaultOpen} />
|
|
87
|
+
<AnchorScrollEnhancer />
|
|
86
88
|
{lessonId && <CitationLayer />}
|
|
87
89
|
{lessonId && <AbbreviationLayer />}
|
|
88
90
|
{lessonId && <KeyedFloatEnhancer />}
|
|
@@ -20,12 +20,18 @@ export const BASE_ABBREVIATIONS = Object.freeze(
|
|
|
20
20
|
'e.g.',
|
|
21
21
|
'i.e.',
|
|
22
22
|
'etc.',
|
|
23
|
+
'e.t.c.',
|
|
23
24
|
'cf.',
|
|
24
25
|
'vs.',
|
|
25
|
-
'a.m.',
|
|
26
|
-
'p.m.',
|
|
27
26
|
'a.k.a.',
|
|
28
27
|
'et al.',
|
|
28
|
+
// time of day, in each casing/punctuation style a lesson might use
|
|
29
|
+
'a.m.',
|
|
30
|
+
'p.m.',
|
|
31
|
+
'A.M.',
|
|
32
|
+
'P.M.',
|
|
33
|
+
'AM',
|
|
34
|
+
'PM',
|
|
29
35
|
// generic acronyms with no domain-specific meaning in this engine
|
|
30
36
|
'AI',
|
|
31
37
|
'ID',
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* Corpus consistency checks (Step D6) — the Tier 1–2 checks from the cleanup
|
|
3
3
|
* plan's "Enforced checks" section that no earlier step covers.
|
|
4
4
|
*
|
|
5
|
-
* Every check here
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Every check here is non-blocking Tier 3: repairable violations are warnings
|
|
6
|
+
* and optional restructuring advice is info. `pnpm validate:content` surfaces
|
|
7
|
+
* warning counts, `manifest.diagnostics.consistency` records every detail, and
|
|
8
|
+
* `resolution/consistency-report.json` is the committed, diff-checkable
|
|
9
|
+
* snapshot. Nothing here changes content. A finding is silenced only by a
|
|
10
10
|
* reviewed line in `lint-ignore.yml` (see `./lint-ignore`).
|
|
11
11
|
*
|
|
12
12
|
* (a) glyph-unique-in-corpus one rendered glyph → one meaning corpus-wide
|
|
@@ -14,8 +14,6 @@
|
|
|
14
14
|
* unit strings drawn from a controlled vocab
|
|
15
15
|
* (c) convention-single-definition a sign / cash-flow-perspective convention a
|
|
16
16
|
* lesson uses resolves to one declaration
|
|
17
|
-
* (e) numerals-tagged numerals in `$…$` / result tables are tagged
|
|
18
|
-
* to a `src/domain/` call or a source locator
|
|
19
17
|
* (f) weak-local a `notation.local` symbol used only in its
|
|
20
18
|
* own defining equation and prose
|
|
21
19
|
* (g) notation-source-locator every notation `sources` entry has a locator
|
|
@@ -54,7 +52,6 @@ export type ConsistencyCode =
|
|
|
54
52
|
| 'notation-units'
|
|
55
53
|
| 'units-vocab'
|
|
56
54
|
| 'convention-single-definition'
|
|
57
|
-
| 'numerals-tagged'
|
|
58
55
|
| 'weak-local'
|
|
59
56
|
| 'notation-source-locator'
|
|
60
57
|
| 'gloss-wants-promoting'
|
|
@@ -65,8 +62,8 @@ export type ConsistencyCode =
|
|
|
65
62
|
|
|
66
63
|
export interface ConsistencyDiagnostic {
|
|
67
64
|
readonly code: ConsistencyCode;
|
|
68
|
-
/** Tier 3 —
|
|
69
|
-
readonly severity: 'warning';
|
|
65
|
+
/** Tier 3 — repairable violations are warnings; optional advice is info. */
|
|
66
|
+
readonly severity: 'warning' | 'info';
|
|
70
67
|
readonly message: string;
|
|
71
68
|
readonly file?: string;
|
|
72
69
|
readonly lessonId?: string;
|
|
@@ -119,28 +116,6 @@ function mathSpans(body: string): MathSpan[] {
|
|
|
119
116
|
return spans;
|
|
120
117
|
}
|
|
121
118
|
|
|
122
|
-
function tableRows(body: string): string[] {
|
|
123
|
-
const rows: string[] = [];
|
|
124
|
-
for (const line of stripCode(body).split('\n')) {
|
|
125
|
-
const trimmed = line.trim();
|
|
126
|
-
if (!trimmed.startsWith('|') || !trimmed.slice(1).includes('|')) continue;
|
|
127
|
-
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
|
|
128
|
-
rows.push(line);
|
|
129
|
-
}
|
|
130
|
-
return rows;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const NUMERAL_RE = /(?<![\w.])-?\d+(?:\.\d+)?/g;
|
|
134
|
-
|
|
135
|
-
function numeralCount(text: string): number {
|
|
136
|
-
return (text.replace(/\\[a-zA-Z]+/g, ' ').match(NUMERAL_RE) ?? []).length;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** A math span / table row is "tagged" when it carries a citation or a domain marker. */
|
|
140
|
-
function isTagged(text: string): boolean {
|
|
141
|
-
return /\[@[a-z0-9]/i.test(text) || /<!--\s*domain:/i.test(text);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
119
|
// --- glyph regex for weak-local --------------------------------------
|
|
145
120
|
|
|
146
121
|
function glyphMatcher(latex: string): RegExp | undefined {
|
|
@@ -274,35 +249,6 @@ function checkConventionSingleDefinition(
|
|
|
274
249
|
}
|
|
275
250
|
}
|
|
276
251
|
|
|
277
|
-
function checkNumeralsTagged(
|
|
278
|
-
input: ConsistencyInput,
|
|
279
|
-
out: ConsistencyDiagnostic[],
|
|
280
|
-
): void {
|
|
281
|
-
for (const lesson of input.notationInput.lessons) {
|
|
282
|
-
if (input.lintIgnore.matches('numerals-tagged', lesson.lessonId)) continue;
|
|
283
|
-
let math = 0;
|
|
284
|
-
for (const span of mathSpans(lesson.body)) {
|
|
285
|
-
if (isTagged(span.latex)) continue;
|
|
286
|
-
math += numeralCount(span.latex);
|
|
287
|
-
}
|
|
288
|
-
let table = 0;
|
|
289
|
-
for (const row of tableRows(lesson.body)) {
|
|
290
|
-
if (isTagged(row)) continue;
|
|
291
|
-
table += numeralCount(row.replace(/\$[^$\n]*\$/g, ' '));
|
|
292
|
-
}
|
|
293
|
-
const count = math + table;
|
|
294
|
-
if (count === 0) continue;
|
|
295
|
-
out.push({
|
|
296
|
-
code: 'numerals-tagged',
|
|
297
|
-
severity: 'warning',
|
|
298
|
-
lessonId: lesson.lessonId,
|
|
299
|
-
file: lesson.source.file,
|
|
300
|
-
count,
|
|
301
|
-
message: `${lesson.lessonId}: ${count} numeral(s) in $…$ / result tables (${math} in math, ${table} in tables) are not tagged to a src/domain/ call or a source locator`,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
252
|
function checkWeakLocal(
|
|
307
253
|
input: ConsistencyInput,
|
|
308
254
|
out: ConsistencyDiagnostic[],
|
|
@@ -317,7 +263,7 @@ function checkWeakLocal(
|
|
|
317
263
|
if (uses > 1) continue;
|
|
318
264
|
out.push({
|
|
319
265
|
code: 'weak-local',
|
|
320
|
-
severity: '
|
|
266
|
+
severity: 'info',
|
|
321
267
|
lessonId: lesson.lessonId,
|
|
322
268
|
file: lesson.source.file,
|
|
323
269
|
key: definition.key,
|
|
@@ -390,7 +336,7 @@ function checkGlossWantsPromoting(
|
|
|
390
336
|
if (input.lintIgnore.matches('gloss-wants-promoting', glyph)) continue;
|
|
391
337
|
out.push({
|
|
392
338
|
code: 'gloss-wants-promoting',
|
|
393
|
-
severity: '
|
|
339
|
+
severity: 'info',
|
|
394
340
|
glyph,
|
|
395
341
|
message: `gloss ${JSON.stringify(glyph)} (${label}) is declared on ${keys.size} entries (${[...keys].sort().join(', ')}); a meaning reused this widely wants promoting to a card`,
|
|
396
342
|
});
|
|
@@ -425,7 +371,7 @@ function checkCardWantsDemoting(
|
|
|
425
371
|
}
|
|
426
372
|
out.push({
|
|
427
373
|
code: 'card-wants-demoting',
|
|
428
|
-
severity: '
|
|
374
|
+
severity: 'info',
|
|
429
375
|
key: definition.key,
|
|
430
376
|
file: definition.source.file,
|
|
431
377
|
count: words,
|
|
@@ -627,7 +573,6 @@ const CHECKS: ((
|
|
|
627
573
|
checkGlyphUniqueness,
|
|
628
574
|
checkNotationUnits,
|
|
629
575
|
checkConventionSingleDefinition,
|
|
630
|
-
checkNumeralsTagged,
|
|
631
576
|
checkWeakLocal,
|
|
632
577
|
checkNotationSourceLocator,
|
|
633
578
|
checkGlossWantsPromoting,
|
|
@@ -666,7 +611,6 @@ export function consistencyCounts(
|
|
|
666
611
|
'notation-units': 0,
|
|
667
612
|
'units-vocab': 0,
|
|
668
613
|
'convention-single-definition': 0,
|
|
669
|
-
'numerals-tagged': 0,
|
|
670
614
|
'weak-local': 0,
|
|
671
615
|
'notation-source-locator': 0,
|
|
672
616
|
'gloss-wants-promoting': 0,
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule N17: a nested scalable delimiter is sized by the engine, never by hand.
|
|
3
|
+
*
|
|
4
|
+
* The engine installs a KaTeX whose `\delimitershortfall` is `-1pt`
|
|
5
|
+
* (`katex-delimiters.mjs`), so a `\left`/`\right` pair is always
|
|
6
|
+
* strictly taller than what it wraps. Nesting depth therefore shows up on its
|
|
7
|
+
* own, and a hand-picked `\bigl`…`\Biggr` inside a nest only replaces a size
|
|
8
|
+
* that tracks the content with a fixed glyph that does not.
|
|
9
|
+
*
|
|
10
|
+
* This deliberately parses only sizing syntax. It does not decide whether a
|
|
11
|
+
* parenthesis, bracket, or brace is semantic, and it never recommends changing
|
|
12
|
+
* one delimiter shape into another. N16's alternating-shape preference remains
|
|
13
|
+
* a review-only authoring concern.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const DELIMITER_SIZE_DIAGNOSTIC_CODE = 'delimiter-manual-size' as const;
|
|
17
|
+
|
|
18
|
+
export type DelimiterSizeName =
|
|
19
|
+
| 'automatic'
|
|
20
|
+
| 'big'
|
|
21
|
+
| 'Big'
|
|
22
|
+
| 'bigg'
|
|
23
|
+
| 'Bigg';
|
|
24
|
+
|
|
25
|
+
export interface DelimiterSizeSuggestion {
|
|
26
|
+
readonly open: string;
|
|
27
|
+
readonly close: string;
|
|
28
|
+
readonly latex: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DelimiterSizeDiagnostic {
|
|
32
|
+
readonly code: typeof DELIMITER_SIZE_DIAGNOSTIC_CODE;
|
|
33
|
+
readonly severity: 'warning';
|
|
34
|
+
readonly offset: number;
|
|
35
|
+
readonly line: number;
|
|
36
|
+
readonly column: number;
|
|
37
|
+
/** The hand-picked size that should become `\left`/`\right`. */
|
|
38
|
+
readonly size: Exclude<DelimiterSizeName, 'automatic'>;
|
|
39
|
+
readonly suggestion: DelimiterSizeSuggestion;
|
|
40
|
+
readonly message: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type TokenKind = 'open' | 'close';
|
|
44
|
+
type PairKind = 'automatic' | 'manual';
|
|
45
|
+
|
|
46
|
+
interface SizeSpec {
|
|
47
|
+
readonly name: DelimiterSizeName;
|
|
48
|
+
/** `\left` and `\big` can be visually equivalent, so share level 1. */
|
|
49
|
+
readonly rank: number;
|
|
50
|
+
readonly openCommand: string;
|
|
51
|
+
readonly closeCommand: string;
|
|
52
|
+
readonly kind: PairKind;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface DelimiterToken {
|
|
56
|
+
readonly kind: TokenKind;
|
|
57
|
+
readonly pairKind: PairKind;
|
|
58
|
+
readonly rank: number;
|
|
59
|
+
readonly size: DelimiterSizeName;
|
|
60
|
+
readonly delimiter: string;
|
|
61
|
+
readonly offset: number;
|
|
62
|
+
readonly end: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface DelimiterPair {
|
|
66
|
+
readonly open: DelimiterToken;
|
|
67
|
+
close?: DelimiterToken;
|
|
68
|
+
readonly children: DelimiterPair[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SIZE_SPECS: readonly SizeSpec[] = [
|
|
72
|
+
{
|
|
73
|
+
name: 'automatic',
|
|
74
|
+
rank: 1,
|
|
75
|
+
openCommand: '\\left',
|
|
76
|
+
closeCommand: '\\right',
|
|
77
|
+
kind: 'automatic',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'big',
|
|
81
|
+
rank: 1,
|
|
82
|
+
openCommand: '\\bigl',
|
|
83
|
+
closeCommand: '\\bigr',
|
|
84
|
+
kind: 'manual',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'Big',
|
|
88
|
+
rank: 2,
|
|
89
|
+
openCommand: '\\Bigl',
|
|
90
|
+
closeCommand: '\\Bigr',
|
|
91
|
+
kind: 'manual',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'bigg',
|
|
95
|
+
rank: 3,
|
|
96
|
+
openCommand: '\\biggl',
|
|
97
|
+
closeCommand: '\\biggr',
|
|
98
|
+
kind: 'manual',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'Bigg',
|
|
102
|
+
rank: 4,
|
|
103
|
+
openCommand: '\\Biggl',
|
|
104
|
+
closeCommand: '\\Biggr',
|
|
105
|
+
kind: 'manual',
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
interface CommandSpec extends SizeSpec {
|
|
110
|
+
readonly command: string;
|
|
111
|
+
readonly tokenKind: TokenKind;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Longest first prevents `\Biggl` from being considered as `\Bigl`.
|
|
115
|
+
const COMMAND_SPECS: readonly CommandSpec[] = SIZE_SPECS.flatMap((spec) => [
|
|
116
|
+
{ ...spec, command: spec.openCommand, tokenKind: 'open' as const },
|
|
117
|
+
{ ...spec, command: spec.closeCommand, tokenKind: 'close' as const },
|
|
118
|
+
]).sort((left, right) => right.command.length - left.command.length);
|
|
119
|
+
|
|
120
|
+
function delimiterAt(
|
|
121
|
+
latex: string,
|
|
122
|
+
offset: number,
|
|
123
|
+
kind: TokenKind,
|
|
124
|
+
): { delimiter: string; end: number } | undefined {
|
|
125
|
+
const candidates =
|
|
126
|
+
kind === 'open'
|
|
127
|
+
? (['\\{', '(', '['] as const)
|
|
128
|
+
: (['\\}', ')', ']'] as const);
|
|
129
|
+
for (const delimiter of candidates) {
|
|
130
|
+
if (latex.startsWith(delimiter, offset)) {
|
|
131
|
+
return { delimiter, end: offset + delimiter.length };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function tokenAt(latex: string, offset: number): DelimiterToken | undefined {
|
|
138
|
+
if (latex[offset] !== '\\') return undefined;
|
|
139
|
+
for (const spec of COMMAND_SPECS) {
|
|
140
|
+
if (!latex.startsWith(spec.command, offset)) continue;
|
|
141
|
+
const afterCommand = offset + spec.command.length;
|
|
142
|
+
// A longer control word such as `\leftarrow` is not `\left`.
|
|
143
|
+
if (/[A-Za-z]/.test(latex[afterCommand] ?? '')) continue;
|
|
144
|
+
let delimiterOffset = afterCommand;
|
|
145
|
+
while (/\s/.test(latex[delimiterOffset] ?? '')) delimiterOffset += 1;
|
|
146
|
+
const delimiter = delimiterAt(latex, delimiterOffset, spec.tokenKind);
|
|
147
|
+
if (!delimiter) continue;
|
|
148
|
+
return {
|
|
149
|
+
kind: spec.tokenKind,
|
|
150
|
+
pairKind: spec.kind,
|
|
151
|
+
rank: spec.rank,
|
|
152
|
+
size: spec.name,
|
|
153
|
+
delimiter: delimiter.delimiter,
|
|
154
|
+
offset,
|
|
155
|
+
end: delimiter.end,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function pairMatches(open: DelimiterToken, close: DelimiterToken): boolean {
|
|
162
|
+
return (
|
|
163
|
+
open.kind === 'open' &&
|
|
164
|
+
close.kind === 'close' &&
|
|
165
|
+
open.pairKind === close.pairKind &&
|
|
166
|
+
open.rank === close.rank
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isEscaped(latex: string, offset: number): boolean {
|
|
171
|
+
let slashes = 0;
|
|
172
|
+
for (let index = offset - 1; index >= 0 && latex[index] === '\\'; index -= 1) {
|
|
173
|
+
slashes += 1;
|
|
174
|
+
}
|
|
175
|
+
return slashes % 2 === 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build a real nesting tree using an explicit stack. Malformed input is left
|
|
180
|
+
* to KaTeX's syntax diagnostics; this linter emits no potentially misleading
|
|
181
|
+
* size advice for a source whose pairs cannot be matched reliably.
|
|
182
|
+
*/
|
|
183
|
+
function parseDelimiterPairs(latex: string): DelimiterPair[] | undefined {
|
|
184
|
+
const roots: DelimiterPair[] = [];
|
|
185
|
+
const stack: DelimiterPair[] = [];
|
|
186
|
+
|
|
187
|
+
for (let offset = 0; offset < latex.length; offset += 1) {
|
|
188
|
+
if (latex[offset] === '%' && !isEscaped(latex, offset)) {
|
|
189
|
+
const newline = latex.indexOf('\n', offset + 1);
|
|
190
|
+
if (newline === -1) break;
|
|
191
|
+
offset = newline;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const token = isEscaped(latex, offset) ? undefined : tokenAt(latex, offset);
|
|
195
|
+
if (!token) continue;
|
|
196
|
+
offset = token.end - 1;
|
|
197
|
+
|
|
198
|
+
if (token.kind === 'open') {
|
|
199
|
+
const pair: DelimiterPair = { open: token, children: [] };
|
|
200
|
+
const parent = stack[stack.length - 1];
|
|
201
|
+
if (parent) parent.children.push(pair);
|
|
202
|
+
else roots.push(pair);
|
|
203
|
+
stack.push(pair);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const pair = stack[stack.length - 1];
|
|
208
|
+
if (!pair || !pairMatches(pair.open, token)) return undefined;
|
|
209
|
+
pair.close = token;
|
|
210
|
+
stack.pop();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return stack.length === 0 ? roots : undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function lineAndColumn(
|
|
217
|
+
source: string,
|
|
218
|
+
offset: number,
|
|
219
|
+
): { line: number; column: number } {
|
|
220
|
+
const before = source.slice(0, offset);
|
|
221
|
+
const lastNewline = before.lastIndexOf('\n');
|
|
222
|
+
return {
|
|
223
|
+
line: before.split('\n').length,
|
|
224
|
+
column: offset - lastNewline,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const SPEC_BY_SIZE = new Map(SIZE_SPECS.map((spec) => [spec.name, spec]));
|
|
229
|
+
|
|
230
|
+
function suggestionFor(pair: DelimiterPair): DelimiterSizeSuggestion {
|
|
231
|
+
const open = `\\left${pair.open.delimiter}`;
|
|
232
|
+
const close = `\\right${pair.close?.delimiter ?? pair.open.delimiter}`;
|
|
233
|
+
return { open, close, latex: `${open} ... ${close}` };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Warn when a pair that takes part in a nest of scalable delimiters is sized
|
|
238
|
+
* by hand instead of by `\left`/`\right`.
|
|
239
|
+
*
|
|
240
|
+
* A pair takes part in a nest when it contains, or sits inside, another pair
|
|
241
|
+
* this linter tracks. A lone hand-sized pair around otherwise bare content —
|
|
242
|
+
* `\bigl(x(t),\ y(t)\bigr)` for a tuple — is a deliberate typographic
|
|
243
|
+
* choice at a single level, has no hierarchy to get wrong, and is left alone.
|
|
244
|
+
*
|
|
245
|
+
* Only `()`, `[]`, and `\{\}` are parsed. Bare delimiters are intentionally
|
|
246
|
+
* ignored: N15 owns that separate error, while ordinary semantic notation
|
|
247
|
+
* such as `f(x)` and `\mathbb{E}[X]` must not trigger this size-only linter.
|
|
248
|
+
*/
|
|
249
|
+
export function lintDelimiterSizes(latex: string): DelimiterSizeDiagnostic[] {
|
|
250
|
+
const roots = parseDelimiterPairs(latex);
|
|
251
|
+
if (!roots) return [];
|
|
252
|
+
const diagnostics: DelimiterSizeDiagnostic[] = [];
|
|
253
|
+
|
|
254
|
+
const visit = (pair: DelimiterPair, nested: boolean): void => {
|
|
255
|
+
const inNest = nested || pair.children.length > 0;
|
|
256
|
+
if (inNest && pair.open.pairKind === 'manual') {
|
|
257
|
+
const location = lineAndColumn(latex, pair.open.offset);
|
|
258
|
+
const suggestion = suggestionFor(pair);
|
|
259
|
+
diagnostics.push({
|
|
260
|
+
code: DELIMITER_SIZE_DIAGNOSTIC_CODE,
|
|
261
|
+
severity: 'warning',
|
|
262
|
+
offset: pair.open.offset,
|
|
263
|
+
...location,
|
|
264
|
+
size: pair.open.size as Exclude<DelimiterSizeName, 'automatic'>,
|
|
265
|
+
suggestion,
|
|
266
|
+
message:
|
|
267
|
+
`A nested delimiter pair is sized by hand with ` +
|
|
268
|
+
`${SPEC_BY_SIZE.get(pair.open.size)?.openCommand ?? pair.open.size}` +
|
|
269
|
+
`${pair.open.delimiter}. Use ` +
|
|
270
|
+
`${suggestion.latex} and let the engine size the nest; preserve the ` +
|
|
271
|
+
`original opening and closing delimiter shapes (rule N17).`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
for (const child of pair.children) visit(child, true);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
for (const root of roots) visit(root, false);
|
|
278
|
+
return diagnostics.sort((left, right) => left.offset - right.offset);
|
|
279
|
+
}
|
|
@@ -15,7 +15,7 @@ export type DiagramDiagnosticCode =
|
|
|
15
15
|
|
|
16
16
|
export interface DiagramDiagnostic {
|
|
17
17
|
readonly code: DiagramDiagnosticCode;
|
|
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;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* - `eq-duplicate-key` (Tier 1, error): an `eq:` key is unique per lesson.
|
|
5
5
|
* - `eq-ref-resolves` (Tier 1, error): every `[[eq-key]]` / `[[slug#eq-key]]`
|
|
6
6
|
* points at a display equation that carries `\label{eq:key}`.
|
|
7
|
-
* - `eq-key-unused` (
|
|
7
|
+
* - `eq-key-unused` (info): a labelled equation is never referenced.
|
|
8
8
|
*
|
|
9
9
|
* The remark pass enforces the same rules on the render path; this gives the
|
|
10
10
|
* fast `pnpm validate:content` the same coverage and feeds the manifest.
|
|
@@ -33,7 +33,7 @@ export type EquationDiagnosticCode =
|
|
|
33
33
|
|
|
34
34
|
export interface EquationDiagnostic {
|
|
35
35
|
readonly code: EquationDiagnosticCode;
|
|
36
|
-
readonly severity: 'error' | 'warning';
|
|
36
|
+
readonly severity: 'error' | 'warning' | 'info';
|
|
37
37
|
readonly message: string;
|
|
38
38
|
readonly file?: string;
|
|
39
39
|
readonly lessonId?: string;
|
|
@@ -154,7 +154,7 @@ export function validateEquations(
|
|
|
154
154
|
if (referenced.has(`${label.slug}#${label.key}`)) continue;
|
|
155
155
|
diagnostics.push({
|
|
156
156
|
code: 'eq-key-unused',
|
|
157
|
-
severity: '
|
|
157
|
+
severity: 'info',
|
|
158
158
|
message: `${label.lessonId} labels eq:${label.key} (${label.number}) but nothing references it with [[eq-${label.key}]]`,
|
|
159
159
|
file: label.slug,
|
|
160
160
|
lessonId: label.lessonId,
|
|
@@ -106,7 +106,13 @@ export function displayEquationNumber({
|
|
|
106
106
|
: `${prefix}.${offset + indexInLesson}`;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* A display equation's anchor id: `eq-<key>` when it carries a
|
|
111
|
+
* `\label{eq:…}`, else the positional form, e.g. `2.4` → `eq-2-4`.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ key?: string, number?: string | number }} equation - `number` is
|
|
114
|
+
* read only when there is no `key`, so a keyed caller may omit it.
|
|
115
|
+
*/
|
|
110
116
|
export function equationAnchorId({ key, number }) {
|
|
111
117
|
return key ? `eq-${key}` : `eq-${String(number).replace(/\./g, '-')}`;
|
|
112
118
|
}
|
|
@@ -15,7 +15,7 @@ export type FigureDiagnosticCode =
|
|
|
15
15
|
|
|
16
16
|
export interface FigureDiagnostic {
|
|
17
17
|
readonly code: FigureDiagnosticCode;
|
|
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;
|
|
@@ -25,7 +25,7 @@ import { collectOperators, collectRomanLabels } from './math-glyphs.mjs';
|
|
|
25
25
|
import { isBaseAbbreviation } from './base-abbreviations.mjs';
|
|
26
26
|
import { pageScope } from './gate-math';
|
|
27
27
|
import type { MathGateDiagnostic, ScopeEntry } from './gate-math';
|
|
28
|
-
import {
|
|
28
|
+
import { lessonLine, proseOnly } from './gate-prose-notation';
|
|
29
29
|
import type {
|
|
30
30
|
AbbreviationDefinitionInput,
|
|
31
31
|
NotationRegistryInput,
|
|
@@ -117,7 +117,7 @@ export function gateAbbreviations(input: {
|
|
|
117
117
|
context: 'abbreviation',
|
|
118
118
|
severity: 'error',
|
|
119
119
|
file: lesson.source.file,
|
|
120
|
-
line:
|
|
120
|
+
line: lessonLine(lesson, index),
|
|
121
121
|
token,
|
|
122
122
|
message: `Unexplained abbreviation ${JSON.stringify(token)} outside math. ${FIX}`,
|
|
123
123
|
});
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { stripFencedCode } from './equations.mjs';
|
|
23
23
|
import type { MathGateDiagnostic } from './gate-math';
|
|
24
|
-
import {
|
|
24
|
+
import { lessonLine } from './gate-prose-notation';
|
|
25
25
|
import type { NotationLessonInput } from './types';
|
|
26
26
|
|
|
27
27
|
export const HEADING_LENGTH_LIMIT = 65;
|
|
@@ -120,7 +120,7 @@ export function gateHeadingLength(
|
|
|
120
120
|
context: 'structure',
|
|
121
121
|
severity: 'error',
|
|
122
122
|
file: lesson.source.file,
|
|
123
|
-
line:
|
|
123
|
+
line: lessonLine(lesson, finding.index),
|
|
124
124
|
token: finding.token,
|
|
125
125
|
message: finding.message,
|
|
126
126
|
});
|