explico 0.5.0 → 0.6.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 CHANGED
@@ -14,6 +14,97 @@ under **Breaking** below; a PATCH never breaks.
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.6.0] - 2026-09-16
18
+
19
+ ### Breaking
20
+
21
+ - **New build-enforced authoring rule S1: no Markdown heading (`##` through
22
+ `######`) and no `<Figure title>` / `<Diagram label>` / `<Memo title>` /
23
+ `<ShortNote title>` may run past 65 characters.** A `<Figure>` with a long
24
+ `caption` and no explicit `title` was silently using the whole caption as
25
+ its always-visible collapsible title-bar text — the reader saw the full
26
+ sentence twice. `gate-heading-length.ts` scans every lesson body; a course
27
+ with an over-limit heading or float title anywhere will now fail
28
+ `pnpm validate:content` / `astro build`. `<Table>` is exempt — it has only
29
+ a `caption`, not a separate `title`. A lesson's own `frontmatter.title`
30
+ (the page `<h1>`) is Review-only, not build-enforced — the gate scans body
31
+ text and cannot see frontmatter. See
32
+ `docs/authoring/structure-rules.md#s1`.
33
+
34
+ - **Removed the "Pin explanation" button from the "Page notation" panel**
35
+ (`.notation-pin`, `[data-notation-control]` — both gone from the DOM). Pin
36
+ a card from the list the same way an inline prose symbol always has: click
37
+ its glyph, now also Tab + Enter/Space-operable (`role="button"`,
38
+ `tabindex="0"`, `aria-pressed`) since it was the button's only remaining
39
+ job. A course's own CSS or tests targeting either selector need updating.
40
+
41
+ - **`NotationLayer.astro`'s separate "Page notation" `<h2>` is gone** —
42
+ the panel had two headings back to back ("Page notation" over "Notation
43
+ used on this page (N)"), saying the same thing twice. The panel's
44
+ `<summary>` is now the only heading, and `.notation-layer-heading` no
45
+ longer exists.
46
+
47
+ - **`Diagram`'s `description` prop and its collapsed "Diagram description"
48
+ fallback block are gone** (`.diagram-fallback` no longer exists in the
49
+ DOM). The caption (`label`) is the diagram's one authored piece of text,
50
+ matching how a LaTeX figure/table caption already tells the whole story —
51
+ a separate always-present description block was inventing a second text
52
+ category most courses never wanted and that only added visible chrome
53
+ under every diagram. Drop any `description="…"` passed to `<Diagram>`;
54
+ the build no longer accepts it (`astro check` fails on an excess prop).
55
+
56
+ ### Added
57
+
58
+ - **`NotationLayer.astro`'s "Page notation" panel and `remark-citation.mjs`'s
59
+ generated "References" section are both properly collapsible now** — same
60
+ native, persisted, deep-linkable disclosure `Figure`/`CollapsibleSections`
61
+ already use, not the always-native-default `<details>` "Page notation" had
62
+ before (no persistence, no course-configurable default, no anchor).
63
+ - `NotationLayer` gains `defaultOpen`/`rememberState` props (mirroring
64
+ `Figure`'s) and a hover-reveal permalink anchor next to its title bar
65
+ (`#page-notation` by default).
66
+ - `remark-citation.mjs`'s generated References heading now carries the
67
+ same `[collapsible]`/`[collapsed]` marker an authored `##` heading uses,
68
+ via a new `defaultOpen` plugin option — so it rides the *same*
69
+ `rehypeHeadingAnchors` / `CollapsibleSections` machinery every other
70
+ heading does, with no engine changes needed on that side. This only
71
+ takes visible effect on a course whose `astro.config.mjs` wires
72
+ `rehypeHeadingAnchors` into its rehype pipeline.
73
+ - New `CourseConfig.disclosureDefaults` (the manifest schema is now v14,
74
+ additive):
75
+ `{ notation?, references? }`, each `true` = open, closed (the engine's
76
+ own default) when omitted or the whole block is absent. Both are
77
+ engine-authored sections, not written by a lesson, so they get their own
78
+ default independent of `defaultCollapsible.sections`/`.subsections` (an
79
+ author's own `##` headings). See `docs/authoring`,
80
+ `playground/astro.config.mjs` for the wiring convention.
81
+
82
+ ### Fixed
83
+
84
+ - **A symbol explained once via `\explain{key}{…}` can now auto-resolve at
85
+ every later bare occurrence of the same declared `latex` on the page — this
86
+ previously failed silently (no tooltip, no build error) for several common
87
+ shapes.** A definition whose whole canonical form is a roman label
88
+ (`\mathrm{SSR}`, `\mathrm{SST}`) never entered the auto-annotator's
89
+ signature index at all, so a bare occurrence of such a symbol could never
90
+ resolve, anywhere on the page, independent of position. Also fixed: a bare
91
+ symbol sitting immediately next to an `\explain{…}{…}` call, a canonical
92
+ match found by seeing through an already-explicit call's own wrapping, and
93
+ a partial `\explain{key}{…}` wrap covering only part of a declared
94
+ multi-token form — all three previously either dropped the symbol silently
95
+ or produced malformed, unparseable output. All of these now resolve
96
+ correctly, or fail loudly as "Unresolved notation" (`resolveMathGlyphs`,
97
+ shared by lesson-body rendering and `gate-math.ts`'s `pnpm validate:content`
98
+ check — so a regression here fails the build immediately rather than
99
+ passing silently through to a render-time surprise). One side effect: the
100
+ fix also restores the `ambiguous-canonical-notation` check for
101
+ `\operatorname{…}`/`\mathrm{…}`-only glyphs, which the same bug had
102
+ silently disabled — a course with two different notation keys declaring
103
+ structurally identical LaTeX (e.g. two keys both `\operatorname{Var}`) will
104
+ now correctly fail `pnpm validate:content` where it previously built with a
105
+ silent, unresolvable ambiguity. See
106
+ [`docs/authoring/notation.md`](docs/authoring/notation.md).
107
+
17
108
  ## [0.5.0] - 2026-09-15
18
109
 
19
110
  ### Added
@@ -137,13 +228,16 @@ under **Breaking** below; a PATCH never breaks.
137
228
  UI. The change is additive, but consumers that validate
138
229
  `MANIFEST_SCHEMA_VERSION` must accept the new version.
139
230
 
140
- - **The compiler manifest schema is now v10.** Diagrams are first-class keyed
231
+ - **The compiler manifest schema is now v10.** Numbered knowledge checks add
232
+ `manifest.checks`, `lessons[].knowledgeChecks`, `lessons[].numbering.checks`,
233
+ and `diagnostics.checks`. The change is additive, but consumers that
234
+ validate `MANIFEST_SCHEMA_VERSION` must accept the new version.
235
+
236
+ - **The compiler manifest schema is now v9.** Diagrams are first-class keyed
141
237
  floats with their own `manifest.diagrams`, `lessons[].diagrams`,
142
- `lessons[].numbering.diagrams`, and `diagnostics.diagrams` fields. Numbered
143
- knowledge checks add `manifest.checks`, `lessons[].knowledgeChecks`,
144
- `lessons[].numbering.checks`, and `diagnostics.checks`. The changes are
145
- additive, but consumers that validate `MANIFEST_SCHEMA_VERSION` must accept
146
- the new version.
238
+ `lessons[].numbering.diagrams`, and `diagnostics.diagrams` fields. The
239
+ change is additive, but consumers that validate `MANIFEST_SCHEMA_VERSION`
240
+ must accept the new version.
147
241
 
148
242
  - **New build-enforced authoring rule N9: a physical quantity, unit, or math
149
243
  operator must be typeset in `$…$`, never as plain text, anywhere on a
@@ -217,6 +311,20 @@ under **Breaking** below; a PATCH never breaks.
217
311
  with `\sin\theta`, `\log x`, `\exp t`, etc. anywhere in rendered math will
218
312
  now fail the build; see `docs/authoring/notation.md#n13`.
219
313
 
314
+ - **New build-enforced authoring rule N15: a nested visible delimiter — `(`,
315
+ `[`, `\{`, or `|` inside another one of them — must use its `\left`/`\right`
316
+ sizing form, not the bare character.** The moment a second one of these four
317
+ glyphs sits inside the first — same type or mixed, e.g. `(a + [b])` — every
318
+ level of that nest switches to the sizing form: `\sin(2(90°-\theta))` is now
319
+ a hard error, `\sin\left(2\left(90°-\theta\right)\right)` is required.
320
+ `findNestedBareDelimiters` (`math-glyphs.mjs`) walks the same parsed KaTeX
321
+ tree `resolveMathGlyphs` and N13 already build; a violation fails
322
+ `pnpm validate:content` / `astro build` with no `lint-ignore.yml` escape
323
+ hatch, like N9/N13. A single, non-nested bare delimiter (`(x)`) is
324
+ untouched, and a delimiter inside `\text{…}` (a unit per N9) is unaffected.
325
+ A downstream course with a nested bare delimiter anywhere in rendered math
326
+ will now fail the build; see `docs/authoring/notation.md#n15`.
327
+
220
328
  ### Added
221
329
 
222
330
  - **Numbered, referenceable, collapsible knowledge checks.** Assessment ids in
@@ -436,7 +544,8 @@ under **Breaking** below; a PATCH never breaks.
436
544
  system with its completeness gate, the curriculum-graph validator, the
437
545
  Astro/Starlight UI, the LMS / session / analytics seams, and the `content` CLI.
438
546
 
439
- [Unreleased]: https://github.com/danieltuzes/explico/compare/v0.5.0...HEAD
547
+ [Unreleased]: https://github.com/danieltuzes/explico/compare/v0.6.0...HEAD
548
+ [0.6.0]: https://github.com/danieltuzes/explico/compare/v0.5.0...v0.6.0
440
549
  [0.5.0]: https://github.com/danieltuzes/explico/compare/v0.4.0...v0.5.0
441
550
  [0.4.0]: https://github.com/danieltuzes/explico/compare/v0.3.0...v0.4.0
442
551
  [0.3.0]: https://github.com/danieltuzes/explico/compare/v0.2.0...v0.3.0
@@ -107,6 +107,33 @@ interface CourseAbout {
107
107
  /** Link to the published content changelog (repo blob, docs page, …). */
108
108
  readonly changelogHref?: string;
109
109
  }
110
+ /**
111
+ * A course's chosen initial open/closed state for the engine's own built-in
112
+ * collapsible sections — the "Page notation" panel (`NotationLayer.astro`)
113
+ * and the generated "References" section (`remark-citation.mjs`). Each is
114
+ * independent of `defaultCollapsible.sections`/`.subsections` (an author's
115
+ * own `##` headings): both of these are engine-authored, not written by a
116
+ * lesson, so they need their own default rather than inheriting the site's
117
+ * general heading default.
118
+ *
119
+ * `true` starts open, `false` (or omitted) starts closed — the engine's own
120
+ * default is closed either way, matching a long panel a reader usually
121
+ * doesn't need open on arrival.
122
+ */
123
+ interface CourseDisclosureDefaults {
124
+ /** Initial state of the "Page notation" panel. Default: closed. */
125
+ readonly notation?: boolean;
126
+ /** Initial state of the generated "References" section. Default: closed. */
127
+ readonly references?: boolean;
128
+ }
129
+ /** The stable, non-optional form `NormalizedLessonExports` mirrors. */
130
+ interface NormalizedDisclosureDefaults {
131
+ readonly notation: boolean;
132
+ readonly references: boolean;
133
+ }
134
+ /** Validate and canonicalize `CourseConfig.disclosureDefaults`. Absent (or
135
+ * an absent sub-field) resolves to closed, the engine's own default. */
136
+ declare function normalizeDisclosureDefaults(config: CourseConfig): NormalizedDisclosureDefaults;
110
137
  /** Optional, build-time lesson artifacts exposed to readers. */
111
138
  interface LessonExportsConfig {
112
139
  /** Emit one self-contained, offline-capable HTML file per lesson. */
@@ -151,6 +178,9 @@ interface CourseConfig {
151
178
  readonly abbreviations?: CourseAbbreviations;
152
179
  /** Per-lesson export actions and downloadable artifacts. Disabled when omitted. */
153
180
  readonly lessonExports?: LessonExportsConfig;
181
+ /** Initial open/closed state for the engine's own built-in collapsible
182
+ * sections ("Page notation", "References"). Closed when omitted. */
183
+ readonly disclosureDefaults?: CourseDisclosureDefaults;
154
184
  }
155
185
  /** Validate and canonicalize the build-facing lesson-export configuration. */
156
186
  declare function normalizeLessonExports(config: CourseConfig): NormalizedLessonExports;
@@ -161,4 +191,4 @@ declare function normalizeLessonExports(config: CourseConfig): NormalizedLessonE
161
191
  */
162
192
  declare function coursePartsOf(config: CourseConfig): readonly CoursePart[];
163
193
 
164
- export { type CourseAbbreviations, type CourseAbout, type CourseAuthor, type CourseConfig, type CourseConventions, type CourseOperators, type CoursePart, type CourseRomanLabels, type CourseSection, type LessonExportsConfig, type NormalizedLessonExports, coursePartsOf, normalizeLessonExports };
194
+ export { type CourseAbbreviations, type CourseAbout, type CourseAuthor, type CourseConfig, type CourseConventions, type CourseDisclosureDefaults, type CourseOperators, type CoursePart, type CourseRomanLabels, type CourseSection, type LessonExportsConfig, type NormalizedDisclosureDefaults, type NormalizedLessonExports, coursePartsOf, normalizeDisclosureDefaults, normalizeLessonExports };
@@ -1,3 +1,9 @@
1
+ function normalizeDisclosureDefaults(config) {
2
+ return {
3
+ notation: config.disclosureDefaults?.notation === true,
4
+ references: config.disclosureDefaults?.references === true
5
+ };
6
+ }
1
7
  function normalizeLessonExports(config) {
2
8
  const standaloneHtml = config.lessonExports?.standaloneHtml === true;
3
9
  const print = config.lessonExports?.print === true;
@@ -35,5 +41,6 @@ function coursePartsOf(config) {
35
41
  }
36
42
  export {
37
43
  coursePartsOf,
44
+ normalizeDisclosureDefaults,
38
45
  normalizeLessonExports
39
46
  };
@@ -10,7 +10,7 @@ import { MathGateDiagnostic } from '../reference/gate-math.js';
10
10
  import { ContentSegment } from '../reference/mixed-content.js';
11
11
  import { GlyphScopeEntry } from '../reference/gloss.js';
12
12
  import { NotationDefinitionRecord, NotationPageBundle, NotationBacklink, NotationDiagnostic, EditorialStatus, NotationGlossEntry } from '../reference/types.js';
13
- import { NormalizedLessonExports, CourseConfig } from './course-config.js';
13
+ import { NormalizedLessonExports, NormalizedDisclosureDefaults, CourseConfig } from './course-config.js';
14
14
  import { KnowledgeVersionDiagnostic } from './knowledge-version.js';
15
15
  import { SidebarGroup } from './lesson-derivation.js';
16
16
  import './collections.js';
@@ -90,8 +90,14 @@ import '../reference/keyed-float-validate.js';
90
90
  * validation and can never get a stable number or `[[…]]` reference; this
91
91
  * flags it (warning-only — the manifest shape a client already reads is
92
92
  * unchanged).
93
+ * v14 (disclosure defaults, additive): `disclosureDefaults` — the resolved
94
+ * initial open/closed state for the "Page notation" panel and the generated
95
+ * "References" section, from `CourseConfig.disclosureDefaults`. Both are
96
+ * engine-authored sections, not written by a lesson, so they get their own
97
+ * default independent of a course's `##` heading default; closed unless the
98
+ * course sets one.
93
99
  */
94
- declare const MANIFEST_SCHEMA_VERSION = 13;
100
+ declare const MANIFEST_SCHEMA_VERSION = 14;
95
101
  declare const MANIFEST_PATH: string;
96
102
  interface ManifestCitation {
97
103
  readonly id: string;
@@ -256,6 +262,8 @@ interface Manifest {
256
262
  readonly schemaVersion: number;
257
263
  /** Normalized build/UI settings for opt-in per-lesson exports. */
258
264
  readonly lessonExports: NormalizedLessonExports;
265
+ /** Initial open/closed state for "Page notation" and "References". */
266
+ readonly disclosureDefaults: NormalizedDisclosureDefaults;
259
267
  /** Reader-facing identity + versions (the `[?]` About panel). */
260
268
  readonly about: ManifestAbout;
261
269
  readonly notation: {
@@ -45,6 +45,7 @@ import {
45
45
  gateProseNotationInAssessments
46
46
  } from "../reference/gate-prose-notation.js";
47
47
  import { gateAbbreviations } from "../reference/gate-abbreviations.js";
48
+ import { gateHeadingLength } from "../reference/gate-heading-length.js";
48
49
  import {
49
50
  splitMixedContent
50
51
  } from "../reference/mixed-content.js";
@@ -54,7 +55,8 @@ import { formulaGlyphScope } from "../reference/gloss.js";
54
55
  import { resolveReferenceId } from "../reference/references.js";
55
56
  import {
56
57
  coursePartsOf,
57
- normalizeLessonExports
58
+ normalizeLessonExports,
59
+ normalizeDisclosureDefaults
58
60
  } from "./course-config.js";
59
61
  import {
60
62
  resolveKnowledgeVersion
@@ -73,7 +75,7 @@ import {
73
75
  isLessonSlug,
74
76
  lessonIdFromSlug
75
77
  } from "./lesson-derivation.js";
76
- const MANIFEST_SCHEMA_VERSION = 13;
78
+ const MANIFEST_SCHEMA_VERSION = 14;
77
79
  const REPOSITORY_ROOT = process.cwd();
78
80
  const CONTENT_ROOT = join(REPOSITORY_ROOT, "content");
79
81
  const MANIFEST_PATH = join(REPOSITORY_ROOT, "build", "manifest.json");
@@ -243,7 +245,8 @@ async function compileManifest(courseConfig) {
243
245
  notationInput,
244
246
  abbreviations: abbreviationsInput,
245
247
  courseAbbreviations: courseConfig.abbreviations
246
- })
248
+ }),
249
+ ...gateHeadingLength(notationInput.lessons)
247
250
  ];
248
251
  const notationLessonById = new Map(
249
252
  notationInput.lessons.map((lesson) => [lesson.lessonId, lesson])
@@ -517,6 +520,7 @@ async function compileManifest(courseConfig) {
517
520
  return {
518
521
  schemaVersion: MANIFEST_SCHEMA_VERSION,
519
522
  lessonExports: normalizeLessonExports(courseConfig),
523
+ disclosureDefaults: normalizeDisclosureDefaults(courseConfig),
520
524
  about,
521
525
  notation: {
522
526
  definitions: registry.definitions,
@@ -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,7 +19,11 @@ 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';
25
29
  readonly file: string;
@@ -6,7 +6,8 @@ const SEVERITY = {
6
6
  formula: "error",
7
7
  assessment: "error",
8
8
  prose: "error",
9
- abbreviation: "error"
9
+ abbreviation: "error",
10
+ structure: "error"
10
11
  };
11
12
  const TERM_KEY = /\[\[\s*([a-z0-9]+(?:[.-][a-z0-9]+)*)\s*\]\]/g;
12
13
  const EXPLAIN_KEY = /\\explain\s*\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}/g;
@@ -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
  */