explico 0.1.1 → 0.2.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 (42) hide show
  1. package/dist/compiler/cli.d.ts +1 -0
  2. package/dist/compiler/collections.js +27 -0
  3. package/dist/compiler/manifest.d.ts +10 -1
  4. package/dist/compiler/manifest.js +18 -2
  5. package/dist/compiler/resolution-report.d.ts +1 -0
  6. package/dist/reference/consistency.d.ts +1 -1
  7. package/dist/reference/consistency.js +93 -2
  8. package/dist/reference/equations.d.mts +43 -16
  9. package/dist/reference/equations.mjs +75 -39
  10. package/dist/reference/gate-math.d.ts +5 -4
  11. package/dist/reference/gate-math.js +73 -2
  12. package/dist/reference/gloss.d.ts +87 -0
  13. package/dist/reference/gloss.js +59 -0
  14. package/dist/reference/registry.js +25 -0
  15. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  16. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  17. package/dist/reference/remark-notation.mjs +30 -24
  18. package/dist/reference/types.d.ts +25 -2
  19. package/package.json +1 -1
  20. package/src/compiler/collections.ts +40 -0
  21. package/src/compiler/manifest.ts +31 -0
  22. package/src/components/HoverPanel.astro +42 -0
  23. package/src/components/citation/CitationLayer.astro +1 -0
  24. package/src/components/hover-panel.ts +71 -5
  25. package/src/components/notation/NotationFormula.astro +154 -0
  26. package/src/components/notation/NotationGlossary.astro +60 -0
  27. package/src/components/notation/NotationLayer.astro +248 -103
  28. package/src/components/notation/render-notation-math.ts +55 -0
  29. package/src/components/popup-stack.ts +345 -0
  30. package/src/components/starlight/EquationEnhancer.astro +20 -5
  31. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  32. package/src/components/starlight/LessonFooter.astro +4 -0
  33. package/src/content-config.ts +38 -0
  34. package/src/reference/consistency.ts +154 -1
  35. package/src/reference/equations.mjs +75 -39
  36. package/src/reference/gate-math.ts +104 -7
  37. package/src/reference/gloss.ts +153 -0
  38. package/src/reference/registry.ts +43 -0
  39. package/src/reference/rehype-heading-anchors.mjs +144 -0
  40. package/src/reference/remark-notation.mjs +30 -24
  41. package/src/reference/types.ts +26 -1
  42. package/src/styles/global.css +84 -2
@@ -11,8 +11,16 @@ export interface NotationLayerDefinition {
11
11
  key: string;
12
12
  notation: string;
13
13
  label: string;
14
- summary: string;
14
+ /** A card's `meaning`. Absent on a gloss, which is a symbol and a name. */
15
+ summary?: string;
15
16
  formula?: string;
17
+ /**
18
+ * Entry-scoped glyph table for `formula` (D15). Without it the formula
19
+ * renders inert, as it did before the two-tier vocabulary.
20
+ */
21
+ formulaScope?: readonly GlyphScopeEntry[];
22
+ /** Symbols the formula names that have no card of their own. */
23
+ glosses?: readonly ResolvedGloss[];
16
24
  units?: string;
17
25
  seeAlso?: readonly string[];
18
26
  alignment?: NotationAlignment;
@@ -37,7 +45,8 @@ export interface Props {
37
45
  }
38
46
 
39
47
  import NotationMath from './NotationMath.astro';
40
- import HoverPanel from '../HoverPanel.astro';
48
+ import NotationFormula from './NotationFormula.astro';
49
+ import type { GlyphScopeEntry, ResolvedGloss } from '../../reference/gloss';
41
50
 
42
51
  const {
43
52
  definitions,
@@ -78,7 +87,21 @@ const items = definitions.map((definition) => {
78
87
  return { ...definition, scope, href };
79
88
  });
80
89
 
81
- const panelId = `${id}-panel`;
90
+ // Glosses (D15) have no card and no anchor, so they need a data source for the
91
+ // panel. Their names are already visible under the formula they belong to
92
+ // (`NotationFormula` renders that list, and it is the no-JS baseline), so these
93
+ // blocks are hidden and exist only for the enhancement to read.
94
+ const glossItems = items.flatMap((definition) => definition.glosses ?? []);
95
+
96
+ for (const gloss of glossItems) {
97
+ if (!keyPattern.test(gloss.key)) {
98
+ throw new Error(`Unsafe notation gloss key: ${gloss.key}`);
99
+ }
100
+ if (seen.has(gloss.key)) {
101
+ throw new Error(`Duplicate notation key in NotationLayer: ${gloss.key}`);
102
+ }
103
+ seen.add(gloss.key);
104
+ }
82
105
  ---
83
106
 
84
107
  {
@@ -134,12 +157,22 @@ const panelId = `${id}-panel`;
134
157
  )}
135
158
  </dt>
136
159
  <dd>
137
- <p data-notation-summary>{definition.summary}</p>
138
- {definition.formula && (
139
- <span class="notation-formula" data-notation-formula>
140
- <NotationMath latex={definition.formula} />
141
- </span>
160
+ {definition.summary && (
161
+ <p data-notation-summary>{definition.summary}</p>
142
162
  )}
163
+ {definition.formula &&
164
+ (definition.formulaScope ? (
165
+ <NotationFormula
166
+ formula={definition.formula}
167
+ scope={definition.formulaScope}
168
+ glosses={definition.glosses}
169
+ entryLabel={definition.label}
170
+ />
171
+ ) : (
172
+ <span class="notation-formula" data-notation-formula>
173
+ <NotationMath latex={definition.formula} />
174
+ </span>
175
+ ))}
143
176
  {definition.units && (
144
177
  <p class="notation-units" data-notation-units>
145
178
  <strong>Units:</strong> {definition.units}
@@ -149,7 +182,6 @@ const panelId = `${id}-panel`;
149
182
  class="notation-pin"
150
183
  type="button"
151
184
  data-notation-control={definition.key}
152
- aria-controls={panelId}
153
185
  aria-expanded="false"
154
186
  aria-pressed="false"
155
187
  hidden
@@ -160,34 +192,71 @@ const panelId = `${id}-panel`;
160
192
  </div>
161
193
  ))}
162
194
  </dl>
195
+
196
+ {glossItems.map((gloss) => (
197
+ <div
198
+ class="notation-gloss-source"
199
+ data-notation-entry={gloss.key}
200
+ hidden
201
+ >
202
+ <span class="notation-symbol-trigger" data-notation-key={gloss.key}>
203
+ <NotationMath class="notation-symbol" latex={gloss.notation} />
204
+ </span>
205
+ <span data-notation-link={gloss.key}>{gloss.label}</span>
206
+ {gloss.units && (
207
+ <span data-notation-units>
208
+ <strong>Units:</strong> {gloss.units}
209
+ </span>
210
+ )}
211
+ </div>
212
+ ))}
163
213
  </details>
164
214
 
165
215
  {!panelOnly && (
166
216
  <p class="notation-enhancement-help" data-notation-help hidden>
167
217
  Point to a marked symbol for a quick explanation, or use the list
168
- above. Select “Pin explanation” to keep one open; press Escape to
169
- close it.
218
+ above — pointing at a symbol inside an open card explains that one
219
+ too, alongside it. Click a symbol, or select “Pin explanation”, to
220
+ keep a card open; click the card (or its “×”) to close it, or press
221
+ Escape.
170
222
  </p>
171
223
  )}
172
224
 
173
- <HoverPanel id={panelId} label="Notation explanation" data-notation-panel>
174
- <div class="notation-panel-body">
175
- <header class="notation-panel-heading">
176
- <span class="notation-panel-symbol" data-panel-symbol />
177
- <h3 data-panel-title />
178
- </header>
179
- <p data-panel-summary />
180
- <div data-panel-formula hidden />
181
- <p data-panel-units hidden />
182
- <a data-panel-link>Open the full definition</a>
183
- </div>
184
- </HoverPanel>
225
+ <template data-notation-popup-template>
226
+ <aside
227
+ class="notation-popup"
228
+ role="region"
229
+ aria-atomic="true"
230
+ tabindex="-1"
231
+ >
232
+ <button
233
+ class="notation-popup-close"
234
+ type="button"
235
+ data-popup-close
236
+ aria-label="Close"
237
+ >
238
+ <span aria-hidden="true">&times;</span>
239
+ </button>
240
+ <div class="notation-panel-body">
241
+ <header class="notation-panel-heading">
242
+ <span class="notation-panel-symbol" data-slot-symbol />
243
+ <h3 data-slot-title />
244
+ </header>
245
+ <p data-slot-summary hidden />
246
+ <div data-slot-formula hidden />
247
+ <p class="notation-units" data-slot-units hidden />
248
+ <a data-slot-link hidden>
249
+ Open the full definition
250
+ </a>
251
+ </div>
252
+ </aside>
253
+ </template>
185
254
  </section>
186
255
  )
187
256
  }
188
257
 
189
258
  <script>
190
- import { createHoverPanel, closestElement } from '../hover-panel';
259
+ import { createPopupStack, closestElement } from '../popup-stack';
191
260
 
192
261
  const triggerSelector = '[data-notation-key]';
193
262
 
@@ -209,34 +278,24 @@ const panelId = `${id}-panel`;
209
278
  entries.delete('');
210
279
  if (entries.size === 0) return;
211
280
 
212
- const panel = root.querySelector<HTMLElement>('[data-notation-panel]');
213
- const panelSymbol = root.querySelector<HTMLElement>('[data-panel-symbol]');
214
- const panelTitle = root.querySelector<HTMLElement>('[data-panel-title]');
215
- const panelSummary = root.querySelector<HTMLElement>(
216
- '[data-panel-summary]',
281
+ const template = root.querySelector<HTMLTemplateElement>(
282
+ '[data-notation-popup-template]',
217
283
  );
218
- const panelFormula = root.querySelector<HTMLElement>(
219
- '[data-panel-formula]',
220
- );
221
- const panelUnits = root.querySelector<HTMLElement>('[data-panel-units]');
222
- const panelLink =
223
- root.querySelector<HTMLAnchorElement>('[data-panel-link]');
224
- if (
225
- !panel ||
226
- !panelSymbol ||
227
- !panelTitle ||
228
- !panelSummary ||
229
- !panelFormula ||
230
- !panelUnits ||
231
- !panelLink
232
- ) {
233
- return;
234
- }
284
+ if (!template) return;
235
285
 
236
286
  const controls = Array.from(
237
287
  root.querySelectorAll<HTMLButtonElement>('[data-notation-control]'),
238
288
  );
239
- const triggers = Array.from(
289
+
290
+ // Static triggers, computed once: markers embedded in prose or an
291
+ // equation, and the symbol glyphs in the "Notation used on this page"
292
+ // list. These get the always-on `.notation-trigger` cursor/hover
293
+ // treatment. Symbols that appear *inside a popup* (a card's own formula,
294
+ // D15) are not in this list — there's no bound on how many of those can
295
+ // exist — but they still work as triggers: `triggerFrom` below finds
296
+ // them live, and the shared `[data-notation-key]:hover` rule (declared
297
+ // `:global`, further down) already paints them the same way.
298
+ const externalTriggers = Array.from(
240
299
  document.querySelectorAll<HTMLElement>(triggerSelector),
241
300
  ).filter(
242
301
  (element) =>
@@ -244,17 +303,10 @@ const panelId = `${id}-panel`;
244
303
  !element.closest('.katex-mathml') &&
245
304
  entries.has(element.dataset.notationKey ?? ''),
246
305
  );
247
- const triggerSet = new Set(triggers);
248
-
249
- // Symbol glyphs rendered inside the page-notation list. They live within
250
- // `root`, so the external-marker filter above deliberately excludes them;
251
- // wire them as their own trigger set so pointing at a listed symbol opens
252
- // the same explanation as pointing at one embedded in prose or an equation.
253
306
  const symbolTriggers = Array.from(
254
307
  root.querySelectorAll<HTMLElement>('[data-notation-symbol-trigger]'),
255
308
  ).filter((element) => entries.has(element.dataset.notationKey ?? ''));
256
- const symbolTriggerSet = new Set(symbolTriggers);
257
- const highlightTargets = [...triggers, ...symbolTriggers];
309
+ const highlightTargets = [...externalTriggers, ...symbolTriggers];
258
310
 
259
311
  for (const trigger of highlightTargets) {
260
312
  trigger.classList.add('notation-trigger');
@@ -269,16 +321,6 @@ const panelId = `${id}-panel`;
269
321
  element?.dataset.notationLink ??
270
322
  null;
271
323
 
272
- const externalTriggerFrom = (node: EventTarget | null) => {
273
- const candidate = closestElement(node, triggerSelector);
274
- return candidate && triggerSet.has(candidate) ? candidate : null;
275
- };
276
-
277
- const symbolTriggerFrom = (node: EventTarget | null) => {
278
- const candidate = closestElement(node, '[data-notation-symbol-trigger]');
279
- return candidate && symbolTriggerSet.has(candidate) ? candidate : null;
280
- };
281
-
282
324
  const controlFrom = (node: EventTarget | null) => {
283
325
  const candidate = closestElement(node, '[data-notation-control]');
284
326
  return candidate && root.contains(candidate) ? candidate : null;
@@ -289,81 +331,125 @@ const panelId = `${id}-panel`;
289
331
  return candidate && root.contains(candidate) ? candidate : null;
290
332
  };
291
333
 
334
+ // A symbol trigger, wherever it lives: prose, the equation, the visible
335
+ // list, or — the D15 case this exists for — inside an already-open
336
+ // popup's own formula. Unlike the static sets above, this has to be
337
+ // live: a popup's content doesn't exist until the reader opens it.
338
+ const symbolFrom = (node: EventTarget | null) => {
339
+ const candidate = closestElement(node, triggerSelector);
340
+ if (!candidate || candidate.closest('.katex-mathml')) return null;
341
+ return entries.has(candidate.dataset.notationKey ?? '')
342
+ ? candidate
343
+ : null;
344
+ };
345
+
292
346
  const triggerFrom = (node: EventTarget | null) =>
293
- controlFrom(node) ??
294
- staticLinkFrom(node) ??
295
- externalTriggerFrom(node) ??
296
- symbolTriggerFrom(node);
347
+ controlFrom(node) ?? staticLinkFrom(node) ?? symbolFrom(node);
348
+
349
+ const labelOf = (entry: HTMLElement, key: string) =>
350
+ entry
351
+ .querySelector<HTMLElement>('[data-notation-link]')
352
+ ?.textContent?.trim() ?? key;
297
353
 
298
- const render = (_anchor: HTMLElement, key: string) => {
354
+ const createPopup = (key: string, _trigger: HTMLElement) => {
299
355
  const entry = entries.get(key);
300
- if (!entry) return false;
356
+ if (!entry) return null;
357
+
358
+ const fragment = template.content.cloneNode(true) as DocumentFragment;
359
+ const el = fragment.querySelector<HTMLElement>('.notation-popup');
360
+ const symbolHost =
361
+ fragment.querySelector<HTMLElement>('[data-slot-symbol]');
362
+ const title = fragment.querySelector<HTMLElement>('[data-slot-title]');
363
+ const summary = fragment.querySelector<HTMLElement>(
364
+ '[data-slot-summary]',
365
+ );
366
+ const formulaHost = fragment.querySelector<HTMLElement>(
367
+ '[data-slot-formula]',
368
+ );
369
+ const units = fragment.querySelector<HTMLElement>('[data-slot-units]');
370
+ const link =
371
+ fragment.querySelector<HTMLAnchorElement>('[data-slot-link]');
372
+ if (!el || !symbolHost || !title || !summary || !formulaHost || !units) {
373
+ return null;
374
+ }
375
+
376
+ el.setAttribute('aria-label', `Notation: ${labelOf(entry, key)}`);
301
377
 
302
378
  const sourceSymbol = entry.querySelector<HTMLElement>('.notation-symbol');
303
- panelSymbol.replaceChildren(
379
+ symbolHost.replaceChildren(
304
380
  ...(sourceSymbol
305
381
  ? [sourceSymbol.cloneNode(true)]
306
382
  : [document.createTextNode(entry.dataset.notationEntry ?? key)]),
307
383
  );
308
- panelTitle.textContent =
309
- entry
310
- .querySelector<HTMLAnchorElement>('[data-notation-link]')
311
- ?.textContent?.trim() ?? key;
312
- panelSummary.textContent =
384
+ title.textContent = labelOf(entry, key);
385
+ setOptionalText(
386
+ summary,
313
387
  entry
314
388
  .querySelector<HTMLElement>('[data-notation-summary]')
315
- ?.textContent?.trim() ?? '';
389
+ ?.textContent?.trim() ?? '',
390
+ );
316
391
  const sourceFormula = entry.querySelector<HTMLElement>(
317
392
  '[data-notation-formula]',
318
393
  );
319
- panelFormula.replaceChildren(
394
+ formulaHost.replaceChildren(
320
395
  ...(sourceFormula ? [sourceFormula.cloneNode(true)] : []),
321
396
  );
322
- panelFormula.hidden = sourceFormula === null;
397
+ formulaHost.hidden = sourceFormula === null;
323
398
  setOptionalText(
324
- panelUnits,
399
+ units,
325
400
  entry
326
401
  .querySelector<HTMLElement>('[data-notation-units]')
327
402
  ?.textContent?.trim() ?? '',
328
403
  );
329
- panelLink.href = entry.dataset.notationHref ?? `#notation-${key}`;
330
- return true;
404
+
405
+ // A gloss has no card to open. Its own popup is the whole of it.
406
+ const href = entry.dataset.notationHref;
407
+ if (link) {
408
+ link.hidden = href === undefined;
409
+ if (href !== undefined) link.href = href;
410
+ }
411
+
412
+ return el;
331
413
  };
332
414
 
333
- const updateControls = (pinnedKey: string | null, visible: boolean) => {
415
+ const updateControls = (
416
+ open: ReadonlySet<string>,
417
+ pinned: ReadonlySet<string>,
418
+ ) => {
334
419
  for (const control of controls) {
335
- const selected = control.dataset.notationControl === pinnedKey;
420
+ const key = control.dataset.notationControl ?? '';
421
+ const selected = pinned.has(key);
336
422
  control.setAttribute('aria-pressed', String(selected));
337
- control.setAttribute('aria-expanded', String(selected && visible));
423
+ control.setAttribute('aria-expanded', String(open.has(key)));
338
424
  control.textContent = selected
339
425
  ? 'Unpin explanation'
340
426
  : 'Pin explanation';
341
427
  }
342
428
  };
343
429
 
344
- createHoverPanel({
345
- panel,
346
- root,
430
+ createPopupStack({
431
+ // Popups are `position: fixed` and float above everything regardless
432
+ // of DOM placement — *except* z-index only ever wins within its own
433
+ // stacking context. Nesting them under the article risked landing
434
+ // inside a stacking context an ancestor happens to establish (found
435
+ // live: the Starlight right sidebar then ate their clicks even though
436
+ // its own z-index is far lower). Mounting on `<body>` sidesteps that
437
+ // entirely.
438
+ container: document.body,
439
+ groupId: 'notation',
347
440
  triggerFrom,
348
441
  keyFor,
349
- render,
442
+ createPopup,
350
443
  pinControlFrom: controlFrom,
351
- onShow: (key) => {
444
+ onChange: (open, pinned) => {
352
445
  for (const trigger of highlightTargets) {
353
446
  trigger.classList.toggle(
354
447
  'is-notation-active',
355
- trigger.dataset.notationKey === key,
448
+ open.has(trigger.dataset.notationKey ?? ''),
356
449
  );
357
450
  }
358
- panel.dataset.activeNotationKey = key;
359
- },
360
- onClose: () => {
361
- delete panel.dataset.activeNotationKey;
362
- for (const trigger of highlightTargets) {
363
- trigger.classList.remove('is-notation-active');
364
- }
451
+ updateControls(open, pinned);
365
452
  },
366
- onSync: ({ pinnedKey, visible }) => updateControls(pinnedKey, visible),
367
453
  });
368
454
  }
369
455
 
@@ -514,8 +600,67 @@ const panelId = `${id}-panel`;
514
600
  font-size: var(--sl-text-xs);
515
601
  }
516
602
 
517
- /* The fixed-position shell is `HoverPanel`; these rules style only the
518
- notation-owned fields slotted into it. */
603
+ /* Each open explanation is its own floating card — see `popup-stack.ts`.
604
+ They're mounted on `<body>`, not here (a stacking-context trap found
605
+ live: an ancestor of this section could otherwise cap how high a
606
+ popup's own z-index reaches). `.notation-panel-body` and its
607
+ descendants below style the fields common to both this static list
608
+ and a popup's cloned content. */
609
+ .notation-popup {
610
+ position: fixed;
611
+ z-index: 100;
612
+ margin: 0;
613
+ width: min(23rem, calc(100vw - 1.5rem));
614
+ overflow: auto;
615
+ padding: 1rem;
616
+ padding-inline-end: 2.4rem;
617
+ border: 1px solid var(--sl-color-gray-5);
618
+ border-top: 0.25rem solid var(--sl-color-accent);
619
+ border-radius: 0.65rem;
620
+ color: var(--sl-color-text);
621
+ background: var(--sl-color-bg);
622
+ box-shadow: var(--sl-shadow-lg);
623
+ font-size: var(--sl-text-sm);
624
+ }
625
+
626
+ .notation-popup-close {
627
+ position: absolute;
628
+ top: 0.5rem;
629
+ right: 0.5rem;
630
+ width: 1.6rem;
631
+ height: 1.6rem;
632
+ display: flex;
633
+ align-items: center;
634
+ justify-content: center;
635
+ padding: 0;
636
+ border: 0;
637
+ border-radius: 0.35rem;
638
+ color: var(--sl-color-gray-3);
639
+ background: transparent;
640
+ font-size: 1.15rem;
641
+ line-height: 1;
642
+ cursor: pointer;
643
+ }
644
+
645
+ .notation-popup-close:hover {
646
+ color: var(--sl-color-text);
647
+ background: var(--sl-color-gray-6);
648
+ }
649
+
650
+ /* Symbols inside a card's formula are click/hover-through triggers for
651
+ another card — the D15 case this whole file exists for. */
652
+ .notation-panel-body [data-slot-formula] :global([data-notation-key]) {
653
+ border-radius: 0.16em;
654
+ cursor: pointer;
655
+ }
656
+
657
+ .notation-panel-body
658
+ [data-slot-formula]
659
+ :global([data-notation-key]:hover) {
660
+ background: var(--sl-color-accent-low);
661
+ box-shadow: 0 0 0 0.12em var(--sl-color-accent-low);
662
+ }
663
+
519
664
  .notation-panel-heading {
520
665
  display: flex;
521
666
  align-items: baseline;
@@ -538,7 +683,7 @@ const panelId = `${id}-panel`;
538
683
  line-height: 1.5;
539
684
  }
540
685
 
541
- .notation-panel-body [data-panel-formula] {
686
+ .notation-panel-body [data-slot-formula] {
542
687
  overflow-x: auto;
543
688
  margin-block: 0.65rem;
544
689
  color: var(--sl-color-text);
@@ -1,5 +1,8 @@
1
1
  import rehypeKatex from 'rehype-katex';
2
2
  import { createNotationKatexOptions } from '../../reference/katex-options.mjs';
3
+ import { NOTATION_KEY_PATTERN } from '../../reference/remark-notation.mjs';
4
+ import { resolveMathGlyphs } from '../../reference/math-glyphs.mjs';
5
+ import type { GlyphScopeEntry } from '../../reference/gloss';
3
6
 
4
7
  interface HastText {
5
8
  type: 'text';
@@ -122,6 +125,20 @@ function safeProperties(
122
125
  continue;
123
126
  }
124
127
 
128
+ // The one marker attribute this renderer emits, and the only one KaTeX's
129
+ // trust callback lets through: `\explain{key}{latex}` expands to
130
+ // `\htmlData{notation-key=key}{latex}` so a resolved `formula` carries
131
+ // live, hoverable glyphs. Re-validate the key here rather than trusting
132
+ // the upstream check — this is the boundary that decides what reaches the
133
+ // page, and it must stay exactly as narrow as `trustNotationMarker`.
134
+ if (name === 'dataNotationKey') {
135
+ if (typeof value !== 'string' || !NOTATION_KEY_PATTERN.test(value)) {
136
+ throw new Error('Unsafe KaTeX notation key.');
137
+ }
138
+ properties['data-notation-key'] = value;
139
+ continue;
140
+ }
141
+
125
142
  if (name === 'encoding' && value === 'application/x-tex') {
126
143
  properties.encoding = value;
127
144
  continue;
@@ -221,3 +238,41 @@ export function renderNotationMath(latex: string): readonly SafeMathNode[] {
221
238
  transform(root as unknown as Parameters<typeof transform>[0], file);
222
239
  return Object.freeze(root.children.map(sanitizeNode));
223
240
  }
241
+
242
+ /**
243
+ * Render a notation entry's `formula` with its glyphs bound to keys, so every
244
+ * symbol in a rigorous definition is itself explainable (D15).
245
+ *
246
+ * `scope` is the entry-scoped table from `reference/gloss.ts` — the entry, its
247
+ * glosses, and the cards its formula names — not the registry-wide table a
248
+ * card's body math uses. Resolution is best-effort by design: an unresolved
249
+ * glyph renders inert rather than breaking the page, and is reported instead
250
+ * by the `formula` completeness gate (Tier 3) and by the CI test that holds
251
+ * the corpus at zero. A scope error degrades all the way to today's behaviour,
252
+ * an inert but correct equation.
253
+ */
254
+ export function renderNotationFormula(
255
+ formula: string,
256
+ scope: readonly GlyphScopeEntry[],
257
+ ): {
258
+ readonly nodes: readonly SafeMathNode[];
259
+ readonly unresolved: readonly string[];
260
+ } {
261
+ let bound = formula;
262
+ let unresolved: string[] = [];
263
+ try {
264
+ const result = resolveMathGlyphs(formula, [...scope]);
265
+ bound = result.latex;
266
+ unresolved = result.unresolved.map(({ token }: { token: string }) => token);
267
+ } catch {
268
+ bound = formula;
269
+ }
270
+
271
+ try {
272
+ return { nodes: renderNotationMath(bound), unresolved };
273
+ } catch {
274
+ // A marker that KaTeX or the sanitizer rejects must not cost the reader
275
+ // the equation itself.
276
+ return { nodes: renderNotationMath(formula), unresolved };
277
+ }
278
+ }