explico 0.2.0 → 0.4.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 (83) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +67 -7
  3. package/dist/compiler/astro.d.ts +30 -0
  4. package/dist/compiler/astro.js +79 -0
  5. package/dist/compiler/cli.d.ts +16 -0
  6. package/dist/compiler/cli.js +43 -9
  7. package/dist/compiler/collections.js +4 -1
  8. package/dist/compiler/course-config.d.ts +80 -4
  9. package/dist/compiler/course-config.js +12 -0
  10. package/dist/compiler/knowledge-version.d.ts +58 -0
  11. package/dist/compiler/knowledge-version.js +80 -0
  12. package/dist/compiler/lesson-derivation.d.ts +1 -1
  13. package/dist/compiler/lesson-derivation.js +2 -2
  14. package/dist/compiler/manifest.d.ts +98 -2
  15. package/dist/compiler/manifest.js +153 -24
  16. package/dist/compiler/resolution-report.d.ts +4 -0
  17. package/dist/reference/base-operators.d.mts +38 -0
  18. package/dist/reference/base-operators.mjs +103 -0
  19. package/dist/reference/consistency.d.ts +7 -2
  20. package/dist/reference/consistency.js +51 -2
  21. package/dist/reference/diagram-layout.d.ts +63 -0
  22. package/dist/reference/diagram-layout.js +122 -0
  23. package/dist/reference/equations-validate.d.ts +10 -0
  24. package/dist/reference/equations-validate.js +1 -1
  25. package/dist/reference/equations.d.mts +80 -13
  26. package/dist/reference/equations.mjs +109 -19
  27. package/dist/reference/figures-validate.d.ts +21 -0
  28. package/dist/reference/figures-validate.js +13 -0
  29. package/dist/reference/keyed-float-validate.d.ts +73 -0
  30. package/dist/reference/keyed-float-validate.js +110 -0
  31. package/dist/reference/keyed-jsx-floats.d.mts +64 -0
  32. package/dist/reference/keyed-jsx-floats.mjs +191 -0
  33. package/dist/reference/math-glyphs.d.mts +18 -0
  34. package/dist/reference/math-glyphs.mjs +92 -0
  35. package/dist/reference/remark-notation.d.mts +61 -0
  36. package/dist/reference/remark-notation.mjs +344 -10
  37. package/dist/reference/tables-validate.d.ts +21 -0
  38. package/dist/reference/tables-validate.js +13 -0
  39. package/dist/reference/units-vocab.js +19 -1
  40. package/docs/VERSIONING.md +66 -0
  41. package/docs/authoring/README.md +100 -0
  42. package/docs/authoring/agents.md +63 -0
  43. package/docs/authoring/migrating.md +169 -0
  44. package/docs/authoring/notation.md +297 -0
  45. package/docs/authoring/structure.md +139 -0
  46. package/docs/authoring/versioning.md +116 -0
  47. package/docs/authoring/visualizations.md +195 -0
  48. package/package.json +20 -5
  49. package/src/compiler/astro.ts +127 -0
  50. package/src/compiler/cli.ts +52 -7
  51. package/src/compiler/collections.ts +12 -3
  52. package/src/compiler/course-config.ts +93 -3
  53. package/src/compiler/knowledge-version.ts +157 -0
  54. package/src/compiler/lesson-derivation.ts +2 -2
  55. package/src/compiler/manifest.ts +320 -26
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/diagram/Diagram.astro +317 -0
  58. package/src/components/examples/CompactExamples.astro +1 -1
  59. package/src/components/figure/Figure.astro +104 -0
  60. package/src/components/notation/NotationLayer.astro +11 -0
  61. package/src/components/notation/render-notation-math.ts +13 -2
  62. package/src/components/reference/FloatRefLayer.astro +671 -0
  63. package/src/components/starlight/AboutPanel.astro +255 -0
  64. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  65. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  66. package/src/components/starlight/LayoutSidebar.astro +62 -1
  67. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  68. package/src/components/starlight/LessonFooter.astro +15 -2
  69. package/src/components/table/Table.astro +105 -0
  70. package/src/reference/base-operators.mjs +103 -0
  71. package/src/reference/consistency.ts +83 -2
  72. package/src/reference/diagram-layout.ts +246 -0
  73. package/src/reference/equations-validate.ts +8 -1
  74. package/src/reference/equations.mjs +109 -19
  75. package/src/reference/figures-validate.ts +40 -0
  76. package/src/reference/keyed-float-validate.ts +210 -0
  77. package/src/reference/keyed-jsx-floats.mjs +191 -0
  78. package/src/reference/math-glyphs.mjs +92 -0
  79. package/src/reference/remark-notation.mjs +344 -10
  80. package/src/reference/tables-validate.ts +40 -0
  81. package/src/reference/units-vocab.ts +18 -0
  82. package/src/styles/global.css +244 -7
  83. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -828,3 +828,95 @@ export function resolveMathGlyphs(latex, definitions) {
828
828
  unresolved: unresolvedIdentifiers(latex, identifiers, bindings),
829
829
  };
830
830
  }
831
+
832
+ // --- named operators ------------------------------------------------
833
+
834
+ /**
835
+ * Every named operator in one KaTeX expression: a built-in `op` node
836
+ * (`\exp`, `\int`, `\sum`, …) or an author-declared `\operatorname{…}` /
837
+ * `\mathop{…}` (`Var`, `Cov`, …). The `name` is the KaTeX command without its
838
+ * backslash for a built-in, and the verbatim body for a declared one — the
839
+ * form `BASE_OPERATORS` and `course.config.ts` `operators` are keyed by.
840
+ *
841
+ * Pure read: unlike `resolveMathGlyphs`, nothing here rewrites the source.
842
+ * Used by the `operator-explained` consistency check (rule N8); the glyph
843
+ * resolver still skips operator bodies whole.
844
+ *
845
+ * @param {string} latex
846
+ * @returns {Array<{ name: string, source: 'named' | 'operatorname' }>}
847
+ */
848
+ export function collectOperators(latex) {
849
+ if (typeof latex !== 'string' || latex.trim() === '') return [];
850
+
851
+ let tree;
852
+ try {
853
+ tree = parseMath(latex, 'operator scan');
854
+ } catch (error) {
855
+ // A span that will not parse is the math gate's problem, not this check's.
856
+ // A KaTeX version-pin failure is nobody's to swallow — let it through.
857
+ if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
858
+ return [];
859
+ }
860
+ throw error;
861
+ }
862
+
863
+ const seen = new Set();
864
+ const operators = [];
865
+ const add = (name, source) => {
866
+ const trimmed = typeof name === 'string' ? name.trim() : '';
867
+ if (trimmed === '') return;
868
+ const identity = `${source}:${trimmed}`;
869
+ if (seen.has(identity)) return;
870
+ seen.add(identity);
871
+ operators.push({ name: trimmed, source });
872
+ };
873
+
874
+ // The declared-operator name is its letters with any internal spacing
875
+ // dropped, so `\operatorname{arg\,max}` keys as `argmax` — `fontBodyText`
876
+ // alone bails on the `\,`.
877
+ const declaredName = (body) => {
878
+ const direct = fontBodyText(body);
879
+ if (typeof direct === 'string') return direct;
880
+ const letters = [];
881
+ const gather = (value) => {
882
+ if (Array.isArray(value)) {
883
+ value.forEach(gather);
884
+ return;
885
+ }
886
+ if (!isNode(value)) return;
887
+ if (value.type === 'mathord' || value.type === 'textord') {
888
+ letters.push(value.text ?? '');
889
+ }
890
+ if (value.type === 'ordgroup') gather(value.body);
891
+ };
892
+ gather(body);
893
+ return letters.join('');
894
+ };
895
+
896
+ const visit = (value) => {
897
+ if (Array.isArray(value)) {
898
+ for (const child of value) visit(child);
899
+ return;
900
+ }
901
+ if (!isNode(value)) return;
902
+
903
+ if (value.type === 'operatorname') {
904
+ add(declaredName(value.body), 'operatorname');
905
+ } else if (value.type === 'op') {
906
+ if (typeof value.name === 'string' && value.name.length > 0) {
907
+ add(value.name.replace(/^\\/, ''), 'named');
908
+ } else if (value.body !== undefined) {
909
+ // `\mathop{Foo}` — a hand-rolled operator with no command name.
910
+ add(declaredName(value.body), 'operatorname');
911
+ }
912
+ }
913
+
914
+ for (const key of Object.keys(value)) {
915
+ if (key === 'loc') continue;
916
+ visit(value[key]);
917
+ }
918
+ };
919
+
920
+ visit(tree);
921
+ return operators;
922
+ }
@@ -42,6 +42,11 @@ import {
42
42
  parseEquationRef,
43
43
  stripEquationLabels,
44
44
  } from './equations.mjs';
45
+ import {
46
+ collectKeyedJsxFloats,
47
+ keyedFloatAnchorId,
48
+ parseKeyedFloatRef,
49
+ } from './keyed-jsx-floats.mjs';
45
50
 
46
51
  const PROSE_SKIP_TYPES = new Set([
47
52
  'code',
@@ -340,6 +345,8 @@ function equationNode(mathNode, { key, number }) {
340
345
  className: ['keyed-equation__number'],
341
346
  'data-eq-number': number,
342
347
  'aria-label': `Equation ${number}`,
348
+ // Plain hover tooltip — the number is a permalink to this equation.
349
+ title: 'Anchor to this equation',
343
350
  },
344
351
  },
345
352
  },
@@ -347,6 +354,32 @@ function equationNode(mathNode, { key, number }) {
347
354
  };
348
355
  }
349
356
 
357
+ /**
358
+ * Give a `<Table>`/`<Figure>` `mdxJsxFlowElement` its computed `number` and
359
+ * `anchorId` as real JSX attributes, so the actual Astro component (which
360
+ * renders the numbered anchor, unlike `equationNode`'s synthesized hast div)
361
+ * receives them as props. The compiler is the single source of truth for
362
+ * both — the component only renders what it's given.
363
+ */
364
+ function injectFloatNumberAttributes(node, float, prefix) {
365
+ const anchorId = keyedFloatAnchorId({
366
+ key: float.key,
367
+ number: float.number,
368
+ prefix,
369
+ });
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
+ );
381
+ }
382
+
350
383
  /** Resolve a `[[eq-key]]` / `[[slug#eq-key]]` reference to `{ number, href }`. */
351
384
  function resolveEquationRef(eqRef, eqCtx, file, node) {
352
385
  if (eqRef.lesson) {
@@ -374,7 +407,47 @@ function resolveEquationRef(eqRef, eqCtx, file, node) {
374
407
  return { number, href: `#eq-${eqRef.key}` };
375
408
  }
376
409
 
377
- function proseNodes(value, resolve, file, node, references, eqCtx) {
410
+ /**
411
+ * Resolve a `[[<prefix>-key]]` / `[[slug#<prefix>-key]]` reference (a
412
+ * `<Table>`/`<Figure>` float) to `{ number, href }` — the table/figure
413
+ * equivalent of `resolveEquationRef`.
414
+ */
415
+ function resolveKeyedFloatRef(floatRef, ctx, { prefix, tagName }, file, node) {
416
+ if (floatRef.lesson) {
417
+ const number = ctx.crossPage?.[floatRef.lesson]?.[floatRef.key];
418
+ if (number === undefined) {
419
+ fail(
420
+ file,
421
+ `Unknown ${tagName} reference [[${floatRef.lesson}#${prefix}-${floatRef.key}]]: lesson ${floatRef.lesson} has no <${tagName}> with id="${floatRef.key}".`,
422
+ node,
423
+ );
424
+ }
425
+ return {
426
+ number,
427
+ href: `${ctx.base.replace(/\/$/, '')}/${floatRef.lesson}/#${prefix}-${floatRef.key}`,
428
+ };
429
+ }
430
+ const number = ctx.pageLabels.get(floatRef.key);
431
+ if (number === undefined) {
432
+ fail(
433
+ file,
434
+ `Unknown ${tagName} reference [[${prefix}-${floatRef.key}]]: no <${tagName}> on this page carries id="${floatRef.key}".`,
435
+ node,
436
+ );
437
+ }
438
+ return { number, href: `#${prefix}-${floatRef.key}` };
439
+ }
440
+
441
+ function proseNodes(
442
+ value,
443
+ resolve,
444
+ file,
445
+ node,
446
+ references,
447
+ eqCtx,
448
+ tableCtx,
449
+ figureCtx,
450
+ ) {
378
451
  const base = eqCtx?.base ?? '';
379
452
  const strayExplain = firstUnescaped(value, EXPLAIN);
380
453
  if (strayExplain !== -1) {
@@ -395,7 +468,18 @@ function proseNodes(value, resolve, file, node, references, eqCtx) {
395
468
 
396
469
  const key = match[1].trim();
397
470
  const eqRef = eqCtx ? parseEquationRef(key) : undefined;
398
- if (!eqRef && !NOTATION_KEY_PATTERN.test(key)) {
471
+ const tableRef =
472
+ !eqRef && tableCtx ? parseKeyedFloatRef(key, { prefix: 'tab' }) : undefined;
473
+ const figureRef =
474
+ !eqRef && !tableRef && figureCtx
475
+ ? parseKeyedFloatRef(key, { prefix: 'fig' })
476
+ : undefined;
477
+ if (
478
+ !eqRef &&
479
+ !tableRef &&
480
+ !figureRef &&
481
+ !NOTATION_KEY_PATTERN.test(key)
482
+ ) {
399
483
  fail(file, `Invalid notation key "${key}" in [[...]].`, node);
400
484
  }
401
485
 
@@ -426,9 +510,46 @@ function proseNodes(value, resolve, file, node, references, eqCtx) {
426
510
  continue;
427
511
  }
428
512
 
513
+ const floatRef = tableRef ?? figureRef;
514
+ if (floatRef) {
515
+ const kind = tableRef ? 'table' : 'figure';
516
+ const prefix = tableRef ? 'tab' : 'fig';
517
+ const tagName = tableRef ? 'Table' : 'Figure';
518
+ const { number, href } = resolveKeyedFloatRef(
519
+ floatRef,
520
+ tableRef ? tableCtx : figureCtx,
521
+ { prefix, tagName },
522
+ file,
523
+ node,
524
+ );
525
+ output.push({
526
+ type: 'link',
527
+ url: href,
528
+ children: [{ type: 'text', value: `${tagName} ${number}` }],
529
+ data: {
530
+ hProperties: {
531
+ className: [`${kind}-ref`],
532
+ [`data-${prefix}-ref`]: floatRef.key,
533
+ ...(floatRef.lesson ? { [`data-${prefix}-lesson`]: floatRef.lesson } : {}),
534
+ },
535
+ },
536
+ });
537
+ references.push({
538
+ key: floatRef.key,
539
+ kind,
540
+ ...(floatRef.lesson ? { lesson: floatRef.lesson } : {}),
541
+ });
542
+ cursor = match.index + match[0].length;
543
+ continue;
544
+ }
545
+
429
546
  const definition = resolve(key, 'prose', node);
430
- const title = definition?.title ?? key;
431
- const summary = definition?.summary;
547
+ // `title`/`summary` are the pre-C1b field names, kept as the first
548
+ // fallback for a caller still supplying them; real content authors
549
+ // `label`/`meaning` (see `content-config.ts`), which raw frontmatter
550
+ // (`manifest.notation.raw`, a page's own `notation.local`) carries as-is.
551
+ const title = definition?.title ?? definition?.label ?? key;
552
+ const summary = definition?.summary ?? definition?.meaning;
432
553
  const href = defaultHref(definition, key, base);
433
554
 
434
555
  const properties = {
@@ -550,17 +671,146 @@ function inspectExplainCalls(value, resolve, file, node, references) {
550
671
  }
551
672
  }
552
673
 
674
+ /**
675
+ * The numbering inputs a lesson page needs, read off one manifest.
676
+ *
677
+ * Every number on a page comes from two places that MUST agree: the page's own
678
+ * 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
681
+ * supplying one without the other — the omission that produced page-local
682
+ * `(2.2)` and cross-page `(4.5)` for the same equation.
683
+ *
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.
687
+ */
688
+ export function numberingFromManifest(manifest) {
689
+ if (!manifest || typeof manifest !== 'object') return {};
690
+ return {
691
+ equations: manifest.equations?.numbersBySlug ?? {},
692
+ tables: manifest.tables?.numbersBySlug ?? {},
693
+ figures: manifest.figures?.numbersBySlug ?? {},
694
+ numbering: Object.fromEntries(
695
+ (manifest.lessons ?? []).map((lesson) => [
696
+ lesson.slug,
697
+ {
698
+ part: lesson.part,
699
+ equations: lesson.numbering?.equations ?? 0,
700
+ tables: lesson.numbering?.tables ?? 0,
701
+ figures: lesson.numbering?.figures ?? 0,
702
+ },
703
+ ]),
704
+ ),
705
+ };
706
+ }
707
+
708
+ /**
709
+ * `{ prefix, offset }` for one kind of float on one page, from whichever
710
+ * numbering option the caller supplied. `numbering` is the current form;
711
+ * `chapters` is the superseded per-lesson form (prefix = the lesson's ordinal,
712
+ * no offset) and is still read so an unmigrated config keeps working.
713
+ */
714
+ function pageNumbering(options, slug, kind) {
715
+ if (!slug) return undefined;
716
+ const placement = options.numbering?.[slug];
717
+ if (placement?.part) {
718
+ return { prefix: placement.part, offset: placement[kind] ?? 0 };
719
+ }
720
+ const chapter = options.chapters?.[slug];
721
+ return chapter ? { prefix: chapter, offset: 0 } : undefined;
722
+ }
723
+
724
+ /**
725
+ * Fail when the number this page just computed for a keyed float disagrees
726
+ * with the number the manifest publishes for the same key (WERN — a reference
727
+ * that shows a different number than its target). The two are produced by the
728
+ * same algorithm from the same content, so any disagreement means the render
729
+ * path and the manifest were fed different inputs; in practice, an
730
+ * `astro.config.mjs` that passes the manifest number tables but not
731
+ * `numbering`, which silently drops the page back to the legacy `##`-section
732
+ * scheme.
733
+ */
734
+ function assertManifestNumberParity({
735
+ file,
736
+ slug,
737
+ pageLabels,
738
+ crossPage,
739
+ placement,
740
+ noun,
741
+ syntax,
742
+ }) {
743
+ // No entry for this slug at all means the caller supplied no manifest table
744
+ // (hand-built test trees, non-lesson pages) — nothing to cross-check.
745
+ const published = crossPage?.[slug];
746
+ if (!published) return;
747
+
748
+ for (const [key, number] of pageLabels) {
749
+ const expected = published[key];
750
+ if (expected === undefined) {
751
+ fail(
752
+ file,
753
+ `Stale Explico manifest for ${slug}: this page numbers ${noun} ` +
754
+ `${syntax(key)} as (${number}), but the manifest lists no such ` +
755
+ `${noun} for the lesson. Recompile the manifest before rendering.`,
756
+ );
757
+ }
758
+ if (expected !== number) {
759
+ fail(
760
+ file,
761
+ `Inconsistent ${noun} numbering for ${syntax(key)} in ${slug}: this ` +
762
+ `page renders it as (${number}) while a cross-page reference to it ` +
763
+ `resolves to (${expected}). ` +
764
+ (placement === undefined
765
+ ? 'The remarkNotation options carry the manifest number tables ' +
766
+ 'but no `numbering` entry for this lesson, so the page fell ' +
767
+ 'back to the legacy `##`-section scheme. Pass `manifest` to ' +
768
+ 'remarkNotation so both sides use the part scheme.'
769
+ : 'The page counters and the manifest disagree; recompile the ' +
770
+ 'manifest from the current content.'),
771
+ );
772
+ }
773
+ }
774
+ }
775
+
553
776
  /**
554
777
  * @param {{
555
778
  * definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>),
556
779
  * resolve?: (key: string, context: object) => object | undefined,
557
780
  * unknown?: 'error' | 'warn' | 'ignore',
558
781
  * base?: string,
782
+ * manifest?: object,
559
783
  * equations?: Record<string, Record<string, string>>,
784
+ * tables?: Record<string, Record<string, string>>,
785
+ * figures?: Record<string, Record<string, string>>,
786
+ * numbering?: Record<string, { part: number, equations: number, tables: number, figures: number }>,
787
+ * chapters?: Record<string, number>,
560
788
  * }} [options]
789
+ *
790
+ * `manifest` is the preferred wiring: pass the compiled manifest and the four
791
+ * numbering inputs below are read off it together, so they cannot drift apart.
792
+ * They stay available individually for hand-built trees, and an explicitly
793
+ * supplied one still wins over the manifest.
794
+ *
795
+ * `numbering` maps a lesson slug to its **part** (the number before the dot)
796
+ * 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
798
+ * numbered `‹part›.‹offset + n›`, with `##` and `###` headings ignored; with no
799
+ * entry the legacy `##`-section scheme applies. It must agree with
800
+ * `manifest.lessons[]`, which is what feeds `equations`/`tables`/`figures`
801
+ * above — supplying the tables without `numbering` numbers the same equation
802
+ * twice, and `assertManifestNumberParity` turns that into a build error rather
803
+ * than a page whose references disagree with their targets.
804
+ *
805
+ * `chapters` is the superseded per-lesson form (slug → the lesson's course
806
+ * ordinal, no running offset). Still read when `numbering` has no entry, so a
807
+ * config written against the old option keeps building.
561
808
  */
562
809
  export default function remarkNotation(options = {}) {
563
810
  const unknown = options.unknown ?? 'error';
811
+ // The manifest supplies every numbering input at once; an explicit option
812
+ // still overrides it, so existing per-field wiring keeps working.
813
+ const numbering = { ...numberingFromManifest(options.manifest), ...options };
564
814
 
565
815
  return function transform(tree, file) {
566
816
  const suppliedDefinitions =
@@ -573,6 +823,10 @@ export default function remarkNotation(options = {}) {
573
823
  );
574
824
  const localDefinitions = localDefinitionsFromFile(file);
575
825
  const lessonId = lessonIdFrom(file);
826
+ const lessonSlug = lessonId ? lessonId.replace(/\./g, '/') : undefined;
827
+ // `{ prefix, offset }` per kind of float — see the `numbering` option.
828
+ // Missing ⇒ the legacy `##`-section numbering.
829
+ const equationNumbering = pageNumbering(numbering, lessonSlug, 'equations');
576
830
  const registryFile = isNotationRegistryFile(file);
577
831
  const mathScope = pageGlyphScope(
578
832
  localDefinitions,
@@ -582,10 +836,11 @@ export default function remarkNotation(options = {}) {
582
836
  const references = [];
583
837
  const resolved = new Map();
584
838
 
585
- // Equation identity (D7): assign every `\label{eq:…}` its section-scoped
586
- // number before any math node is rewritten, so a `[[eq-key]]` earlier in
587
- // the page still resolves.
588
- const equationLabels = collectEquationLabels(tree);
839
+ // Equation identity (D7): assign every `\label{eq:…}` its number before any
840
+ // math node is rewritten, so a `[[eq-key]]` earlier in the page still
841
+ // resolves. `equationNumbering` picks the `‹part›.‹n›` vs `##`-section
842
+ // scheme.
843
+ const equationLabels = collectEquationLabels(tree, equationNumbering);
589
844
  const pageLabels = new Map();
590
845
  for (const label of equationLabels) {
591
846
  if (pageLabels.has(label.key)) {
@@ -600,17 +855,79 @@ export default function remarkNotation(options = {}) {
600
855
  const eqCtx = {
601
856
  base: options.base ?? '',
602
857
  pageLabels,
603
- crossPage: options.equations ?? {},
858
+ crossPage: numbering.equations ?? {},
604
859
  };
860
+ // WERN guard: what this page prints next to the equation must be what a
861
+ // reference from another page prints for it.
862
+ assertManifestNumberParity({
863
+ file,
864
+ slug: lessonSlug,
865
+ pageLabels,
866
+ crossPage: eqCtx.crossPage,
867
+ placement: equationNumbering,
868
+ noun: 'equation',
869
+ syntax: (key) => `eq:${key}`,
870
+ });
605
871
 
606
872
  // Number and anchor every display equation on a lesson page, keyed or
607
873
  // not (F3). Non-lesson pages (notation registry, stray docs) keep the
608
874
  // keyed-only behaviour so their body math is left untouched.
609
- const displayEquations = lessonId ? collectDisplayEquations(tree) : [];
875
+ const displayEquations = lessonId
876
+ ? collectDisplayEquations(tree, equationNumbering)
877
+ : [];
610
878
  const equationByNode = new Map(
611
879
  displayEquations.map((equation) => [equation.node, equation]),
612
880
  );
613
881
 
882
+ // Keyed floats (F-tables-figures): every `<Table>`/`<Figure>` on a lesson
883
+ // page is numbered and anchored, keyed or not — same rule as equations,
884
+ // via the shared engine in `keyed-jsx-floats.mjs`.
885
+ const makeFloatCtx = (tagName, crossPage, kind) => {
886
+ const floatNumbering = pageNumbering(numbering, lessonSlug, kind);
887
+ const floats = lessonId
888
+ ? collectKeyedJsxFloats(tree, { tagName, numbering: floatNumbering })
889
+ : [];
890
+ const pageFloatLabels = new Map();
891
+ for (const float of floats) {
892
+ if (float.key == null) continue;
893
+ if (pageFloatLabels.has(float.key)) {
894
+ fail(
895
+ file,
896
+ `Duplicate <${tagName} id="${float.key}"> on this page; an id is unique per lesson.`,
897
+ float.node,
898
+ );
899
+ }
900
+ pageFloatLabels.set(float.key, float.number);
901
+ }
902
+ assertManifestNumberParity({
903
+ file,
904
+ slug: lessonSlug,
905
+ pageLabels: pageFloatLabels,
906
+ crossPage: crossPage ?? {},
907
+ placement: floatNumbering,
908
+ noun: tagName,
909
+ syntax: (key) => `<${tagName} id="${key}">`,
910
+ });
911
+ return {
912
+ ctx: {
913
+ base: options.base ?? '',
914
+ pageLabels: pageFloatLabels,
915
+ crossPage: crossPage ?? {},
916
+ },
917
+ byNode: new Map(floats.map((float) => [float.node, float])),
918
+ };
919
+ };
920
+ const { ctx: tableCtx, byNode: tableByNode } = makeFloatCtx(
921
+ 'Table',
922
+ numbering.tables,
923
+ 'tables',
924
+ );
925
+ const { ctx: figureCtx, byNode: figureByNode } = makeFloatCtx(
926
+ 'Figure',
927
+ numbering.figures,
928
+ 'figures',
929
+ );
930
+
614
931
  const resolve = (key, kind, node) => {
615
932
  const context = {
616
933
  file,
@@ -729,6 +1046,8 @@ export default function remarkNotation(options = {}) {
729
1046
  child,
730
1047
  references,
731
1048
  eqCtx,
1049
+ tableCtx,
1050
+ figureCtx,
732
1051
  );
733
1052
  if (replacement) {
734
1053
  node.children.splice(index, 1, ...replacement);
@@ -739,6 +1058,10 @@ export default function remarkNotation(options = {}) {
739
1058
  visit(child, false);
740
1059
  if (child && child.type === 'math' && equationByNode.has(child)) {
741
1060
  node.children[index] = equationNode(child, equationByNode.get(child));
1061
+ } else if (child?.type === 'mdxJsxFlowElement' && tableByNode.has(child)) {
1062
+ injectFloatNumberAttributes(child, tableByNode.get(child), 'tab');
1063
+ } else if (child?.type === 'mdxJsxFlowElement' && figureByNode.has(child)) {
1064
+ injectFloatNumberAttributes(child, figureByNode.get(child), 'fig');
742
1065
  }
743
1066
  }
744
1067
  };
@@ -755,5 +1078,16 @@ export default function remarkNotation(options = {}) {
755
1078
  indexInSection,
756
1079
  }),
757
1080
  );
1081
+ const floatLabelData = (byNode) =>
1082
+ [...byNode.values()]
1083
+ .filter((float) => float.key != null)
1084
+ .map(({ key, number, section, indexInSection }) => ({
1085
+ key,
1086
+ number,
1087
+ section,
1088
+ indexInSection,
1089
+ }));
1090
+ file.data.tableLabels = floatLabelData(tableByNode);
1091
+ file.data.figureLabels = floatLabelData(figureByNode);
758
1092
  };
759
1093
  }
@@ -0,0 +1,40 @@
1
+ /** `validateKeyedFloats` for `<Table>` — see `keyed-float-validate.ts`. */
2
+
3
+ import {
4
+ validateKeyedFloats,
5
+ type KeyedFloatLabelRecord,
6
+ type KeyedFloatLessonInput,
7
+ type KeyedFloatValidation,
8
+ } from './keyed-float-validate';
9
+
10
+ export type TableDiagnosticCode =
11
+ | 'tab-invalid-key'
12
+ | 'tab-duplicate-key'
13
+ | 'tab-ref-resolves'
14
+ | 'tab-key-unused';
15
+
16
+ export interface TableDiagnostic {
17
+ readonly code: TableDiagnosticCode;
18
+ readonly severity: 'error' | 'warning';
19
+ readonly message: string;
20
+ readonly file?: string;
21
+ readonly lessonId?: string;
22
+ readonly key?: string;
23
+ }
24
+
25
+ export type TableLabelRecord = KeyedFloatLabelRecord;
26
+ export type TableLessonInput = KeyedFloatLessonInput;
27
+
28
+ export interface TableValidation extends KeyedFloatValidation {
29
+ readonly diagnostics: readonly TableDiagnostic[];
30
+ }
31
+
32
+ export function validateTables(
33
+ lessons: readonly TableLessonInput[],
34
+ ): TableValidation {
35
+ return validateKeyedFloats(lessons, {
36
+ tagName: 'Table',
37
+ prefix: 'tab',
38
+ noun: 'table',
39
+ }) as TableValidation;
40
+ }
@@ -118,6 +118,24 @@ export const UNITS_VOCAB: ReadonlySet<string> = new Set([
118
118
  'quoted',
119
119
  'explicitly',
120
120
  'calculations',
121
+ // motion / physical quantities (playground corpus)
122
+ 'meter',
123
+ 'meters',
124
+ 'second',
125
+ 'seconds',
126
+ 'speed',
127
+ 'velocity',
128
+ 'acceleration',
129
+ 'gravitational',
130
+ 'launch',
131
+ 'angle',
132
+ 'degrees',
133
+ 'horizontal',
134
+ 'vertical',
135
+ 'level',
136
+ 'ground',
137
+ 'squared',
138
+ 'above',
121
139
  ]);
122
140
 
123
141
  /** Lowercase word tokens of a `units` string (split on whitespace and hyphens). */