explico 0.4.0 → 0.5.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 (143) hide show
  1. package/CHANGELOG.md +273 -1
  2. package/README.md +103 -8
  3. package/dist/compiler/astro.d.ts +9 -1
  4. package/dist/compiler/astro.js +182 -3
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +28 -4
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +58 -1
  10. package/dist/compiler/course-config.js +28 -1
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/manifest.d.ts +90 -6
  14. package/dist/compiler/manifest.js +150 -17
  15. package/dist/compiler/resolution-report.d.ts +4 -0
  16. package/dist/reference/base-abbreviations.d.mts +30 -0
  17. package/dist/reference/base-abbreviations.mjs +60 -0
  18. package/dist/reference/base-operators.d.mts +15 -0
  19. package/dist/reference/base-operators.mjs +40 -0
  20. package/dist/reference/base-roman-labels.d.mts +35 -0
  21. package/dist/reference/base-roman-labels.mjs +43 -0
  22. package/dist/reference/consistency.d.ts +8 -2
  23. package/dist/reference/consistency.js +50 -3
  24. package/dist/reference/diagrams-validate.d.ts +21 -0
  25. package/dist/reference/diagrams-validate.js +13 -0
  26. package/dist/reference/equations.d.mts +10 -15
  27. package/dist/reference/equations.mjs +67 -14
  28. package/dist/reference/gate-abbreviations.d.ts +17 -0
  29. package/dist/reference/gate-abbreviations.js +64 -0
  30. package/dist/reference/gate-math.d.ts +28 -5
  31. package/dist/reference/gate-math.js +5 -2
  32. package/dist/reference/gate-prose-notation.d.ts +60 -0
  33. package/dist/reference/gate-prose-notation.js +125 -0
  34. package/dist/reference/keyed-float-validate.d.ts +5 -5
  35. package/dist/reference/keyed-jsx-floats.d.mts +7 -12
  36. package/dist/reference/keyed-jsx-floats.mjs +50 -25
  37. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  38. package/dist/reference/knowledge-checks-validate.js +78 -0
  39. package/dist/reference/knowledge-checks.d.mts +25 -0
  40. package/dist/reference/knowledge-checks.mjs +45 -0
  41. package/dist/reference/lab-math-scope.d.ts +9 -1
  42. package/dist/reference/lab-math-scope.js +32 -4
  43. package/dist/reference/math-glyphs.d.mts +30 -0
  44. package/dist/reference/math-glyphs.mjs +367 -0
  45. package/dist/reference/mdx-attributes.d.mts +13 -0
  46. package/dist/reference/mdx-attributes.mjs +38 -0
  47. package/dist/reference/mixed-content.d.ts +17 -0
  48. package/dist/reference/mixed-content.js +29 -0
  49. package/dist/reference/page-notation.d.ts +52 -0
  50. package/dist/reference/page-notation.js +171 -0
  51. package/dist/reference/raw-floats-validate.d.ts +18 -0
  52. package/dist/reference/raw-floats-validate.js +53 -0
  53. package/dist/reference/raw-floats.d.mts +18 -0
  54. package/dist/reference/raw-floats.mjs +129 -0
  55. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  56. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  57. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  58. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  59. package/dist/reference/remark-abbreviations.d.mts +7 -0
  60. package/dist/reference/remark-abbreviations.mjs +174 -0
  61. package/dist/reference/remark-memo.d.mts +16 -0
  62. package/dist/reference/remark-memo.mjs +339 -0
  63. package/dist/reference/remark-notation.d.mts +57 -9
  64. package/dist/reference/remark-notation.mjs +354 -42
  65. package/dist/reference/remark-short-note.d.mts +4 -0
  66. package/dist/reference/remark-short-note.mjs +237 -0
  67. package/dist/reference/types.d.ts +16 -1
  68. package/dist/reference/units-vocab.js +4 -0
  69. package/docs/VERSIONING.md +33 -4
  70. package/docs/authoring/README.md +8 -0
  71. package/docs/authoring/agents.md +17 -2
  72. package/docs/authoring/components.md +135 -0
  73. package/docs/authoring/migrating.md +92 -7
  74. package/docs/authoring/notation.md +560 -1
  75. package/docs/authoring/structure.md +120 -8
  76. package/docs/authoring/visualizations.md +36 -3
  77. package/package.json +5 -2
  78. package/src/compiler/astro.ts +208 -3
  79. package/src/compiler/cli.ts +29 -2
  80. package/src/compiler/collections.ts +45 -4
  81. package/src/compiler/course-config.ts +92 -0
  82. package/src/compiler/exports/standalone-html.ts +963 -0
  83. package/src/compiler/manifest.ts +268 -22
  84. package/src/components/HoverPanel.astro +45 -4
  85. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  86. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  87. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  88. package/src/components/assessments/AssessmentSet.astro +98 -14
  89. package/src/components/assessments/RenderedContent.tsx +55 -0
  90. package/src/components/assessments/assessment-randomization.ts +16 -0
  91. package/src/components/diagram/Diagram.astro +444 -83
  92. package/src/components/disclosure/hover-preview.ts +202 -0
  93. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  94. package/src/components/examples/CompactExamples.astro +2 -0
  95. package/src/components/figure/Figure.astro +279 -10
  96. package/src/components/memo/Memo.astro +453 -0
  97. package/src/components/memo/ShortNote.astro +223 -0
  98. package/src/components/notation/NotationFormula.astro +18 -1
  99. package/src/components/notation/NotationLayer.astro +105 -8
  100. package/src/components/notation/render-notation-math.ts +39 -2
  101. package/src/components/notation/safe-math-to-react.ts +50 -0
  102. package/src/components/reference/FloatRefLayer.astro +233 -15
  103. package/src/components/starlight/CollapsibleSections.astro +461 -0
  104. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  105. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  106. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  107. package/src/components/starlight/LayoutHeader.astro +50 -0
  108. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  109. package/src/components/starlight/LessonExportActions.astro +436 -0
  110. package/src/components/starlight/LessonFooter.astro +27 -2
  111. package/src/components/starlight/section-parts.ts +31 -0
  112. package/src/components/table/Table.astro +293 -7
  113. package/src/components/table/TableOverflowExpander.astro +489 -0
  114. package/src/content-config.ts +20 -0
  115. package/src/reference/base-abbreviations.mjs +60 -0
  116. package/src/reference/base-operators.mjs +40 -0
  117. package/src/reference/base-roman-labels.mjs +43 -0
  118. package/src/reference/consistency.ts +73 -2
  119. package/src/reference/diagrams-validate.ts +40 -0
  120. package/src/reference/equations.mjs +67 -14
  121. package/src/reference/gate-abbreviations.ts +128 -0
  122. package/src/reference/gate-math.ts +27 -7
  123. package/src/reference/gate-prose-notation.ts +224 -0
  124. package/src/reference/keyed-float-validate.ts +5 -5
  125. package/src/reference/keyed-jsx-floats.mjs +50 -25
  126. package/src/reference/knowledge-checks-validate.ts +130 -0
  127. package/src/reference/knowledge-checks.mjs +45 -0
  128. package/src/reference/lab-math-scope.ts +45 -3
  129. package/src/reference/math-glyphs.mjs +367 -0
  130. package/src/reference/mdx-attributes.mjs +38 -0
  131. package/src/reference/mixed-content.ts +69 -0
  132. package/src/reference/page-notation.ts +252 -0
  133. package/src/reference/raw-floats-validate.ts +103 -0
  134. package/src/reference/raw-floats.mjs +129 -0
  135. package/src/reference/rehype-heading-anchors.mjs +107 -1
  136. package/src/reference/rehype-table-header-labels.mjs +152 -0
  137. package/src/reference/remark-abbreviations.mjs +174 -0
  138. package/src/reference/remark-memo.mjs +339 -0
  139. package/src/reference/remark-notation.mjs +354 -42
  140. package/src/reference/remark-short-note.mjs +237 -0
  141. package/src/reference/types.ts +16 -0
  142. package/src/reference/units-vocab.ts +4 -0
  143. package/src/styles/global.css +996 -50
@@ -33,7 +33,11 @@
33
33
 
34
34
  export const NOTATION_KEY_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
35
35
 
36
- import { GlyphResolutionError, resolveMathGlyphs } from './math-glyphs.mjs';
36
+ import {
37
+ GlyphResolutionError,
38
+ resolveMathGlyphs,
39
+ stripNotationMarkers,
40
+ } from './math-glyphs.mjs';
37
41
  import {
38
42
  EQ_LABEL_PATTERN,
39
43
  collectDisplayEquations,
@@ -47,6 +51,12 @@ import {
47
51
  keyedFloatAnchorId,
48
52
  parseKeyedFloatRef,
49
53
  } from './keyed-jsx-floats.mjs';
54
+ import { collectRawFloats } from './raw-floats.mjs';
55
+ import {
56
+ knowledgeCheckAnchorId,
57
+ parseKnowledgeCheckRef,
58
+ } from './knowledge-checks.mjs';
59
+ import { injectMdxStringAttributes } from './mdx-attributes.mjs';
50
60
 
51
61
  const PROSE_SKIP_TYPES = new Set([
52
62
  'code',
@@ -313,13 +323,58 @@ function defaultHref(definition, key, base = '') {
313
323
  return `${base.replace(/\/$/, '')}/glossary/#notation-${key}`;
314
324
  }
315
325
 
326
+ /** A non-mdast hast element (a `<div>`, `<button>`, `<svg>`, …) built through
327
+ * the same `data.hName`/`hProperties` override `equationNode` already uses
328
+ * for its own wrapper — mdast-to-hast renders exactly what these say
329
+ * regardless of the carrier node's own `type`. */
330
+ function el(hName, hProperties, children = []) {
331
+ return { type: 'paragraph', data: { hName, hProperties }, children };
332
+ }
333
+
334
+ /** Feather/Lucide's "copy" glyph (two overlapping rounded rects), matching
335
+ * the stroke-icon-button style `LessonExportActions.astro` already uses for
336
+ * lesson-level actions. */
337
+ function copyIconNode() {
338
+ return el('svg', { viewBox: '0 0 24 24', 'aria-hidden': 'true' }, [
339
+ el('rect', { x: '9', y: '9', width: '10', height: '10', rx: '1' }),
340
+ el('path', {
341
+ d: 'M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1',
342
+ }),
343
+ ]);
344
+ }
345
+
346
+ /**
347
+ * Wrap an inline `$…$` `math` node in an inert `<span data-latex>` so a
348
+ * reader can trigger the same "click an unexplained part to copy" gesture
349
+ * a display equation gets (`KeyedFloatEnhancer.astro`), without a
350
+ * `.keyed-equation` block's room for a dedicated button — a persistent
351
+ * button here would break the reading line (see `global.css`, which gives
352
+ * this wrapper no visible styling of its own). `mathNode.value` is read
353
+ * after the resolver above has already bound the page's glyphs into it, same
354
+ * as `equationNode` below.
355
+ */
356
+ function inlineEquationNode(mathNode) {
357
+ const latex = stripNotationMarkers(String(mathNode.value ?? ''));
358
+ return el('span', { className: ['inline-equation'], 'data-latex': latex }, [
359
+ mathNode,
360
+ ]);
361
+ }
362
+
316
363
  /**
317
364
  * Wrap a display `$$…$$` `math` node so the container survives `rehype-katex`
318
365
  * (which splices the inner `math-display` element out). Every display equation
319
366
  * in a lesson is numbered; a keyed one anchors at `#eq-<key>`, an unkeyed one
320
367
  * at its positional `#eq-<section>-<index>`. The visible number is a focusable
321
368
  * `<a>` sibling — never authored, never inside KaTeX — so it survives print and
322
- * screen readers name it "Equation 2.4".
369
+ * screen readers name it "Equation 2.4". A "Copy LaTeX" button sits beside it
370
+ * (`.keyed-equation__controls`, styled in `global.css`) — the equation's own
371
+ * copy affordance, distinct from the notation popup card's (`NotationLayer.astro`),
372
+ * which only exists for a symbol with a registry entry, not a bare display
373
+ * equation. `mathNode.value` is read here — after the resolver above has
374
+ * already bound the page's glyphs into it (`replaceMathSource`) — so an
375
+ * auto-bound symbol's `\explain{…}` marker gets stripped same as an
376
+ * author-written one; either way the reader copies plain LaTeX, never this
377
+ * system's own notation-key plumbing (`math-glyphs.mjs:stripNotationMarkers`).
323
378
  */
324
379
  function equationNode(mathNode, { key, number }) {
325
380
  const id = equationAnchorId({ key, number });
@@ -331,31 +386,47 @@ function equationNode(mathNode, { key, number }) {
331
386
  if (key) hProperties['data-eq-key'] = key;
332
387
  else hProperties['data-eq-auto'] = '';
333
388
 
389
+ const latex = stripNotationMarkers(String(mathNode.value ?? ''));
390
+
334
391
  return {
335
392
  type: 'paragraph',
336
393
  data: { hName: 'div', hProperties },
337
394
  children: [
338
395
  mathNode,
339
- {
340
- type: 'link',
341
- url: `#${id}`,
342
- children: [{ type: 'text', value: `(${number})` }],
343
- data: {
344
- hProperties: {
345
- className: ['keyed-equation__number'],
346
- 'data-eq-number': number,
347
- 'aria-label': `Equation ${number}`,
348
- // Plain hover tooltip — the number is a permalink to this equation.
349
- title: 'Anchor to this equation',
396
+ el('div', { className: ['keyed-equation__controls'] }, [
397
+ {
398
+ type: 'link',
399
+ url: `#${id}`,
400
+ children: [{ type: 'text', value: `(${number})` }],
401
+ data: {
402
+ hProperties: {
403
+ className: ['keyed-equation__number'],
404
+ 'data-eq-number': number,
405
+ 'aria-label': `Equation ${number}`,
406
+ // Plain hover tooltip — the number is a permalink to this equation.
407
+ title: 'Anchor to this equation',
408
+ },
350
409
  },
351
410
  },
352
- },
411
+ el(
412
+ 'button',
413
+ {
414
+ type: 'button',
415
+ className: ['keyed-equation__copy'],
416
+ 'data-eq-copy': '',
417
+ 'data-latex': latex,
418
+ 'aria-label': `Copy LaTeX for Equation ${number}`,
419
+ title: 'Copy LaTeX',
420
+ },
421
+ [copyIconNode()],
422
+ ),
423
+ ]),
353
424
  ],
354
425
  };
355
426
  }
356
427
 
357
428
  /**
358
- * Give a `<Table>`/`<Figure>` `mdxJsxFlowElement` its computed `number` and
429
+ * Give a `<Table>`/`<Figure>`/`<Diagram>` `mdxJsxFlowElement` its computed `number` and
359
430
  * `anchorId` as real JSX attributes, so the actual Astro component (which
360
431
  * renders the numbered anchor, unlike `equationNode`'s synthesized hast div)
361
432
  * receives them as props. The compiler is the single source of truth for
@@ -367,17 +438,10 @@ function injectFloatNumberAttributes(node, float, prefix) {
367
438
  number: float.number,
368
439
  prefix,
369
440
  });
370
- node.attributes = (node.attributes ?? []).filter(
371
- (attribute) =>
372
- !(
373
- attribute?.type === 'mdxJsxAttribute' &&
374
- (attribute.name === 'number' || attribute.name === 'anchorId')
375
- ),
376
- );
377
- node.attributes.push(
378
- { type: 'mdxJsxAttribute', name: 'number', value: float.number },
379
- { type: 'mdxJsxAttribute', name: 'anchorId', value: anchorId },
380
- );
441
+ injectMdxStringAttributes(node, {
442
+ number: float.number,
443
+ anchorId,
444
+ });
381
445
  }
382
446
 
383
447
  /** Resolve a `[[eq-key]]` / `[[slug#eq-key]]` reference to `{ number, href }`. */
@@ -409,7 +473,7 @@ function resolveEquationRef(eqRef, eqCtx, file, node) {
409
473
 
410
474
  /**
411
475
  * Resolve a `[[<prefix>-key]]` / `[[slug#<prefix>-key]]` reference (a
412
- * `<Table>`/`<Figure>` float) to `{ number, href }` — the table/figure
476
+ * `<Table>`/`<Figure>`/`<Diagram>` float) to `{ number, href }` — the keyed-float
413
477
  * equivalent of `resolveEquationRef`.
414
478
  */
415
479
  function resolveKeyedFloatRef(floatRef, ctx, { prefix, tagName }, file, node) {
@@ -438,6 +502,33 @@ function resolveKeyedFloatRef(floatRef, ctx, { prefix, tagName }, file, node) {
438
502
  return { number, href: `#${prefix}-${floatRef.key}` };
439
503
  }
440
504
 
505
+ /** Resolve a numbered knowledge check declared by a lesson's `.checks.yml`. */
506
+ function resolveKnowledgeCheckRef(checkRef, ctx, file, node) {
507
+ if (checkRef.lesson) {
508
+ const number = ctx.crossPage?.[checkRef.lesson]?.[checkRef.key];
509
+ if (number === undefined) {
510
+ fail(
511
+ file,
512
+ `Unknown knowledge-check reference [[${checkRef.lesson}#check-${checkRef.key}]]: that lesson's .checks.yml does not list "${checkRef.key}".`,
513
+ node,
514
+ );
515
+ }
516
+ return {
517
+ number,
518
+ href: `${ctx.base.replace(/\/$/, '')}/${checkRef.lesson}/#${knowledgeCheckAnchorId(checkRef.key)}`,
519
+ };
520
+ }
521
+ const number = ctx.pageLabels.get(checkRef.key);
522
+ if (number === undefined) {
523
+ fail(
524
+ file,
525
+ `Unknown knowledge-check reference [[check-${checkRef.key}]]: this lesson's .checks.yml does not list "${checkRef.key}".`,
526
+ node,
527
+ );
528
+ }
529
+ return { number, href: `#${knowledgeCheckAnchorId(checkRef.key)}` };
530
+ }
531
+
441
532
  function proseNodes(
442
533
  value,
443
534
  resolve,
@@ -447,6 +538,8 @@ function proseNodes(
447
538
  eqCtx,
448
539
  tableCtx,
449
540
  figureCtx,
541
+ diagramCtx,
542
+ checkCtx,
450
543
  ) {
451
544
  const base = eqCtx?.base ?? '';
452
545
  const strayExplain = firstUnescaped(value, EXPLAIN);
@@ -474,10 +567,20 @@ function proseNodes(
474
567
  !eqRef && !tableRef && figureCtx
475
568
  ? parseKeyedFloatRef(key, { prefix: 'fig' })
476
569
  : undefined;
570
+ const diagramRef =
571
+ !eqRef && !tableRef && !figureRef && diagramCtx
572
+ ? parseKeyedFloatRef(key, { prefix: 'dia' })
573
+ : undefined;
574
+ const checkRef =
575
+ !eqRef && !tableRef && !figureRef && !diagramRef && checkCtx
576
+ ? parseKnowledgeCheckRef(key)
577
+ : undefined;
477
578
  if (
478
579
  !eqRef &&
479
580
  !tableRef &&
480
581
  !figureRef &&
582
+ !diagramRef &&
583
+ !checkRef &&
481
584
  !NOTATION_KEY_PATTERN.test(key)
482
585
  ) {
483
586
  fail(file, `Invalid notation key "${key}" in [[...]].`, node);
@@ -510,14 +613,44 @@ function proseNodes(
510
613
  continue;
511
614
  }
512
615
 
513
- const floatRef = tableRef ?? figureRef;
616
+ if (checkRef) {
617
+ const { number, href } = resolveKnowledgeCheckRef(
618
+ checkRef,
619
+ checkCtx,
620
+ file,
621
+ node,
622
+ );
623
+ output.push({
624
+ type: 'link',
625
+ url: href,
626
+ children: [{ type: 'text', value: `Knowledge check ${number}` }],
627
+ data: {
628
+ hProperties: {
629
+ className: ['knowledge-check-ref'],
630
+ 'data-check-ref': checkRef.key,
631
+ ...(checkRef.lesson
632
+ ? { 'data-check-lesson': checkRef.lesson }
633
+ : {}),
634
+ },
635
+ },
636
+ });
637
+ references.push({
638
+ key: checkRef.key,
639
+ kind: 'knowledge-check',
640
+ ...(checkRef.lesson ? { lesson: checkRef.lesson } : {}),
641
+ });
642
+ cursor = match.index + match[0].length;
643
+ continue;
644
+ }
645
+
646
+ const floatRef = tableRef ?? figureRef ?? diagramRef;
514
647
  if (floatRef) {
515
- const kind = tableRef ? 'table' : 'figure';
516
- const prefix = tableRef ? 'tab' : 'fig';
517
- const tagName = tableRef ? 'Table' : 'Figure';
648
+ const kind = tableRef ? 'table' : figureRef ? 'figure' : 'diagram';
649
+ const prefix = tableRef ? 'tab' : figureRef ? 'fig' : 'dia';
650
+ const tagName = tableRef ? 'Table' : figureRef ? 'Figure' : 'Diagram';
518
651
  const { number, href } = resolveKeyedFloatRef(
519
652
  floatRef,
520
- tableRef ? tableCtx : figureCtx,
653
+ tableRef ? tableCtx : figureRef ? figureCtx : diagramCtx,
521
654
  { prefix, tagName },
522
655
  file,
523
656
  node,
@@ -676,14 +809,16 @@ function inspectExplainCalls(value, resolve, file, node, references) {
676
809
  *
677
810
  * Every number on a page comes from two places that MUST agree: the page's own
678
811
  * counters (`numbering`) and the manifest tables a cross-page `[[slug#eq-key]]`
679
- * is resolved against (`equations` / `tables` / `figures`). Deriving all four
680
- * from the same manifest in one step is what stops a course config from
812
+ * is resolved against (`equations` / `tables` / `figures` / `diagrams` /
813
+ * `checks`).
814
+ * Deriving every input from the same manifest in one step stops a course config from
681
815
  * supplying one without the other — the omission that produced page-local
682
816
  * `(2.2)` and cross-page `(4.5)` for the same equation.
683
817
  *
684
- * `numbering` maps a lesson slug to `{ part, equations, tables, figures }`:
685
- * the part's ordinal (the number before the dot) and, per kind of float, how
686
- * many of them the earlier lessons of that part already used.
818
+ * `numbering` maps a lesson slug to
819
+ * `{ part, equations, tables, figures, diagrams, checks }`: the part's ordinal
820
+ * (the number before the dot) and, per numbered kind, how many earlier lessons
821
+ * of that part already used.
687
822
  */
688
823
  export function numberingFromManifest(manifest) {
689
824
  if (!manifest || typeof manifest !== 'object') return {};
@@ -691,6 +826,8 @@ export function numberingFromManifest(manifest) {
691
826
  equations: manifest.equations?.numbersBySlug ?? {},
692
827
  tables: manifest.tables?.numbersBySlug ?? {},
693
828
  figures: manifest.figures?.numbersBySlug ?? {},
829
+ diagrams: manifest.diagrams?.numbersBySlug ?? {},
830
+ checks: manifest.checks?.numbersBySlug ?? {},
694
831
  numbering: Object.fromEntries(
695
832
  (manifest.lessons ?? []).map((lesson) => [
696
833
  lesson.slug,
@@ -699,12 +836,44 @@ export function numberingFromManifest(manifest) {
699
836
  equations: lesson.numbering?.equations ?? 0,
700
837
  tables: lesson.numbering?.tables ?? 0,
701
838
  figures: lesson.numbering?.figures ?? 0,
839
+ diagrams: lesson.numbering?.diagrams ?? 0,
840
+ checks: lesson.numbering?.checks ?? 0,
702
841
  },
703
842
  ]),
704
843
  ),
705
844
  };
706
845
  }
707
846
 
847
+ /**
848
+ * The options object `remarkNotation` needs for one manifest, in one call.
849
+ *
850
+ * A consumer used to hand-assemble `definitions`, `equations`, `tables`,
851
+ * `figures`, and `chapters` separately — easy to do partially, which is how a
852
+ * page ends up numbered under the legacy `##`-section scheme while the
853
+ * manifest (and every cross-page reference to that page) uses the part
854
+ * scheme. This does not return that literal four-map-plus-chapters shape:
855
+ * `chapters` is the superseded per-lesson form (see `pageNumbering` below),
856
+ * and passing `manifest` through already makes `numberingFromManifest()`
857
+ * derive `numbering` plus every `equations`/`tables`/`figures`/`diagrams`/
858
+ * `checks` map from the same source in one step — strictly more correct than
859
+ * reconstructing `chapters` by hand.
860
+ *
861
+ * `overrides` is spread last, so a caller can add `strict`, `unknown`,
862
+ * `resolve`, `base`, or override anything above:
863
+ *
864
+ * [remarkNotation, createRemarkNotationOptions(manifest, { strict: true })]
865
+ *
866
+ * @param {object} manifest - a compiled Explico manifest (`compileManifest()`).
867
+ * @param {object} [overrides] - additional/overriding `remarkNotation` options.
868
+ */
869
+ export function createRemarkNotationOptions(manifest, overrides = {}) {
870
+ return {
871
+ definitions: manifest?.notation?.raw ?? [],
872
+ manifest,
873
+ ...overrides,
874
+ };
875
+ }
876
+
708
877
  /**
709
878
  * `{ prefix, offset }` for one kind of float on one page, from whichever
710
879
  * numbering option the caller supplied. `numbering` is the current form;
@@ -773,31 +942,71 @@ function assertManifestNumberParity({
773
942
  }
774
943
  }
775
944
 
945
+ /**
946
+ * Strict-mode guard: a catalogued lesson (one `remarkNotation` is told the
947
+ * slug of) that contains at least one equation/`<Table>`/`<Figure>` — keyed or
948
+ * not — must have both a `numbering` placement and a cross-page number table
949
+ * entry. Without this, `assertManifestNumberParity` above only ever catches a
950
+ * missing/wrong map for *keyed* items (it walks `pageLabels`, which unkeyed
951
+ * equations/floats never enter — see `keyed-jsx-floats.mjs`), so a lesson
952
+ * whose only floats are unkeyed can silently fall back to `##`-section
953
+ * numbering with no diagnostic at all. `Diagram` is deliberately excluded by
954
+ * every caller: `Diagram.astro` already tolerates an unplaced diagram
955
+ * rendering without a number, by design.
956
+ */
957
+ function assertStrictNumbering({ file, slug, noun, count, placement, crossPage }) {
958
+ if (!slug || count === 0) return;
959
+ const plural = count === 1 ? '' : 's';
960
+ if (placement === undefined) {
961
+ fail(
962
+ file,
963
+ `Strict mode: ${slug} has ${count} ${noun}${plural} but remarkNotation ` +
964
+ `has no \`numbering\` entry for this lesson, so it would silently ` +
965
+ `fall back to the legacy ##-section numbering. Pass \`manifest\` ` +
966
+ `(e.g. via createRemarkNotationOptions) or add a \`numbering\` entry ` +
967
+ `for "${slug}".`,
968
+ );
969
+ return;
970
+ }
971
+ if (crossPage?.[slug] === undefined) {
972
+ fail(
973
+ file,
974
+ `Strict mode: ${slug} has ${count} ${noun}${plural} but remarkNotation ` +
975
+ `has no cross-page ${noun} number table entry for this lesson. Pass ` +
976
+ `\`manifest\` (e.g. via createRemarkNotationOptions) so every ` +
977
+ `catalogued lesson is covered.`,
978
+ );
979
+ }
980
+ }
981
+
776
982
  /**
777
983
  * @param {{
778
984
  * definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>),
779
985
  * resolve?: (key: string, context: object) => object | undefined,
780
986
  * unknown?: 'error' | 'warn' | 'ignore',
987
+ * strict?: boolean,
781
988
  * base?: string,
782
989
  * manifest?: object,
783
990
  * equations?: Record<string, Record<string, string>>,
784
991
  * tables?: Record<string, Record<string, string>>,
785
992
  * figures?: Record<string, Record<string, string>>,
786
- * numbering?: Record<string, { part: number, equations: number, tables: number, figures: number }>,
993
+ * diagrams?: Record<string, Record<string, string>>,
994
+ * checks?: Record<string, Record<string, string>>,
995
+ * numbering?: Record<string, { part: number, equations: number, tables: number, figures: number, diagrams: number, checks: number }>,
787
996
  * chapters?: Record<string, number>,
788
997
  * }} [options]
789
998
  *
790
- * `manifest` is the preferred wiring: pass the compiled manifest and the four
999
+ * `manifest` is the preferred wiring: pass the compiled manifest and all
791
1000
  * numbering inputs below are read off it together, so they cannot drift apart.
792
1001
  * They stay available individually for hand-built trees, and an explicitly
793
1002
  * supplied one still wins over the manifest.
794
1003
  *
795
1004
  * `numbering` maps a lesson slug to its **part** (the number before the dot)
796
1005
  * and, per kind of float, how many the earlier lessons of that part already
797
- * used. Every display equation / `<Table>` / `<Figure>` on the page is then
1006
+ * used. Every display equation / `<Table>` / `<Figure>` / `<Diagram>` on the page is then
798
1007
  * numbered `‹part›.‹offset + n›`, with `##` and `###` headings ignored; with no
799
1008
  * entry the legacy `##`-section scheme applies. It must agree with
800
- * `manifest.lessons[]`, which is what feeds `equations`/`tables`/`figures`
1009
+ * `manifest.lessons[]`, which is what feeds `equations`/`tables`/`figures`/`diagrams`
801
1010
  * above — supplying the tables without `numbering` numbers the same equation
802
1011
  * twice, and `assertManifestNumberParity` turns that into a build error rather
803
1012
  * than a page whose references disagree with their targets.
@@ -805,9 +1014,18 @@ function assertManifestNumberParity({
805
1014
  * `chapters` is the superseded per-lesson form (slug → the lesson's course
806
1015
  * ordinal, no running offset). Still read when `numbering` has no entry, so a
807
1016
  * config written against the old option keeps building.
1017
+ *
1018
+ * `strict` (default `false`) additionally fails the build when a catalogued
1019
+ * lesson contains an equation/`<Table>`/`<Figure>` — keyed or not — but has no
1020
+ * `numbering` placement or no cross-page number table entry, instead of
1021
+ * silently rendering it under the legacy `##`-section scheme. Off by default
1022
+ * for backward compatibility; `createRemarkNotationOptions(manifest, { strict:
1023
+ * true })` is the recommended way to turn it on for a course whose manifest
1024
+ * covers every catalogued lesson.
808
1025
  */
809
1026
  export default function remarkNotation(options = {}) {
810
1027
  const unknown = options.unknown ?? 'error';
1028
+ const strict = options.strict ?? false;
811
1029
  // The manifest supplies every numbering input at once; an explicit option
812
1030
  // still overrides it, so existing per-field wiring keeps working.
813
1031
  const numbering = { ...numberingFromManifest(options.manifest), ...options };
@@ -878,8 +1096,18 @@ export default function remarkNotation(options = {}) {
878
1096
  const equationByNode = new Map(
879
1097
  displayEquations.map((equation) => [equation.node, equation]),
880
1098
  );
1099
+ if (strict) {
1100
+ assertStrictNumbering({
1101
+ file,
1102
+ slug: lessonSlug,
1103
+ noun: 'equation',
1104
+ count: displayEquations.length,
1105
+ placement: equationNumbering,
1106
+ crossPage: numbering.equations,
1107
+ });
1108
+ }
881
1109
 
882
- // Keyed floats (F-tables-figures): every `<Table>`/`<Figure>` on a lesson
1110
+ // Keyed floats: every `<Table>`/`<Figure>`/`<Diagram>` on a lesson
883
1111
  // page is numbered and anchored, keyed or not — same rule as equations,
884
1112
  // via the shared engine in `keyed-jsx-floats.mjs`.
885
1113
  const makeFloatCtx = (tagName, crossPage, kind) => {
@@ -908,6 +1136,17 @@ export default function remarkNotation(options = {}) {
908
1136
  noun: tagName,
909
1137
  syntax: (key) => `<${tagName} id="${key}">`,
910
1138
  });
1139
+ // Diagram is deliberately excluded — see `assertStrictNumbering`'s doc.
1140
+ if (strict && tagName !== 'Diagram') {
1141
+ assertStrictNumbering({
1142
+ file,
1143
+ slug: lessonSlug,
1144
+ noun: tagName,
1145
+ count: floats.length,
1146
+ placement: floatNumbering,
1147
+ crossPage,
1148
+ });
1149
+ }
911
1150
  return {
912
1151
  ctx: {
913
1152
  base: options.base ?? '',
@@ -927,6 +1166,18 @@ export default function remarkNotation(options = {}) {
927
1166
  numbering.figures,
928
1167
  'figures',
929
1168
  );
1169
+ const { ctx: diagramCtx, byNode: diagramByNode } = makeFloatCtx(
1170
+ 'Diagram',
1171
+ numbering.diagrams,
1172
+ 'diagrams',
1173
+ );
1174
+ const checkCtx = {
1175
+ base: options.base ?? '',
1176
+ pageLabels: new Map(
1177
+ Object.entries(numbering.checks?.[lessonSlug] ?? {}),
1178
+ ),
1179
+ crossPage: numbering.checks ?? {},
1180
+ };
930
1181
 
931
1182
  const resolve = (key, kind, node) => {
932
1183
  const context = {
@@ -1048,6 +1299,8 @@ export default function remarkNotation(options = {}) {
1048
1299
  eqCtx,
1049
1300
  tableCtx,
1050
1301
  figureCtx,
1302
+ diagramCtx,
1303
+ checkCtx,
1051
1304
  );
1052
1305
  if (replacement) {
1053
1306
  node.children.splice(index, 1, ...replacement);
@@ -1058,14 +1311,72 @@ export default function remarkNotation(options = {}) {
1058
1311
  visit(child, false);
1059
1312
  if (child && child.type === 'math' && equationByNode.has(child)) {
1060
1313
  node.children[index] = equationNode(child, equationByNode.get(child));
1314
+ } else if (lessonId && child?.type === 'inlineMath') {
1315
+ // Scoped to lesson pages, same as `displayEquations` above — a
1316
+ // registry/glossary page's inline math is left exactly as before.
1317
+ node.children[index] = inlineEquationNode(child);
1061
1318
  } else if (child?.type === 'mdxJsxFlowElement' && tableByNode.has(child)) {
1062
1319
  injectFloatNumberAttributes(child, tableByNode.get(child), 'tab');
1063
1320
  } else if (child?.type === 'mdxJsxFlowElement' && figureByNode.has(child)) {
1064
1321
  injectFloatNumberAttributes(child, figureByNode.get(child), 'fig');
1322
+ } else if (child?.type === 'mdxJsxFlowElement' && diagramByNode.has(child)) {
1323
+ injectFloatNumberAttributes(child, diagramByNode.get(child), 'dia');
1065
1324
  }
1066
1325
  }
1067
1326
  };
1068
1327
 
1328
+ // Render-time defense in depth for content invisible to keyed-float
1329
+ // validation — the manifest builder runs the same check via
1330
+ // `validateRawFloats`/`scanRawFloats` (raw-floats.mjs); this catches
1331
+ // content added since the manifest was last compiled.
1332
+ if (lessonId) {
1333
+ const rawFloats = collectRawFloats(tree);
1334
+ const plural = (count, noun) => (count === 1 ? noun : `${noun}s`);
1335
+ if (rawFloats.markdownTableNodes.length > 0) {
1336
+ warn(
1337
+ file,
1338
+ `${lessonSlug} has ${rawFloats.markdownTableNodes.length} raw ` +
1339
+ `Markdown ${plural(rawFloats.markdownTableNodes.length, 'table')} ` +
1340
+ `not wrapped in <Table>; it has no stable number and cannot be ` +
1341
+ `referenced with [[tab-…]]. Wrap it in <Table id="…">…</Table> ` +
1342
+ `to give it one.`,
1343
+ rawFloats.markdownTableNodes[0],
1344
+ );
1345
+ }
1346
+ if (rawFloats.htmlTableNodes.length > 0) {
1347
+ warn(
1348
+ file,
1349
+ `${lessonSlug} has ${rawFloats.htmlTableNodes.length} raw HTML ` +
1350
+ `<table> ${plural(rawFloats.htmlTableNodes.length, 'element')} ` +
1351
+ `not wrapped in <Table>; it has no stable number and cannot be ` +
1352
+ `referenced with [[tab-…]]. Wrap it in <Table id="…">…</Table> ` +
1353
+ `to give it one.`,
1354
+ rawFloats.htmlTableNodes[0],
1355
+ );
1356
+ }
1357
+ if (rawFloats.unkeyedDiagramNodes.length > 0) {
1358
+ warn(
1359
+ file,
1360
+ `${lessonSlug} has ${rawFloats.unkeyedDiagramNodes.length} ` +
1361
+ `<Diagram> ${plural(rawFloats.unkeyedDiagramNodes.length, 'component')} ` +
1362
+ `with no id; it cannot be referenced with [[dia-…]]. Add an id ` +
1363
+ `to give it a stable reference.`,
1364
+ rawFloats.unkeyedDiagramNodes[0],
1365
+ );
1366
+ }
1367
+ if (rawFloats.nestedDiagramNodes.length > 0) {
1368
+ warn(
1369
+ file,
1370
+ `${lessonSlug} has ${rawFloats.nestedDiagramNodes.length} ` +
1371
+ `<Diagram> ${plural(rawFloats.nestedDiagramNodes.length, 'component')} ` +
1372
+ `nested inside another component; it is invisible to diagram ` +
1373
+ `numbering. Move it to the top level (or inside ` +
1374
+ `<Memo>/<ShortNote>), or wrap it in <Figure> to give it identity.`,
1375
+ rawFloats.nestedDiagramNodes[0],
1376
+ );
1377
+ }
1378
+ }
1379
+
1069
1380
  visit(tree);
1070
1381
 
1071
1382
  file.data.notationReferences = references;
@@ -1089,5 +1400,6 @@ export default function remarkNotation(options = {}) {
1089
1400
  }));
1090
1401
  file.data.tableLabels = floatLabelData(tableByNode);
1091
1402
  file.data.figureLabels = floatLabelData(figureByNode);
1403
+ file.data.diagramLabels = floatLabelData(diagramByNode);
1092
1404
  };
1093
1405
  }