explico 0.3.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 (151) hide show
  1. package/CHANGELOG.md +344 -1
  2. package/README.md +117 -3
  3. package/dist/compiler/astro.d.ts +38 -0
  4. package/dist/compiler/astro.js +258 -0
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +32 -12
  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 +86 -4
  10. package/dist/compiler/course-config.js +39 -0
  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/lesson-derivation.d.ts +1 -1
  14. package/dist/compiler/lesson-derivation.js +2 -2
  15. package/dist/compiler/manifest.d.ts +112 -7
  16. package/dist/compiler/manifest.js +205 -40
  17. package/dist/compiler/resolution-report.d.ts +4 -0
  18. package/dist/reference/base-abbreviations.d.mts +30 -0
  19. package/dist/reference/base-abbreviations.mjs +60 -0
  20. package/dist/reference/base-operators.d.mts +15 -0
  21. package/dist/reference/base-operators.mjs +40 -0
  22. package/dist/reference/base-roman-labels.d.mts +35 -0
  23. package/dist/reference/base-roman-labels.mjs +43 -0
  24. package/dist/reference/consistency.d.ts +8 -2
  25. package/dist/reference/consistency.js +50 -3
  26. package/dist/reference/diagrams-validate.d.ts +21 -0
  27. package/dist/reference/diagrams-validate.js +13 -0
  28. package/dist/reference/equations-validate.d.ts +8 -3
  29. package/dist/reference/equations-validate.js +1 -1
  30. package/dist/reference/equations.d.mts +66 -35
  31. package/dist/reference/equations.mjs +142 -40
  32. package/dist/reference/gate-abbreviations.d.ts +17 -0
  33. package/dist/reference/gate-abbreviations.js +64 -0
  34. package/dist/reference/gate-math.d.ts +28 -5
  35. package/dist/reference/gate-math.js +5 -2
  36. package/dist/reference/gate-prose-notation.d.ts +60 -0
  37. package/dist/reference/gate-prose-notation.js +125 -0
  38. package/dist/reference/keyed-float-validate.d.ts +14 -8
  39. package/dist/reference/keyed-float-validate.js +1 -1
  40. package/dist/reference/keyed-jsx-floats.d.mts +24 -21
  41. package/dist/reference/keyed-jsx-floats.mjs +90 -41
  42. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  43. package/dist/reference/knowledge-checks-validate.js +78 -0
  44. package/dist/reference/knowledge-checks.d.mts +25 -0
  45. package/dist/reference/knowledge-checks.mjs +45 -0
  46. package/dist/reference/lab-math-scope.d.ts +9 -1
  47. package/dist/reference/lab-math-scope.js +32 -4
  48. package/dist/reference/math-glyphs.d.mts +30 -0
  49. package/dist/reference/math-glyphs.mjs +367 -0
  50. package/dist/reference/mdx-attributes.d.mts +13 -0
  51. package/dist/reference/mdx-attributes.mjs +38 -0
  52. package/dist/reference/mixed-content.d.ts +17 -0
  53. package/dist/reference/mixed-content.js +29 -0
  54. package/dist/reference/page-notation.d.ts +52 -0
  55. package/dist/reference/page-notation.js +171 -0
  56. package/dist/reference/raw-floats-validate.d.ts +18 -0
  57. package/dist/reference/raw-floats-validate.js +53 -0
  58. package/dist/reference/raw-floats.d.mts +18 -0
  59. package/dist/reference/raw-floats.mjs +129 -0
  60. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  61. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  62. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  63. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  64. package/dist/reference/remark-abbreviations.d.mts +7 -0
  65. package/dist/reference/remark-abbreviations.mjs +174 -0
  66. package/dist/reference/remark-memo.d.mts +16 -0
  67. package/dist/reference/remark-memo.mjs +339 -0
  68. package/dist/reference/remark-notation.d.mts +102 -5
  69. package/dist/reference/remark-notation.mjs +506 -50
  70. package/dist/reference/remark-short-note.d.mts +4 -0
  71. package/dist/reference/remark-short-note.mjs +237 -0
  72. package/dist/reference/types.d.ts +16 -1
  73. package/dist/reference/units-vocab.js +4 -0
  74. package/docs/VERSIONING.md +33 -4
  75. package/docs/authoring/README.md +12 -0
  76. package/docs/authoring/agents.md +17 -2
  77. package/docs/authoring/components.md +135 -0
  78. package/docs/authoring/migrating.md +154 -7
  79. package/docs/authoring/notation.md +568 -8
  80. package/docs/authoring/structure.md +251 -0
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +332 -0
  84. package/src/compiler/cli.ts +33 -10
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +131 -3
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/lesson-derivation.ts +2 -2
  89. package/src/compiler/manifest.ts +367 -49
  90. package/src/components/HoverPanel.astro +45 -4
  91. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  92. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  93. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  94. package/src/components/assessments/AssessmentSet.astro +98 -14
  95. package/src/components/assessments/RenderedContent.tsx +55 -0
  96. package/src/components/assessments/assessment-randomization.ts +16 -0
  97. package/src/components/diagram/Diagram.astro +444 -83
  98. package/src/components/disclosure/hover-preview.ts +202 -0
  99. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  100. package/src/components/examples/CompactExamples.astro +2 -0
  101. package/src/components/figure/Figure.astro +279 -10
  102. package/src/components/memo/Memo.astro +453 -0
  103. package/src/components/memo/ShortNote.astro +223 -0
  104. package/src/components/notation/NotationFormula.astro +18 -1
  105. package/src/components/notation/NotationLayer.astro +116 -8
  106. package/src/components/notation/render-notation-math.ts +39 -2
  107. package/src/components/notation/safe-math-to-react.ts +50 -0
  108. package/src/components/reference/FloatRefLayer.astro +233 -15
  109. package/src/components/starlight/CollapsibleSections.astro +461 -0
  110. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  111. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  112. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  113. package/src/components/starlight/LayoutHeader.astro +50 -0
  114. package/src/components/starlight/LayoutSidebar.astro +62 -1
  115. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  116. package/src/components/starlight/LessonExportActions.astro +436 -0
  117. package/src/components/starlight/LessonFooter.astro +38 -2
  118. package/src/components/starlight/section-parts.ts +31 -0
  119. package/src/components/table/Table.astro +296 -9
  120. package/src/components/table/TableOverflowExpander.astro +489 -0
  121. package/src/content-config.ts +20 -0
  122. package/src/reference/base-abbreviations.mjs +60 -0
  123. package/src/reference/base-operators.mjs +40 -0
  124. package/src/reference/base-roman-labels.mjs +43 -0
  125. package/src/reference/consistency.ts +73 -2
  126. package/src/reference/diagrams-validate.ts +40 -0
  127. package/src/reference/equations-validate.ts +6 -4
  128. package/src/reference/equations.mjs +142 -40
  129. package/src/reference/gate-abbreviations.ts +128 -0
  130. package/src/reference/gate-math.ts +27 -7
  131. package/src/reference/gate-prose-notation.ts +224 -0
  132. package/src/reference/keyed-float-validate.ts +12 -9
  133. package/src/reference/keyed-jsx-floats.mjs +90 -41
  134. package/src/reference/knowledge-checks-validate.ts +130 -0
  135. package/src/reference/knowledge-checks.mjs +45 -0
  136. package/src/reference/lab-math-scope.ts +45 -3
  137. package/src/reference/math-glyphs.mjs +367 -0
  138. package/src/reference/mdx-attributes.mjs +38 -0
  139. package/src/reference/mixed-content.ts +69 -0
  140. package/src/reference/page-notation.ts +252 -0
  141. package/src/reference/raw-floats-validate.ts +103 -0
  142. package/src/reference/raw-floats.mjs +129 -0
  143. package/src/reference/rehype-heading-anchors.mjs +107 -1
  144. package/src/reference/rehype-table-header-labels.mjs +152 -0
  145. package/src/reference/remark-abbreviations.mjs +174 -0
  146. package/src/reference/remark-memo.mjs +339 -0
  147. package/src/reference/remark-notation.mjs +506 -50
  148. package/src/reference/remark-short-note.mjs +237 -0
  149. package/src/reference/types.ts +16 -0
  150. package/src/reference/units-vocab.ts +4 -0
  151. package/src/styles/global.css +1125 -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,
@@ -671,26 +804,231 @@ function inspectExplainCalls(value, resolve, file, node, references) {
671
804
  }
672
805
  }
673
806
 
807
+ /**
808
+ * The numbering inputs a lesson page needs, read off one manifest.
809
+ *
810
+ * Every number on a page comes from two places that MUST agree: the page's own
811
+ * counters (`numbering`) and the manifest tables a cross-page `[[slug#eq-key]]`
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
815
+ * supplying one without the other — the omission that produced page-local
816
+ * `(2.2)` and cross-page `(4.5)` for the same equation.
817
+ *
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.
822
+ */
823
+ export function numberingFromManifest(manifest) {
824
+ if (!manifest || typeof manifest !== 'object') return {};
825
+ return {
826
+ equations: manifest.equations?.numbersBySlug ?? {},
827
+ tables: manifest.tables?.numbersBySlug ?? {},
828
+ figures: manifest.figures?.numbersBySlug ?? {},
829
+ diagrams: manifest.diagrams?.numbersBySlug ?? {},
830
+ checks: manifest.checks?.numbersBySlug ?? {},
831
+ numbering: Object.fromEntries(
832
+ (manifest.lessons ?? []).map((lesson) => [
833
+ lesson.slug,
834
+ {
835
+ part: lesson.part,
836
+ equations: lesson.numbering?.equations ?? 0,
837
+ tables: lesson.numbering?.tables ?? 0,
838
+ figures: lesson.numbering?.figures ?? 0,
839
+ diagrams: lesson.numbering?.diagrams ?? 0,
840
+ checks: lesson.numbering?.checks ?? 0,
841
+ },
842
+ ]),
843
+ ),
844
+ };
845
+ }
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
+
877
+ /**
878
+ * `{ prefix, offset }` for one kind of float on one page, from whichever
879
+ * numbering option the caller supplied. `numbering` is the current form;
880
+ * `chapters` is the superseded per-lesson form (prefix = the lesson's ordinal,
881
+ * no offset) and is still read so an unmigrated config keeps working.
882
+ */
883
+ function pageNumbering(options, slug, kind) {
884
+ if (!slug) return undefined;
885
+ const placement = options.numbering?.[slug];
886
+ if (placement?.part) {
887
+ return { prefix: placement.part, offset: placement[kind] ?? 0 };
888
+ }
889
+ const chapter = options.chapters?.[slug];
890
+ return chapter ? { prefix: chapter, offset: 0 } : undefined;
891
+ }
892
+
893
+ /**
894
+ * Fail when the number this page just computed for a keyed float disagrees
895
+ * with the number the manifest publishes for the same key (WERN — a reference
896
+ * that shows a different number than its target). The two are produced by the
897
+ * same algorithm from the same content, so any disagreement means the render
898
+ * path and the manifest were fed different inputs; in practice, an
899
+ * `astro.config.mjs` that passes the manifest number tables but not
900
+ * `numbering`, which silently drops the page back to the legacy `##`-section
901
+ * scheme.
902
+ */
903
+ function assertManifestNumberParity({
904
+ file,
905
+ slug,
906
+ pageLabels,
907
+ crossPage,
908
+ placement,
909
+ noun,
910
+ syntax,
911
+ }) {
912
+ // No entry for this slug at all means the caller supplied no manifest table
913
+ // (hand-built test trees, non-lesson pages) — nothing to cross-check.
914
+ const published = crossPage?.[slug];
915
+ if (!published) return;
916
+
917
+ for (const [key, number] of pageLabels) {
918
+ const expected = published[key];
919
+ if (expected === undefined) {
920
+ fail(
921
+ file,
922
+ `Stale Explico manifest for ${slug}: this page numbers ${noun} ` +
923
+ `${syntax(key)} as (${number}), but the manifest lists no such ` +
924
+ `${noun} for the lesson. Recompile the manifest before rendering.`,
925
+ );
926
+ }
927
+ if (expected !== number) {
928
+ fail(
929
+ file,
930
+ `Inconsistent ${noun} numbering for ${syntax(key)} in ${slug}: this ` +
931
+ `page renders it as (${number}) while a cross-page reference to it ` +
932
+ `resolves to (${expected}). ` +
933
+ (placement === undefined
934
+ ? 'The remarkNotation options carry the manifest number tables ' +
935
+ 'but no `numbering` entry for this lesson, so the page fell ' +
936
+ 'back to the legacy `##`-section scheme. Pass `manifest` to ' +
937
+ 'remarkNotation so both sides use the part scheme.'
938
+ : 'The page counters and the manifest disagree; recompile the ' +
939
+ 'manifest from the current content.'),
940
+ );
941
+ }
942
+ }
943
+ }
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
+
674
982
  /**
675
983
  * @param {{
676
984
  * definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>),
677
985
  * resolve?: (key: string, context: object) => object | undefined,
678
986
  * unknown?: 'error' | 'warn' | 'ignore',
987
+ * strict?: boolean,
679
988
  * base?: string,
989
+ * manifest?: object,
680
990
  * equations?: Record<string, Record<string, string>>,
681
991
  * tables?: Record<string, Record<string, string>>,
682
992
  * figures?: Record<string, Record<string, string>>,
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 }>,
683
996
  * chapters?: Record<string, number>,
684
997
  * }} [options]
685
998
  *
686
- * `chapters` maps a lesson slug to its 1-based course ordinal. When the
687
- * current page has one, every display equation / `<Table>` / `<Figure>` is
688
- * numbered `‹chapter›.‹n›` (`##` headings ignored); with no entry the legacy
689
- * `##`-section scheme applies. It must agree with `manifest.lessons[].chapter`,
690
- * which is what feeds `equations`/`tables`/`figures` above.
999
+ * `manifest` is the preferred wiring: pass the compiled manifest and all
1000
+ * numbering inputs below are read off it together, so they cannot drift apart.
1001
+ * They stay available individually for hand-built trees, and an explicitly
1002
+ * supplied one still wins over the manifest.
1003
+ *
1004
+ * `numbering` maps a lesson slug to its **part** (the number before the dot)
1005
+ * and, per kind of float, how many the earlier lessons of that part already
1006
+ * used. Every display equation / `<Table>` / `<Figure>` / `<Diagram>` on the page is then
1007
+ * numbered `‹part›.‹offset + n›`, with `##` and `###` headings ignored; with no
1008
+ * entry the legacy `##`-section scheme applies. It must agree with
1009
+ * `manifest.lessons[]`, which is what feeds `equations`/`tables`/`figures`/`diagrams`
1010
+ * above — supplying the tables without `numbering` numbers the same equation
1011
+ * twice, and `assertManifestNumberParity` turns that into a build error rather
1012
+ * than a page whose references disagree with their targets.
1013
+ *
1014
+ * `chapters` is the superseded per-lesson form (slug → the lesson's course
1015
+ * ordinal, no running offset). Still read when `numbering` has no entry, so a
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.
691
1025
  */
692
1026
  export default function remarkNotation(options = {}) {
693
1027
  const unknown = options.unknown ?? 'error';
1028
+ const strict = options.strict ?? false;
1029
+ // The manifest supplies every numbering input at once; an explicit option
1030
+ // still overrides it, so existing per-field wiring keeps working.
1031
+ const numbering = { ...numberingFromManifest(options.manifest), ...options };
694
1032
 
695
1033
  return function transform(tree, file) {
696
1034
  const suppliedDefinitions =
@@ -703,10 +1041,10 @@ export default function remarkNotation(options = {}) {
703
1041
  );
704
1042
  const localDefinitions = localDefinitionsFromFile(file);
705
1043
  const lessonId = lessonIdFrom(file);
706
- // The lesson's course ordinal (see the `chapters` option). Falsy / missing
707
- // ⇒ the legacy `##`-section numbering, so `0` and `undefined` behave alike.
708
- const chapter =
709
- (lessonId && options.chapters?.[lessonId.replace(/\./g, '/')]) || undefined;
1044
+ const lessonSlug = lessonId ? lessonId.replace(/\./g, '/') : undefined;
1045
+ // `{ prefix, offset }` per kind of float — see the `numbering` option.
1046
+ // Missing ⇒ the legacy `##`-section numbering.
1047
+ const equationNumbering = pageNumbering(numbering, lessonSlug, 'equations');
710
1048
  const registryFile = isNotationRegistryFile(file);
711
1049
  const mathScope = pageGlyphScope(
712
1050
  localDefinitions,
@@ -718,8 +1056,9 @@ export default function remarkNotation(options = {}) {
718
1056
 
719
1057
  // Equation identity (D7): assign every `\label{eq:…}` its number before any
720
1058
  // math node is rewritten, so a `[[eq-key]]` earlier in the page still
721
- // resolves. `chapter` picks the `‹chapter›.‹n›` vs `##`-section scheme.
722
- const equationLabels = collectEquationLabels(tree, chapter);
1059
+ // resolves. `equationNumbering` picks the `‹part›.‹n›` vs `##`-section
1060
+ // scheme.
1061
+ const equationLabels = collectEquationLabels(tree, equationNumbering);
723
1062
  const pageLabels = new Map();
724
1063
  for (const label of equationLabels) {
725
1064
  if (pageLabels.has(label.key)) {
@@ -734,25 +1073,47 @@ export default function remarkNotation(options = {}) {
734
1073
  const eqCtx = {
735
1074
  base: options.base ?? '',
736
1075
  pageLabels,
737
- crossPage: options.equations ?? {},
1076
+ crossPage: numbering.equations ?? {},
738
1077
  };
1078
+ // WERN guard: what this page prints next to the equation must be what a
1079
+ // reference from another page prints for it.
1080
+ assertManifestNumberParity({
1081
+ file,
1082
+ slug: lessonSlug,
1083
+ pageLabels,
1084
+ crossPage: eqCtx.crossPage,
1085
+ placement: equationNumbering,
1086
+ noun: 'equation',
1087
+ syntax: (key) => `eq:${key}`,
1088
+ });
739
1089
 
740
1090
  // Number and anchor every display equation on a lesson page, keyed or
741
1091
  // not (F3). Non-lesson pages (notation registry, stray docs) keep the
742
1092
  // keyed-only behaviour so their body math is left untouched.
743
1093
  const displayEquations = lessonId
744
- ? collectDisplayEquations(tree, chapter)
1094
+ ? collectDisplayEquations(tree, equationNumbering)
745
1095
  : [];
746
1096
  const equationByNode = new Map(
747
1097
  displayEquations.map((equation) => [equation.node, equation]),
748
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
+ }
749
1109
 
750
- // Keyed floats (F-tables-figures): every `<Table>`/`<Figure>` on a lesson
1110
+ // Keyed floats: every `<Table>`/`<Figure>`/`<Diagram>` on a lesson
751
1111
  // page is numbered and anchored, keyed or not — same rule as equations,
752
1112
  // via the shared engine in `keyed-jsx-floats.mjs`.
753
- const makeFloatCtx = (tagName, crossPage) => {
1113
+ const makeFloatCtx = (tagName, crossPage, kind) => {
1114
+ const floatNumbering = pageNumbering(numbering, lessonSlug, kind);
754
1115
  const floats = lessonId
755
- ? collectKeyedJsxFloats(tree, { tagName, chapter })
1116
+ ? collectKeyedJsxFloats(tree, { tagName, numbering: floatNumbering })
756
1117
  : [];
757
1118
  const pageFloatLabels = new Map();
758
1119
  for (const float of floats) {
@@ -766,6 +1127,26 @@ export default function remarkNotation(options = {}) {
766
1127
  }
767
1128
  pageFloatLabels.set(float.key, float.number);
768
1129
  }
1130
+ assertManifestNumberParity({
1131
+ file,
1132
+ slug: lessonSlug,
1133
+ pageLabels: pageFloatLabels,
1134
+ crossPage: crossPage ?? {},
1135
+ placement: floatNumbering,
1136
+ noun: tagName,
1137
+ syntax: (key) => `<${tagName} id="${key}">`,
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
+ }
769
1150
  return {
770
1151
  ctx: {
771
1152
  base: options.base ?? '',
@@ -777,12 +1158,26 @@ export default function remarkNotation(options = {}) {
777
1158
  };
778
1159
  const { ctx: tableCtx, byNode: tableByNode } = makeFloatCtx(
779
1160
  'Table',
780
- options.tables,
1161
+ numbering.tables,
1162
+ 'tables',
781
1163
  );
782
1164
  const { ctx: figureCtx, byNode: figureByNode } = makeFloatCtx(
783
1165
  'Figure',
784
- options.figures,
1166
+ numbering.figures,
1167
+ 'figures',
1168
+ );
1169
+ const { ctx: diagramCtx, byNode: diagramByNode } = makeFloatCtx(
1170
+ 'Diagram',
1171
+ numbering.diagrams,
1172
+ 'diagrams',
785
1173
  );
1174
+ const checkCtx = {
1175
+ base: options.base ?? '',
1176
+ pageLabels: new Map(
1177
+ Object.entries(numbering.checks?.[lessonSlug] ?? {}),
1178
+ ),
1179
+ crossPage: numbering.checks ?? {},
1180
+ };
786
1181
 
787
1182
  const resolve = (key, kind, node) => {
788
1183
  const context = {
@@ -904,6 +1299,8 @@ export default function remarkNotation(options = {}) {
904
1299
  eqCtx,
905
1300
  tableCtx,
906
1301
  figureCtx,
1302
+ diagramCtx,
1303
+ checkCtx,
907
1304
  );
908
1305
  if (replacement) {
909
1306
  node.children.splice(index, 1, ...replacement);
@@ -914,14 +1311,72 @@ export default function remarkNotation(options = {}) {
914
1311
  visit(child, false);
915
1312
  if (child && child.type === 'math' && equationByNode.has(child)) {
916
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);
917
1318
  } else if (child?.type === 'mdxJsxFlowElement' && tableByNode.has(child)) {
918
1319
  injectFloatNumberAttributes(child, tableByNode.get(child), 'tab');
919
1320
  } else if (child?.type === 'mdxJsxFlowElement' && figureByNode.has(child)) {
920
1321
  injectFloatNumberAttributes(child, figureByNode.get(child), 'fig');
1322
+ } else if (child?.type === 'mdxJsxFlowElement' && diagramByNode.has(child)) {
1323
+ injectFloatNumberAttributes(child, diagramByNode.get(child), 'dia');
921
1324
  }
922
1325
  }
923
1326
  };
924
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
+
925
1380
  visit(tree);
926
1381
 
927
1382
  file.data.notationReferences = references;
@@ -945,5 +1400,6 @@ export default function remarkNotation(options = {}) {
945
1400
  }));
946
1401
  file.data.tableLabels = floatLabelData(tableByNode);
947
1402
  file.data.figureLabels = floatLabelData(figureByNode);
1403
+ file.data.diagramLabels = floatLabelData(diagramByNode);
948
1404
  };
949
1405
  }