explico 0.5.0 → 0.7.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 +295 -7
- 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 +22 -3
- package/dist/compiler/cli.js +71 -10
- package/dist/compiler/course-config.d.ts +31 -1
- package/dist/compiler/course-config.js +7 -0
- package/dist/compiler/exports/standalone-html.js +13 -0
- package/dist/compiler/logging.d.ts +109 -0
- package/dist/compiler/logging.js +107 -0
- package/dist/compiler/manifest.d.ts +16 -4
- package/dist/compiler/manifest.js +7 -3
- 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 +39 -0
- package/dist/reference/delimiter-size.js +177 -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/figures-validate.d.ts +1 -1
- package/dist/reference/gate-heading-length.d.ts +12 -0
- package/dist/reference/gate-heading-length.js +79 -0
- package/dist/reference/gate-math.d.ts +8 -2
- package/dist/reference/gate-math.js +30 -7
- package/dist/reference/keyed-float-validate.d.ts +2 -2
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/math-glyphs.d.mts +12 -0
- package/dist/reference/math-glyphs.mjs +183 -42
- 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.d.mts +5 -1
- 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 +10 -3
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +1 -1
- package/docs/VERSIONING.md +24 -4
- package/docs/authoring/README.md +14 -2
- package/docs/authoring/agents.md +7 -3
- package/docs/authoring/components.md +4 -1
- package/docs/authoring/migrating.md +477 -8
- package/docs/authoring/notation.md +194 -7
- package/docs/authoring/prose.md +64 -0
- package/docs/authoring/structure-rules.md +69 -0
- package/docs/authoring/visualizations.md +2 -2
- package/package.json +1 -1
- package/src/compiler/astro.ts +89 -0
- package/src/compiler/cli.ts +144 -12
- package/src/compiler/course-config.ts +40 -0
- package/src/compiler/exports/standalone-html.ts +15 -0
- package/src/compiler/logging.ts +248 -0
- package/src/compiler/manifest.ts +20 -3
- package/src/components/diagram/Diagram.astro +0 -55
- 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 +89 -58
- package/src/components/notation/NotationLayer.astro +238 -156
- package/src/components/scroll-animation.ts +160 -0
- 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 +5 -1
- package/src/content-config.ts +16 -1
- package/src/reference/base-abbreviations.mjs +8 -2
- package/src/reference/consistency.ts +10 -66
- package/src/reference/delimiter-size.ts +301 -0
- package/src/reference/diagrams-validate.ts +1 -1
- package/src/reference/equations-validate.ts +3 -3
- package/src/reference/figures-validate.ts +1 -1
- package/src/reference/gate-heading-length.ts +131 -0
- package/src/reference/gate-math.ts +48 -8
- package/src/reference/keyed-float-validate.ts +3 -3
- package/src/reference/math-glyphs.mjs +183 -42
- 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 +24 -1
- package/src/reference/tables-validate.ts +1 -1
- package/src/reference/types.ts +1 -1
- package/src/styles/global.css +120 -15
|
@@ -17,23 +17,6 @@ function mathSpans(body) {
|
|
|
17
17
|
}
|
|
18
18
|
return spans;
|
|
19
19
|
}
|
|
20
|
-
function tableRows(body) {
|
|
21
|
-
const rows = [];
|
|
22
|
-
for (const line of stripCode(body).split("\n")) {
|
|
23
|
-
const trimmed = line.trim();
|
|
24
|
-
if (!trimmed.startsWith("|") || !trimmed.slice(1).includes("|")) continue;
|
|
25
|
-
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
|
|
26
|
-
rows.push(line);
|
|
27
|
-
}
|
|
28
|
-
return rows;
|
|
29
|
-
}
|
|
30
|
-
const NUMERAL_RE = /(?<![\w.])-?\d+(?:\.\d+)?/g;
|
|
31
|
-
function numeralCount(text) {
|
|
32
|
-
return (text.replace(/\\[a-zA-Z]+/g, " ").match(NUMERAL_RE) ?? []).length;
|
|
33
|
-
}
|
|
34
|
-
function isTagged(text) {
|
|
35
|
-
return /\[@[a-z0-9]/i.test(text) || /<!--\s*domain:/i.test(text);
|
|
36
|
-
}
|
|
37
20
|
function glyphMatcher(latex) {
|
|
38
21
|
const trimmed = latex.trim();
|
|
39
22
|
if (trimmed === "") return void 0;
|
|
@@ -141,31 +124,6 @@ function checkConventionSingleDefinition(input, out) {
|
|
|
141
124
|
});
|
|
142
125
|
}
|
|
143
126
|
}
|
|
144
|
-
function checkNumeralsTagged(input, out) {
|
|
145
|
-
for (const lesson of input.notationInput.lessons) {
|
|
146
|
-
if (input.lintIgnore.matches("numerals-tagged", lesson.lessonId)) continue;
|
|
147
|
-
let math = 0;
|
|
148
|
-
for (const span of mathSpans(lesson.body)) {
|
|
149
|
-
if (isTagged(span.latex)) continue;
|
|
150
|
-
math += numeralCount(span.latex);
|
|
151
|
-
}
|
|
152
|
-
let table = 0;
|
|
153
|
-
for (const row of tableRows(lesson.body)) {
|
|
154
|
-
if (isTagged(row)) continue;
|
|
155
|
-
table += numeralCount(row.replace(/\$[^$\n]*\$/g, " "));
|
|
156
|
-
}
|
|
157
|
-
const count = math + table;
|
|
158
|
-
if (count === 0) continue;
|
|
159
|
-
out.push({
|
|
160
|
-
code: "numerals-tagged",
|
|
161
|
-
severity: "warning",
|
|
162
|
-
lessonId: lesson.lessonId,
|
|
163
|
-
file: lesson.source.file,
|
|
164
|
-
count,
|
|
165
|
-
message: `${lesson.lessonId}: ${count} numeral(s) in $\u2026$ / result tables (${math} in math, ${table} in tables) are not tagged to a src/domain/ call or a source locator`
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
127
|
function checkWeakLocal(input, out) {
|
|
170
128
|
for (const lesson of input.notationInput.lessons) {
|
|
171
129
|
const spans = mathSpans(lesson.body).map((span) => span.latex);
|
|
@@ -177,7 +135,7 @@ function checkWeakLocal(input, out) {
|
|
|
177
135
|
if (uses > 1) continue;
|
|
178
136
|
out.push({
|
|
179
137
|
code: "weak-local",
|
|
180
|
-
severity: "
|
|
138
|
+
severity: "info",
|
|
181
139
|
lessonId: lesson.lessonId,
|
|
182
140
|
file: lesson.source.file,
|
|
183
141
|
key: definition.key,
|
|
@@ -228,7 +186,7 @@ function checkGlossWantsPromoting(input, out) {
|
|
|
228
186
|
if (input.lintIgnore.matches("gloss-wants-promoting", glyph)) continue;
|
|
229
187
|
out.push({
|
|
230
188
|
code: "gloss-wants-promoting",
|
|
231
|
-
severity: "
|
|
189
|
+
severity: "info",
|
|
232
190
|
glyph,
|
|
233
191
|
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`
|
|
234
192
|
});
|
|
@@ -250,7 +208,7 @@ function checkCardWantsDemoting(input, out) {
|
|
|
250
208
|
}
|
|
251
209
|
out.push({
|
|
252
210
|
code: "card-wants-demoting",
|
|
253
|
-
severity: "
|
|
211
|
+
severity: "info",
|
|
254
212
|
key: definition.key,
|
|
255
213
|
file: definition.source.file,
|
|
256
214
|
count: words,
|
|
@@ -381,7 +339,6 @@ const CHECKS = [
|
|
|
381
339
|
checkGlyphUniqueness,
|
|
382
340
|
checkNotationUnits,
|
|
383
341
|
checkConventionSingleDefinition,
|
|
384
|
-
checkNumeralsTagged,
|
|
385
342
|
checkWeakLocal,
|
|
386
343
|
checkNotationSourceLocator,
|
|
387
344
|
checkGlossWantsPromoting,
|
|
@@ -403,7 +360,6 @@ function consistencyCounts(diagnostics) {
|
|
|
403
360
|
"notation-units": 0,
|
|
404
361
|
"units-vocab": 0,
|
|
405
362
|
"convention-single-definition": 0,
|
|
406
|
-
"numerals-tagged": 0,
|
|
407
363
|
"weak-local": 0,
|
|
408
364
|
"notation-source-locator": 0,
|
|
409
365
|
"gloss-wants-promoting": 0,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule N17: nested scalable delimiters must make their hierarchy visible.
|
|
3
|
+
*
|
|
4
|
+
* This deliberately parses only sizing syntax. It does not decide whether a
|
|
5
|
+
* parenthesis, bracket, or brace is semantic, and it never recommends changing
|
|
6
|
+
* one delimiter shape into another. N16's alternating-shape preference remains
|
|
7
|
+
* a review-only authoring concern.
|
|
8
|
+
*/
|
|
9
|
+
declare const DELIMITER_SIZE_DIAGNOSTIC_CODE: "delimiter-size-hierarchy";
|
|
10
|
+
type DelimiterSizeName = 'automatic' | 'big' | 'Big' | 'bigg' | 'Bigg';
|
|
11
|
+
interface DelimiterSizeSuggestion {
|
|
12
|
+
readonly size: Exclude<DelimiterSizeName, 'automatic' | 'big'>;
|
|
13
|
+
readonly open: string;
|
|
14
|
+
readonly close: string;
|
|
15
|
+
readonly latex: string;
|
|
16
|
+
}
|
|
17
|
+
interface DelimiterSizeDiagnostic {
|
|
18
|
+
readonly code: typeof DELIMITER_SIZE_DIAGNOSTIC_CODE;
|
|
19
|
+
readonly severity: 'warning';
|
|
20
|
+
readonly offset: number;
|
|
21
|
+
readonly line: number;
|
|
22
|
+
readonly column: number;
|
|
23
|
+
readonly outerSize: DelimiterSizeName;
|
|
24
|
+
readonly nestedSize: DelimiterSizeName;
|
|
25
|
+
readonly suggestion?: DelimiterSizeSuggestion;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Warn when a scalable outer pair is not at least one manual size level above
|
|
30
|
+
* everything nested inside it. `\left` and `\big` form the baseline level;
|
|
31
|
+
* progressively larger levels are `\Big`, `\bigg`, and `\Bigg`.
|
|
32
|
+
*
|
|
33
|
+
* Only `()`, `[]`, and `\{\}` are parsed. Bare delimiters are intentionally
|
|
34
|
+
* ignored: N15 owns that separate error, while ordinary semantic notation
|
|
35
|
+
* such as `f(x)` and `\mathbb{E}[X]` must not trigger this size-only linter.
|
|
36
|
+
*/
|
|
37
|
+
declare function lintDelimiterSizes(latex: string): DelimiterSizeDiagnostic[];
|
|
38
|
+
|
|
39
|
+
export { DELIMITER_SIZE_DIAGNOSTIC_CODE, type DelimiterSizeDiagnostic, type DelimiterSizeName, type DelimiterSizeSuggestion, lintDelimiterSizes };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const DELIMITER_SIZE_DIAGNOSTIC_CODE = "delimiter-size-hierarchy";
|
|
2
|
+
const SIZE_SPECS = [
|
|
3
|
+
{
|
|
4
|
+
name: "automatic",
|
|
5
|
+
rank: 1,
|
|
6
|
+
openCommand: "\\left",
|
|
7
|
+
closeCommand: "\\right",
|
|
8
|
+
kind: "automatic"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
name: "big",
|
|
12
|
+
rank: 1,
|
|
13
|
+
openCommand: "\\bigl",
|
|
14
|
+
closeCommand: "\\bigr",
|
|
15
|
+
kind: "manual"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "Big",
|
|
19
|
+
rank: 2,
|
|
20
|
+
openCommand: "\\Bigl",
|
|
21
|
+
closeCommand: "\\Bigr",
|
|
22
|
+
kind: "manual"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "bigg",
|
|
26
|
+
rank: 3,
|
|
27
|
+
openCommand: "\\biggl",
|
|
28
|
+
closeCommand: "\\biggr",
|
|
29
|
+
kind: "manual"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "Bigg",
|
|
33
|
+
rank: 4,
|
|
34
|
+
openCommand: "\\Biggl",
|
|
35
|
+
closeCommand: "\\Biggr",
|
|
36
|
+
kind: "manual"
|
|
37
|
+
}
|
|
38
|
+
];
|
|
39
|
+
const COMMAND_SPECS = SIZE_SPECS.flatMap((spec) => [
|
|
40
|
+
{ ...spec, command: spec.openCommand, tokenKind: "open" },
|
|
41
|
+
{ ...spec, command: spec.closeCommand, tokenKind: "close" }
|
|
42
|
+
]).sort((left, right) => right.command.length - left.command.length);
|
|
43
|
+
const SUGGESTION_BY_RANK = new Map(
|
|
44
|
+
SIZE_SPECS.filter((spec) => spec.rank >= 2).map((spec) => [spec.rank, spec])
|
|
45
|
+
);
|
|
46
|
+
function delimiterAt(latex, offset, kind) {
|
|
47
|
+
const candidates = kind === "open" ? ["\\{", "(", "["] : ["\\}", ")", "]"];
|
|
48
|
+
for (const delimiter of candidates) {
|
|
49
|
+
if (latex.startsWith(delimiter, offset)) {
|
|
50
|
+
return { delimiter, end: offset + delimiter.length };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return void 0;
|
|
54
|
+
}
|
|
55
|
+
function tokenAt(latex, offset) {
|
|
56
|
+
if (latex[offset] !== "\\") return void 0;
|
|
57
|
+
for (const spec of COMMAND_SPECS) {
|
|
58
|
+
if (!latex.startsWith(spec.command, offset)) continue;
|
|
59
|
+
const afterCommand = offset + spec.command.length;
|
|
60
|
+
if (/[A-Za-z]/.test(latex[afterCommand] ?? "")) continue;
|
|
61
|
+
let delimiterOffset = afterCommand;
|
|
62
|
+
while (/\s/.test(latex[delimiterOffset] ?? "")) delimiterOffset += 1;
|
|
63
|
+
const delimiter = delimiterAt(latex, delimiterOffset, spec.tokenKind);
|
|
64
|
+
if (!delimiter) continue;
|
|
65
|
+
return {
|
|
66
|
+
kind: spec.tokenKind,
|
|
67
|
+
pairKind: spec.kind,
|
|
68
|
+
rank: spec.rank,
|
|
69
|
+
size: spec.name,
|
|
70
|
+
delimiter: delimiter.delimiter,
|
|
71
|
+
offset,
|
|
72
|
+
end: delimiter.end
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
function pairMatches(open, close) {
|
|
78
|
+
return open.kind === "open" && close.kind === "close" && open.pairKind === close.pairKind && open.rank === close.rank;
|
|
79
|
+
}
|
|
80
|
+
function isEscaped(latex, offset) {
|
|
81
|
+
let slashes = 0;
|
|
82
|
+
for (let index = offset - 1; index >= 0 && latex[index] === "\\"; index -= 1) {
|
|
83
|
+
slashes += 1;
|
|
84
|
+
}
|
|
85
|
+
return slashes % 2 === 1;
|
|
86
|
+
}
|
|
87
|
+
function parseDelimiterPairs(latex) {
|
|
88
|
+
const roots = [];
|
|
89
|
+
const stack = [];
|
|
90
|
+
for (let offset = 0; offset < latex.length; offset += 1) {
|
|
91
|
+
if (latex[offset] === "%" && !isEscaped(latex, offset)) {
|
|
92
|
+
const newline = latex.indexOf("\n", offset + 1);
|
|
93
|
+
if (newline === -1) break;
|
|
94
|
+
offset = newline;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const token = isEscaped(latex, offset) ? void 0 : tokenAt(latex, offset);
|
|
98
|
+
if (!token) continue;
|
|
99
|
+
offset = token.end - 1;
|
|
100
|
+
if (token.kind === "open") {
|
|
101
|
+
const pair2 = { open: token, children: [] };
|
|
102
|
+
const parent = stack[stack.length - 1];
|
|
103
|
+
if (parent) parent.children.push(pair2);
|
|
104
|
+
else roots.push(pair2);
|
|
105
|
+
stack.push(pair2);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const pair = stack[stack.length - 1];
|
|
109
|
+
if (!pair || !pairMatches(pair.open, token)) return void 0;
|
|
110
|
+
pair.close = token;
|
|
111
|
+
stack.pop();
|
|
112
|
+
}
|
|
113
|
+
return stack.length === 0 ? roots : void 0;
|
|
114
|
+
}
|
|
115
|
+
function lineAndColumn(source, offset) {
|
|
116
|
+
const before = source.slice(0, offset);
|
|
117
|
+
const lastNewline = before.lastIndexOf("\n");
|
|
118
|
+
return {
|
|
119
|
+
line: before.split("\n").length,
|
|
120
|
+
column: offset - lastNewline
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function sizeName(rank) {
|
|
124
|
+
return SUGGESTION_BY_RANK.get(rank)?.name ?? "Bigg";
|
|
125
|
+
}
|
|
126
|
+
function suggestionFor(pair, requiredRank) {
|
|
127
|
+
const close = pair.close;
|
|
128
|
+
const spec = SUGGESTION_BY_RANK.get(requiredRank);
|
|
129
|
+
if (!close || !spec || spec.name === "automatic" || spec.name === "big") {
|
|
130
|
+
return void 0;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
size: spec.name,
|
|
134
|
+
open: `${spec.openCommand}${pair.open.delimiter}`,
|
|
135
|
+
close: `${spec.closeCommand}${close.delimiter}`,
|
|
136
|
+
latex: `${spec.openCommand}${pair.open.delimiter} ... ${spec.closeCommand}${close.delimiter}`
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function lintDelimiterSizes(latex) {
|
|
140
|
+
const roots = parseDelimiterPairs(latex);
|
|
141
|
+
if (!roots) return [];
|
|
142
|
+
const diagnostics = [];
|
|
143
|
+
const assess = (pair) => {
|
|
144
|
+
let largestNestedRequirement = 0;
|
|
145
|
+
let representativeNestedSize = "automatic";
|
|
146
|
+
for (const child of pair.children) {
|
|
147
|
+
const childRequired = assess(child);
|
|
148
|
+
if (childRequired > largestNestedRequirement) {
|
|
149
|
+
largestNestedRequirement = childRequired;
|
|
150
|
+
representativeNestedSize = sizeName(childRequired);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const requiredRank = largestNestedRequirement === 0 ? pair.open.rank : largestNestedRequirement + 1;
|
|
154
|
+
pair.requiredRank = Math.max(pair.open.rank, requiredRank);
|
|
155
|
+
if (largestNestedRequirement > 0 && pair.open.rank < requiredRank) {
|
|
156
|
+
const location = lineAndColumn(latex, pair.open.offset);
|
|
157
|
+
const suggestion = suggestionFor(pair, requiredRank);
|
|
158
|
+
diagnostics.push({
|
|
159
|
+
code: DELIMITER_SIZE_DIAGNOSTIC_CODE,
|
|
160
|
+
severity: "warning",
|
|
161
|
+
offset: pair.open.offset,
|
|
162
|
+
...location,
|
|
163
|
+
outerSize: pair.open.size,
|
|
164
|
+
nestedSize: representativeNestedSize,
|
|
165
|
+
...suggestion ? { suggestion } : {},
|
|
166
|
+
message: suggestion ? `Outer delimiters are not larger than their nested pair. Use ${suggestion.latex}; preserve the original opening and closing delimiter shapes (rule N17).` : `Outer delimiters are not larger than their nested pair, and no larger standard manual delimiter size remains. Preserve the original opening and closing shapes and restructure the expression if necessary (rule N17).`
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return pair.requiredRank;
|
|
170
|
+
};
|
|
171
|
+
for (const root of roots) assess(root);
|
|
172
|
+
return diagnostics.sort((left, right) => left.offset - right.offset);
|
|
173
|
+
}
|
|
174
|
+
export {
|
|
175
|
+
DELIMITER_SIZE_DIAGNOSTIC_CODE,
|
|
176
|
+
lintDelimiterSizes
|
|
177
|
+
};
|
|
@@ -5,7 +5,7 @@ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } fr
|
|
|
5
5
|
type DiagramDiagnosticCode = 'dia-invalid-key' | 'dia-duplicate-key' | 'dia-ref-resolves' | 'dia-key-unused';
|
|
6
6
|
interface DiagramDiagnostic {
|
|
7
7
|
readonly code: DiagramDiagnosticCode;
|
|
8
|
-
readonly severity: 'error' | 'warning';
|
|
8
|
+
readonly severity: 'error' | 'warning' | 'info';
|
|
9
9
|
readonly message: string;
|
|
10
10
|
readonly file?: string;
|
|
11
11
|
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.
|
|
@@ -26,7 +26,7 @@ interface EquationRefRecord {
|
|
|
26
26
|
type EquationDiagnosticCode = 'eq-duplicate-key' | 'eq-ref-resolves' | 'eq-key-unused';
|
|
27
27
|
interface EquationDiagnostic {
|
|
28
28
|
readonly code: EquationDiagnosticCode;
|
|
29
|
-
readonly severity: 'error' | 'warning';
|
|
29
|
+
readonly severity: 'error' | 'warning' | 'info';
|
|
30
30
|
readonly message: string;
|
|
31
31
|
readonly file?: string;
|
|
32
32
|
readonly lessonId?: string;
|
|
@@ -73,7 +73,7 @@ function validateEquations(lessons) {
|
|
|
73
73
|
if (referenced.has(`${label.slug}#${label.key}`)) continue;
|
|
74
74
|
diagnostics.push({
|
|
75
75
|
code: "eq-key-unused",
|
|
76
|
-
severity: "
|
|
76
|
+
severity: "info",
|
|
77
77
|
message: `${label.lessonId} labels eq:${label.key} (${label.number}) but nothing references it with [[eq-${label.key}]]`,
|
|
78
78
|
file: label.slug,
|
|
79
79
|
lessonId: label.lessonId,
|
|
@@ -5,7 +5,7 @@ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } fr
|
|
|
5
5
|
type FigureDiagnosticCode = 'fig-invalid-key' | 'fig-duplicate-key' | 'fig-ref-resolves' | 'fig-key-unused';
|
|
6
6
|
interface FigureDiagnostic {
|
|
7
7
|
readonly code: FigureDiagnosticCode;
|
|
8
|
-
readonly severity: 'error' | 'warning';
|
|
8
|
+
readonly severity: 'error' | 'warning' | 'info';
|
|
9
9
|
readonly message: string;
|
|
10
10
|
readonly file?: string;
|
|
11
11
|
readonly lessonId?: string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { MathGateDiagnostic } from './gate-math.js';
|
|
2
|
+
import { NotationLessonInput } from './types.js';
|
|
3
|
+
|
|
4
|
+
declare const HEADING_LENGTH_LIMIT = 65;
|
|
5
|
+
/**
|
|
6
|
+
* Scan every lesson body for a heading or a keyed float's title running past
|
|
7
|
+
* `HEADING_LENGTH_LIMIT` characters. Returns one `MathGateDiagnostic`
|
|
8
|
+
* (context `'structure'`, always an error) per finding.
|
|
9
|
+
*/
|
|
10
|
+
declare function gateHeadingLength(lessons: readonly NotationLessonInput[]): MathGateDiagnostic[];
|
|
11
|
+
|
|
12
|
+
export { HEADING_LENGTH_LIMIT, gateHeadingLength };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { stripFencedCode } from "./equations.mjs";
|
|
2
|
+
import { lineOf } from "./gate-prose-notation.js";
|
|
3
|
+
const HEADING_LENGTH_LIMIT = 65;
|
|
4
|
+
const HEADING_LINE = /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm;
|
|
5
|
+
const TITLE_ATTRIBUTE_BY_TAG = {
|
|
6
|
+
Figure: "title",
|
|
7
|
+
Diagram: "label",
|
|
8
|
+
Memo: "title",
|
|
9
|
+
ShortNote: "title"
|
|
10
|
+
};
|
|
11
|
+
function readAttribute(attributesSource, name) {
|
|
12
|
+
const pattern = new RegExp(`\\b${name}\\s*=\\s*(["'])([^"']*)\\1`);
|
|
13
|
+
const match = pattern.exec(attributesSource);
|
|
14
|
+
return match ? match[2] : void 0;
|
|
15
|
+
}
|
|
16
|
+
function overLimitMessage(kind, text) {
|
|
17
|
+
return `${kind} ${JSON.stringify(text)} is ${text.length} characters, over the ${HEADING_LENGTH_LIMIT}-character limit (rule S1). Shorten it and move the rest into the paragraph or caption that follows.`;
|
|
18
|
+
}
|
|
19
|
+
function headingFindings(text) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const re = new RegExp(HEADING_LINE.source, HEADING_LINE.flags);
|
|
22
|
+
let match;
|
|
23
|
+
while (match = re.exec(text)) {
|
|
24
|
+
const level = match[1]?.length ?? 1;
|
|
25
|
+
const title = (match[2] ?? "").trim();
|
|
26
|
+
if (title.length > HEADING_LENGTH_LIMIT) {
|
|
27
|
+
out.push({
|
|
28
|
+
index: match.index,
|
|
29
|
+
token: title,
|
|
30
|
+
message: overLimitMessage(`${"#".repeat(level)} heading`, title)
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function floatTitleFindings(text) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const [tagName, attribute] of Object.entries(TITLE_ATTRIBUTE_BY_TAG)) {
|
|
39
|
+
const opening = new RegExp(`<${tagName}\\b([^>]*)>`, "g");
|
|
40
|
+
let match;
|
|
41
|
+
while (match = opening.exec(text)) {
|
|
42
|
+
const value = readAttribute(match[1] ?? "", attribute)?.trim();
|
|
43
|
+
if (value && value.length > HEADING_LENGTH_LIMIT) {
|
|
44
|
+
out.push({
|
|
45
|
+
index: match.index,
|
|
46
|
+
token: value,
|
|
47
|
+
message: overLimitMessage(`<${tagName} ${attribute}>`, value)
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function findingsIn(text) {
|
|
55
|
+
return [...headingFindings(text), ...floatTitleFindings(text)].sort(
|
|
56
|
+
(a, b) => a.index - b.index
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
function gateHeadingLength(lessons) {
|
|
60
|
+
const diagnostics = [];
|
|
61
|
+
for (const lesson of lessons) {
|
|
62
|
+
const text = stripFencedCode(lesson.body);
|
|
63
|
+
for (const finding of findingsIn(text)) {
|
|
64
|
+
diagnostics.push({
|
|
65
|
+
context: "structure",
|
|
66
|
+
severity: "error",
|
|
67
|
+
file: lesson.source.file,
|
|
68
|
+
line: lineOf(lesson.body, finding.index),
|
|
69
|
+
token: finding.token,
|
|
70
|
+
message: finding.message
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return diagnostics;
|
|
75
|
+
}
|
|
76
|
+
export {
|
|
77
|
+
HEADING_LENGTH_LIMIT,
|
|
78
|
+
gateHeadingLength
|
|
79
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NotationRegistryInput, NotationLessonInput } from './types.js';
|
|
2
2
|
|
|
3
3
|
interface MathGateDiagnostic {
|
|
4
|
-
readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation';
|
|
4
|
+
readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
|
|
5
5
|
/**
|
|
6
6
|
* Body / component-slot math blocks the build (the render path needs every
|
|
7
7
|
* glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
|
|
@@ -19,11 +19,17 @@ interface MathGateDiagnostic {
|
|
|
19
19
|
* same change that introduced the check. `abbreviation` (N14,
|
|
20
20
|
* `gate-abbreviations.ts`) is the same "ships straight as an error" story
|
|
21
21
|
* for a different shape of plain-text token — an unexplained acronym or
|
|
22
|
-
* dotted abbreviation, rather than a mis-typed quantity/unit.
|
|
22
|
+
* dotted abbreviation, rather than a mis-typed quantity/unit. `structure`
|
|
23
|
+
* (S1, `gate-heading-length.ts`) is unrelated to notation — it caps how
|
|
24
|
+
* long a heading or a keyed float's title may run — but reuses this same
|
|
25
|
+
* diagnostic shape and the one `manifest.diagnostics.math` array rather
|
|
26
|
+
* than adding a second array the CLI has to know about.
|
|
23
27
|
*/
|
|
24
28
|
readonly severity: 'error' | 'warning';
|
|
29
|
+
readonly code?: string;
|
|
25
30
|
readonly file: string;
|
|
26
31
|
readonly line?: number;
|
|
32
|
+
readonly column?: number;
|
|
27
33
|
readonly token?: string;
|
|
28
34
|
readonly message: string;
|
|
29
35
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { resolveMathGlyphs, GlyphResolutionError } from "./math-glyphs.mjs";
|
|
2
2
|
import { stripEquationLabels } from "./equations.mjs";
|
|
3
|
+
import { lintDelimiterSizes } from "./delimiter-size.js";
|
|
3
4
|
import { formulaGlyphScope, unknownFormulaScopeKeys } from "./gloss.js";
|
|
4
5
|
const SEVERITY = {
|
|
5
6
|
body: "error",
|
|
6
7
|
formula: "error",
|
|
7
8
|
assessment: "error",
|
|
8
9
|
prose: "error",
|
|
9
|
-
abbreviation: "error"
|
|
10
|
+
abbreviation: "error",
|
|
11
|
+
structure: "error"
|
|
10
12
|
};
|
|
11
13
|
const TERM_KEY = /\[\[\s*([a-z0-9]+(?:[.-][a-z0-9]+)*)\s*\]\]/g;
|
|
12
14
|
const EXPLAIN_KEY = /\\explain\s*\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}/g;
|
|
@@ -25,10 +27,14 @@ function mathSpans(body) {
|
|
|
25
27
|
pattern.lastIndex = 0;
|
|
26
28
|
let match;
|
|
27
29
|
while (match = pattern.exec(searchable)) {
|
|
30
|
+
const latex = match[1] ?? "";
|
|
31
|
+
const latexOffset = match.index + match[0].indexOf(latex);
|
|
32
|
+
const before = body.slice(0, latexOffset);
|
|
33
|
+
const lastNewline = before.lastIndexOf("\n");
|
|
28
34
|
spans.push({
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
latex,
|
|
36
|
+
line: lineOf(body, latexOffset),
|
|
37
|
+
column: latexOffset - lastNewline
|
|
32
38
|
});
|
|
33
39
|
}
|
|
34
40
|
}
|
|
@@ -52,10 +58,23 @@ function pageScope(lesson, sharedByKey) {
|
|
|
52
58
|
}
|
|
53
59
|
return scope;
|
|
54
60
|
}
|
|
55
|
-
function resolveInto(latex, scope, context, file, line, fix, out) {
|
|
61
|
+
function resolveInto(latex, scope, context, file, line, column, fix, out) {
|
|
62
|
+
for (const diagnostic of lintDelimiterSizes(latex)) {
|
|
63
|
+
const locatedLine = line === void 0 ? void 0 : line + diagnostic.line - 1;
|
|
64
|
+
const locatedColumn = column === void 0 ? void 0 : diagnostic.line === 1 ? column + diagnostic.column - 1 : diagnostic.column;
|
|
65
|
+
out.push({
|
|
66
|
+
context,
|
|
67
|
+
code: diagnostic.code,
|
|
68
|
+
severity: "warning",
|
|
69
|
+
file,
|
|
70
|
+
...locatedLine === void 0 ? {} : { line: locatedLine },
|
|
71
|
+
...locatedColumn === void 0 ? {} : { column: locatedColumn },
|
|
72
|
+
message: diagnostic.message
|
|
73
|
+
});
|
|
74
|
+
}
|
|
56
75
|
let result;
|
|
57
76
|
try {
|
|
58
|
-
result = resolveMathGlyphs(latex, [...scope]);
|
|
77
|
+
result = resolveMathGlyphs(stripEquationLabels(latex), [...scope]);
|
|
59
78
|
} catch (error) {
|
|
60
79
|
out.push({
|
|
61
80
|
context,
|
|
@@ -144,6 +163,7 @@ function gateContentMath(input) {
|
|
|
144
163
|
"body",
|
|
145
164
|
file,
|
|
146
165
|
span.line,
|
|
166
|
+
span.column,
|
|
147
167
|
BODY_FIX,
|
|
148
168
|
diagnostics
|
|
149
169
|
);
|
|
@@ -162,6 +182,7 @@ function gateContentMath(input) {
|
|
|
162
182
|
"formula",
|
|
163
183
|
file,
|
|
164
184
|
void 0,
|
|
185
|
+
void 0,
|
|
165
186
|
FORMULA_FIX,
|
|
166
187
|
diagnostics
|
|
167
188
|
);
|
|
@@ -182,6 +203,7 @@ function gateContentMath(input) {
|
|
|
182
203
|
"formula",
|
|
183
204
|
definition.source.file,
|
|
184
205
|
void 0,
|
|
206
|
+
void 0,
|
|
185
207
|
SHARED_FORMULA_FIX,
|
|
186
208
|
diagnostics
|
|
187
209
|
);
|
|
@@ -208,7 +230,8 @@ function gateContentMath(input) {
|
|
|
208
230
|
scope,
|
|
209
231
|
"assessment",
|
|
210
232
|
`${assessmentId} (${item.id ?? "?"})`,
|
|
211
|
-
|
|
233
|
+
span.line,
|
|
234
|
+
span.column,
|
|
212
235
|
ASSESSMENT_FIX,
|
|
213
236
|
diagnostics
|
|
214
237
|
);
|
|
@@ -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
|
|
@@ -38,7 +38,7 @@ interface KeyedFloatRefRecord {
|
|
|
38
38
|
}
|
|
39
39
|
interface KeyedFloatDiagnostic {
|
|
40
40
|
readonly code: string;
|
|
41
|
-
readonly severity: 'error' | 'warning';
|
|
41
|
+
readonly severity: 'error' | 'warning' | 'info';
|
|
42
42
|
readonly message: string;
|
|
43
43
|
readonly file?: string;
|
|
44
44
|
readonly lessonId?: string;
|
|
@@ -93,7 +93,7 @@ function validateKeyedFloats(lessons, kind) {
|
|
|
93
93
|
if (referenced.has(`${label.slug}#${label.key}`)) continue;
|
|
94
94
|
diagnostics.push({
|
|
95
95
|
code: `${prefix}-key-unused`,
|
|
96
|
-
severity: "
|
|
96
|
+
severity: "info",
|
|
97
97
|
message: `${label.lessonId} has a ${noun} id="${label.key}" (${label.number}) but nothing references it with [[${prefix}-${label.key}]]`,
|
|
98
98
|
file: label.slug,
|
|
99
99
|
lessonId: label.lessonId,
|
|
@@ -42,6 +42,14 @@ export function resolveMathGlyphs(latex: string, definitions: Array<{
|
|
|
42
42
|
* Used by the `operator-explained` consistency check (rule N8); the glyph
|
|
43
43
|
* resolver still skips operator bodies whole.
|
|
44
44
|
*
|
|
45
|
+
* The span handed in is raw lesson source, so it may still carry
|
|
46
|
+
* `\explain{key}{…}` / `\slot{key}{…}` markers around other symbols in the
|
|
47
|
+
* same expression — `\explain` is not valid KaTeX, so parsing it unstripped
|
|
48
|
+
* throws and this function used to swallow that as "no operators here" for
|
|
49
|
+
* the whole span, silently hiding any `\operatorname{…}` alongside the
|
|
50
|
+
* marker. `stripNotationMarkers` removes just the marker wrapper first so
|
|
51
|
+
* the scan sees plain, parseable math.
|
|
52
|
+
*
|
|
45
53
|
* @param {string} latex
|
|
46
54
|
* @returns {Array<{ name: string, source: 'named' | 'operatorname' }>}
|
|
47
55
|
*/
|
|
@@ -60,6 +68,10 @@ export function collectOperators(latex: string): Array<{
|
|
|
60
68
|
* `roman-label-explained` consistency check (rule N10); the glyph resolver
|
|
61
69
|
* still skips `\mathrm{…}` bodies whole ([N3](../../docs/authoring/notation.md#n3)).
|
|
62
70
|
*
|
|
71
|
+
* Same `stripNotationMarkers` preprocessing as {@link collectOperators}, and
|
|
72
|
+
* for the same reason: an unstripped `\explain{…}{…}` elsewhere in the span
|
|
73
|
+
* fails the raw KaTeX parse and used to silently drop every label in it.
|
|
74
|
+
*
|
|
63
75
|
* @param {string} latex
|
|
64
76
|
* @returns {Array<{ name: string }>}
|
|
65
77
|
*/
|