explico 0.6.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +22 -3
  6. package/dist/compiler/cli.js +71 -10
  7. package/dist/compiler/exports/standalone-html.js +13 -0
  8. package/dist/compiler/logging.d.ts +109 -0
  9. package/dist/compiler/logging.js +107 -0
  10. package/dist/compiler/manifest.d.ts +7 -3
  11. package/dist/compiler/manifest.js +1 -1
  12. package/dist/reference/base-abbreviations.mjs +8 -2
  13. package/dist/reference/consistency.d.ts +8 -10
  14. package/dist/reference/consistency.js +3 -47
  15. package/dist/reference/delimiter-size.d.ts +39 -0
  16. package/dist/reference/delimiter-size.js +177 -0
  17. package/dist/reference/diagrams-validate.d.ts +1 -1
  18. package/dist/reference/equations-validate.d.ts +2 -2
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/figures-validate.d.ts +1 -1
  21. package/dist/reference/gate-math.d.ts +2 -0
  22. package/dist/reference/gate-math.js +28 -6
  23. package/dist/reference/keyed-float-validate.d.ts +2 -2
  24. package/dist/reference/keyed-float-validate.js +1 -1
  25. package/dist/reference/references.js +2 -1
  26. package/dist/reference/registry.js +1 -1
  27. package/dist/reference/rehype-base-links.d.mts +23 -0
  28. package/dist/reference/rehype-base-links.mjs +40 -0
  29. package/dist/reference/remark-abbreviations.mjs +10 -12
  30. package/dist/reference/remark-citation.mjs +0 -0
  31. package/dist/reference/render-lab-math.d.ts +18 -1
  32. package/dist/reference/render-lab-math.js +10 -3
  33. package/dist/reference/tables-validate.d.ts +1 -1
  34. package/dist/reference/types.d.ts +1 -1
  35. package/docs/VERSIONING.md +2 -2
  36. package/docs/authoring/README.md +12 -2
  37. package/docs/authoring/agents.md +7 -3
  38. package/docs/authoring/migrating.md +286 -0
  39. package/docs/authoring/notation.md +139 -7
  40. package/docs/authoring/prose.md +64 -0
  41. package/package.json +1 -1
  42. package/src/compiler/astro.ts +89 -0
  43. package/src/compiler/cli.ts +144 -12
  44. package/src/compiler/exports/standalone-html.ts +15 -0
  45. package/src/compiler/logging.ts +248 -0
  46. package/src/compiler/manifest.ts +7 -3
  47. package/src/components/memo/Memo.astro +2 -5
  48. package/src/components/memo/ShortNote.astro +2 -5
  49. package/src/components/notation/NotationFormula.astro +199 -10
  50. package/src/components/notation/NotationGlossary.astro +89 -58
  51. package/src/components/notation/NotationLayer.astro +83 -81
  52. package/src/components/scroll-animation.ts +160 -0
  53. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  54. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  55. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  56. package/src/components/starlight/LessonFooter.astro +2 -0
  57. package/src/reference/base-abbreviations.mjs +8 -2
  58. package/src/reference/consistency.ts +10 -66
  59. package/src/reference/delimiter-size.ts +301 -0
  60. package/src/reference/diagrams-validate.ts +1 -1
  61. package/src/reference/equations-validate.ts +3 -3
  62. package/src/reference/figures-validate.ts +1 -1
  63. package/src/reference/gate-math.ts +41 -6
  64. package/src/reference/keyed-float-validate.ts +3 -3
  65. package/src/reference/references.ts +2 -1
  66. package/src/reference/registry.ts +1 -1
  67. package/src/reference/rehype-base-links.mjs +40 -0
  68. package/src/reference/remark-abbreviations.mjs +10 -12
  69. package/src/reference/remark-citation.mjs +0 -0
  70. package/src/reference/render-lab-math.ts +24 -1
  71. package/src/reference/tables-validate.ts +1 -1
  72. package/src/reference/types.ts +1 -1
  73. package/src/styles/global.css +119 -14
@@ -0,0 +1,301 @@
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
+
10
+ export const DELIMITER_SIZE_DIAGNOSTIC_CODE =
11
+ 'delimiter-size-hierarchy' as const;
12
+
13
+ export type DelimiterSizeName =
14
+ | 'automatic'
15
+ | 'big'
16
+ | 'Big'
17
+ | 'bigg'
18
+ | 'Bigg';
19
+
20
+ export interface DelimiterSizeSuggestion {
21
+ readonly size: Exclude<DelimiterSizeName, 'automatic' | 'big'>;
22
+ readonly open: string;
23
+ readonly close: string;
24
+ readonly latex: string;
25
+ }
26
+
27
+ export interface DelimiterSizeDiagnostic {
28
+ readonly code: typeof DELIMITER_SIZE_DIAGNOSTIC_CODE;
29
+ readonly severity: 'warning';
30
+ readonly offset: number;
31
+ readonly line: number;
32
+ readonly column: number;
33
+ readonly outerSize: DelimiterSizeName;
34
+ readonly nestedSize: DelimiterSizeName;
35
+ readonly suggestion?: DelimiterSizeSuggestion;
36
+ readonly message: string;
37
+ }
38
+
39
+ type TokenKind = 'open' | 'close';
40
+ type PairKind = 'automatic' | 'manual';
41
+
42
+ interface SizeSpec {
43
+ readonly name: DelimiterSizeName;
44
+ /** `\left` and `\big` can be visually equivalent, so share level 1. */
45
+ readonly rank: number;
46
+ readonly openCommand: string;
47
+ readonly closeCommand: string;
48
+ readonly kind: PairKind;
49
+ }
50
+
51
+ interface DelimiterToken {
52
+ readonly kind: TokenKind;
53
+ readonly pairKind: PairKind;
54
+ readonly rank: number;
55
+ readonly size: DelimiterSizeName;
56
+ readonly delimiter: string;
57
+ readonly offset: number;
58
+ readonly end: number;
59
+ }
60
+
61
+ interface DelimiterPair {
62
+ readonly open: DelimiterToken;
63
+ close?: DelimiterToken;
64
+ readonly children: DelimiterPair[];
65
+ requiredRank?: number;
66
+ }
67
+
68
+ const SIZE_SPECS: readonly SizeSpec[] = [
69
+ {
70
+ name: 'automatic',
71
+ rank: 1,
72
+ openCommand: '\\left',
73
+ closeCommand: '\\right',
74
+ kind: 'automatic',
75
+ },
76
+ {
77
+ name: 'big',
78
+ rank: 1,
79
+ openCommand: '\\bigl',
80
+ closeCommand: '\\bigr',
81
+ kind: 'manual',
82
+ },
83
+ {
84
+ name: 'Big',
85
+ rank: 2,
86
+ openCommand: '\\Bigl',
87
+ closeCommand: '\\Bigr',
88
+ kind: 'manual',
89
+ },
90
+ {
91
+ name: 'bigg',
92
+ rank: 3,
93
+ openCommand: '\\biggl',
94
+ closeCommand: '\\biggr',
95
+ kind: 'manual',
96
+ },
97
+ {
98
+ name: 'Bigg',
99
+ rank: 4,
100
+ openCommand: '\\Biggl',
101
+ closeCommand: '\\Biggr',
102
+ kind: 'manual',
103
+ },
104
+ ];
105
+
106
+ interface CommandSpec extends SizeSpec {
107
+ readonly command: string;
108
+ readonly tokenKind: TokenKind;
109
+ }
110
+
111
+ // Longest first prevents `\Biggl` from being considered as `\Bigl`.
112
+ const COMMAND_SPECS: readonly CommandSpec[] = SIZE_SPECS.flatMap((spec) => [
113
+ { ...spec, command: spec.openCommand, tokenKind: 'open' as const },
114
+ { ...spec, command: spec.closeCommand, tokenKind: 'close' as const },
115
+ ]).sort((left, right) => right.command.length - left.command.length);
116
+
117
+ const SUGGESTION_BY_RANK = new Map(
118
+ SIZE_SPECS.filter((spec) => spec.rank >= 2).map((spec) => [spec.rank, spec]),
119
+ );
120
+
121
+ function delimiterAt(
122
+ latex: string,
123
+ offset: number,
124
+ kind: TokenKind,
125
+ ): { delimiter: string; end: number } | undefined {
126
+ const candidates =
127
+ kind === 'open'
128
+ ? (['\\{', '(', '['] as const)
129
+ : (['\\}', ')', ']'] as const);
130
+ for (const delimiter of candidates) {
131
+ if (latex.startsWith(delimiter, offset)) {
132
+ return { delimiter, end: offset + delimiter.length };
133
+ }
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ function tokenAt(latex: string, offset: number): DelimiterToken | undefined {
139
+ if (latex[offset] !== '\\') return undefined;
140
+ for (const spec of COMMAND_SPECS) {
141
+ if (!latex.startsWith(spec.command, offset)) continue;
142
+ const afterCommand = offset + spec.command.length;
143
+ // A longer control word such as `\leftarrow` is not `\left`.
144
+ if (/[A-Za-z]/.test(latex[afterCommand] ?? '')) continue;
145
+ let delimiterOffset = afterCommand;
146
+ while (/\s/.test(latex[delimiterOffset] ?? '')) delimiterOffset += 1;
147
+ const delimiter = delimiterAt(latex, delimiterOffset, spec.tokenKind);
148
+ if (!delimiter) continue;
149
+ return {
150
+ kind: spec.tokenKind,
151
+ pairKind: spec.kind,
152
+ rank: spec.rank,
153
+ size: spec.name,
154
+ delimiter: delimiter.delimiter,
155
+ offset,
156
+ end: delimiter.end,
157
+ };
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ function pairMatches(open: DelimiterToken, close: DelimiterToken): boolean {
163
+ return (
164
+ open.kind === 'open' &&
165
+ close.kind === 'close' &&
166
+ open.pairKind === close.pairKind &&
167
+ open.rank === close.rank
168
+ );
169
+ }
170
+
171
+ function isEscaped(latex: string, offset: number): boolean {
172
+ let slashes = 0;
173
+ for (let index = offset - 1; index >= 0 && latex[index] === '\\'; index -= 1) {
174
+ slashes += 1;
175
+ }
176
+ return slashes % 2 === 1;
177
+ }
178
+
179
+ /**
180
+ * Build a real nesting tree using an explicit stack. Malformed input is left
181
+ * to KaTeX's syntax diagnostics; this linter emits no potentially misleading
182
+ * size advice for a source whose pairs cannot be matched reliably.
183
+ */
184
+ function parseDelimiterPairs(latex: string): DelimiterPair[] | undefined {
185
+ const roots: DelimiterPair[] = [];
186
+ const stack: DelimiterPair[] = [];
187
+
188
+ for (let offset = 0; offset < latex.length; offset += 1) {
189
+ if (latex[offset] === '%' && !isEscaped(latex, offset)) {
190
+ const newline = latex.indexOf('\n', offset + 1);
191
+ if (newline === -1) break;
192
+ offset = newline;
193
+ continue;
194
+ }
195
+ const token = isEscaped(latex, offset) ? undefined : tokenAt(latex, offset);
196
+ if (!token) continue;
197
+ offset = token.end - 1;
198
+
199
+ if (token.kind === 'open') {
200
+ const pair: DelimiterPair = { open: token, children: [] };
201
+ const parent = stack[stack.length - 1];
202
+ if (parent) parent.children.push(pair);
203
+ else roots.push(pair);
204
+ stack.push(pair);
205
+ continue;
206
+ }
207
+
208
+ const pair = stack[stack.length - 1];
209
+ if (!pair || !pairMatches(pair.open, token)) return undefined;
210
+ pair.close = token;
211
+ stack.pop();
212
+ }
213
+
214
+ return stack.length === 0 ? roots : undefined;
215
+ }
216
+
217
+ function lineAndColumn(
218
+ source: string,
219
+ offset: number,
220
+ ): { line: number; column: number } {
221
+ const before = source.slice(0, offset);
222
+ const lastNewline = before.lastIndexOf('\n');
223
+ return {
224
+ line: before.split('\n').length,
225
+ column: offset - lastNewline,
226
+ };
227
+ }
228
+
229
+ function sizeName(rank: number): DelimiterSizeName {
230
+ return SUGGESTION_BY_RANK.get(rank)?.name ?? 'Bigg';
231
+ }
232
+
233
+ function suggestionFor(
234
+ pair: DelimiterPair,
235
+ requiredRank: number,
236
+ ): DelimiterSizeSuggestion | undefined {
237
+ const close = pair.close;
238
+ const spec = SUGGESTION_BY_RANK.get(requiredRank);
239
+ if (!close || !spec || spec.name === 'automatic' || spec.name === 'big') {
240
+ return undefined;
241
+ }
242
+ return {
243
+ size: spec.name,
244
+ open: `${spec.openCommand}${pair.open.delimiter}`,
245
+ close: `${spec.closeCommand}${close.delimiter}`,
246
+ latex: `${spec.openCommand}${pair.open.delimiter} ... ${spec.closeCommand}${close.delimiter}`,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Warn when a scalable outer pair is not at least one manual size level above
252
+ * everything nested inside it. `\left` and `\big` form the baseline level;
253
+ * progressively larger levels are `\Big`, `\bigg`, and `\Bigg`.
254
+ *
255
+ * Only `()`, `[]`, and `\{\}` are parsed. Bare delimiters are intentionally
256
+ * ignored: N15 owns that separate error, while ordinary semantic notation
257
+ * such as `f(x)` and `\mathbb{E}[X]` must not trigger this size-only linter.
258
+ */
259
+ export function lintDelimiterSizes(latex: string): DelimiterSizeDiagnostic[] {
260
+ const roots = parseDelimiterPairs(latex);
261
+ if (!roots) return [];
262
+ const diagnostics: DelimiterSizeDiagnostic[] = [];
263
+
264
+ const assess = (pair: DelimiterPair): number => {
265
+ let largestNestedRequirement = 0;
266
+ let representativeNestedSize: DelimiterSizeName = 'automatic';
267
+ for (const child of pair.children) {
268
+ const childRequired = assess(child);
269
+ if (childRequired > largestNestedRequirement) {
270
+ largestNestedRequirement = childRequired;
271
+ representativeNestedSize = sizeName(childRequired);
272
+ }
273
+ }
274
+
275
+ const requiredRank =
276
+ largestNestedRequirement === 0
277
+ ? pair.open.rank
278
+ : largestNestedRequirement + 1;
279
+ pair.requiredRank = Math.max(pair.open.rank, requiredRank);
280
+ if (largestNestedRequirement > 0 && pair.open.rank < requiredRank) {
281
+ const location = lineAndColumn(latex, pair.open.offset);
282
+ const suggestion = suggestionFor(pair, requiredRank);
283
+ diagnostics.push({
284
+ code: DELIMITER_SIZE_DIAGNOSTIC_CODE,
285
+ severity: 'warning',
286
+ offset: pair.open.offset,
287
+ ...location,
288
+ outerSize: pair.open.size,
289
+ nestedSize: representativeNestedSize,
290
+ ...(suggestion ? { suggestion } : {}),
291
+ message: suggestion
292
+ ? `Outer delimiters are not larger than their nested pair. Use ${suggestion.latex}; preserve the original opening and closing delimiter shapes (rule N17).`
293
+ : `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).`,
294
+ });
295
+ }
296
+ return pair.requiredRank;
297
+ };
298
+
299
+ for (const root of roots) assess(root);
300
+ return diagnostics.sort((left, right) => left.offset - right.offset);
301
+ }
@@ -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` (Tier 3, warning): a labelled equation never referenced.
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: 'warning',
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,
@@ -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;
@@ -7,10 +7,12 @@
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 failure names `file:line: "token"` and the fix.
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
14
  import { stripEquationLabels } from './equations.mjs';
15
+ import { lintDelimiterSizes } from './delimiter-size';
14
16
  import { formulaGlyphScope, unknownFormulaScopeKeys } from './gloss';
15
17
  import type { GlyphScopeEntry } from './gloss';
16
18
  import type {
@@ -45,8 +47,10 @@ export interface MathGateDiagnostic {
45
47
  * than adding a second array the CLI has to know about.
46
48
  */
47
49
  readonly severity: 'error' | 'warning';
50
+ readonly code?: string;
48
51
  readonly file: string;
49
52
  readonly line?: number;
53
+ readonly column?: number;
50
54
  readonly token?: string;
51
55
  readonly message: string;
52
56
  }
@@ -92,6 +96,7 @@ function lineOf(text: string, index: number): number {
92
96
  interface MathSpan {
93
97
  readonly latex: string;
94
98
  readonly line: number;
99
+ readonly column: number;
95
100
  }
96
101
 
97
102
  function mathSpans(body: string): MathSpan[] {
@@ -101,10 +106,14 @@ function mathSpans(body: string): MathSpan[] {
101
106
  pattern.lastIndex = 0;
102
107
  let match: RegExpExecArray | null;
103
108
  while ((match = pattern.exec(searchable))) {
109
+ const latex = match[1] ?? '';
110
+ const latexOffset = match.index + match[0].indexOf(latex);
111
+ const before = body.slice(0, latexOffset);
112
+ const lastNewline = before.lastIndexOf('\n');
104
113
  spans.push({
105
- // D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
106
- latex: stripEquationLabels(match[1] ?? ''),
107
- line: lineOf(body, match.index),
114
+ latex,
115
+ line: lineOf(body, latexOffset),
116
+ column: latexOffset - lastNewline,
108
117
  });
109
118
  }
110
119
  }
@@ -150,12 +159,34 @@ function resolveInto(
150
159
  context: MathGateDiagnostic['context'],
151
160
  file: string,
152
161
  line: number | undefined,
162
+ column: number | undefined,
153
163
  fix: string,
154
164
  out: MathGateDiagnostic[],
155
165
  ): void {
166
+ for (const diagnostic of lintDelimiterSizes(latex)) {
167
+ const locatedLine =
168
+ line === undefined ? undefined : line + diagnostic.line - 1;
169
+ const locatedColumn =
170
+ column === undefined
171
+ ? undefined
172
+ : diagnostic.line === 1
173
+ ? column + diagnostic.column - 1
174
+ : diagnostic.column;
175
+ out.push({
176
+ context,
177
+ code: diagnostic.code,
178
+ severity: 'warning',
179
+ file,
180
+ ...(locatedLine === undefined ? {} : { line: locatedLine }),
181
+ ...(locatedColumn === undefined ? {} : { column: locatedColumn }),
182
+ message: diagnostic.message,
183
+ });
184
+ }
185
+
156
186
  let result;
157
187
  try {
158
- result = resolveMathGlyphs(latex, [...scope]);
188
+ // D7: `\label{eq:…}` is an identity marker, not math KaTeX parses.
189
+ result = resolveMathGlyphs(stripEquationLabels(latex), [...scope]);
159
190
  } catch (error) {
160
191
  out.push({
161
192
  context,
@@ -283,6 +314,7 @@ export function gateContentMath(input: {
283
314
  'body',
284
315
  file,
285
316
  span.line,
317
+ span.column,
286
318
  BODY_FIX,
287
319
  diagnostics,
288
320
  );
@@ -305,6 +337,7 @@ export function gateContentMath(input: {
305
337
  'formula',
306
338
  file,
307
339
  undefined,
340
+ undefined,
308
341
  FORMULA_FIX,
309
342
  diagnostics,
310
343
  );
@@ -330,6 +363,7 @@ export function gateContentMath(input: {
330
363
  'formula',
331
364
  definition.source.file,
332
365
  undefined,
366
+ undefined,
333
367
  SHARED_FORMULA_FIX,
334
368
  diagnostics,
335
369
  );
@@ -358,7 +392,8 @@ export function gateContentMath(input: {
358
392
  scope,
359
393
  'assessment',
360
394
  `${assessmentId} (${item.id ?? '?'})`,
361
- undefined,
395
+ span.line,
396
+ span.column,
362
397
  ASSESSMENT_FIX,
363
398
  diagnostics,
364
399
  );
@@ -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` (warning): a keyed float never referenced.
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: 'warning',
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,
@@ -78,5 +78,6 @@ export function compareDiagnostics(
78
78
  }
79
79
 
80
80
  function severityRank(severity: NotationDiagnostic['severity']): number {
81
- return severity === 'error' ? 0 : 1;
81
+ if (severity === 'error') return 0;
82
+ return severity === 'warning' ? 1 : 2;
82
83
  }
@@ -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: 'warning',
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 the **first** occurrence per
9
- * document in a real link (`/glossary/#abbr-<key>`, so it still navigates
10
- * with no JS) carrying the `data-abbr-key` an island script reads to render a
11
- * hover/click card, exactly the way `remarkCitation` wraps `[@id]` markers.
12
- * Later occurrences of the same term on the same page stay plain text, so a
13
- * common short word isn't turned into a link everywhere it appears.
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 || usedKeys.has(key)) continue;
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
1
  import katex from 'katex';
2
- import { resolveMathGlyphs } from './math-glyphs.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;
@@ -127,7 +127,7 @@ export type NotationDiagnosticCode =
127
127
  | 'alignment-review-state'
128
128
  | 'gloss-collides-with-card';
129
129
 
130
- export type DiagnosticSeverity = 'error' | 'warning';
130
+ export type DiagnosticSeverity = 'error' | 'warning' | 'info';
131
131
 
132
132
  export interface NotationDiagnostic {
133
133
  readonly code: NotationDiagnosticCode;