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
@@ -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,21 @@
1
+ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } from './keyed-float-validate.js';
2
+
3
+ /** `validateKeyedFloats` for `<Table>` — see `keyed-float-validate.ts`. */
4
+
5
+ type TableDiagnosticCode = 'tab-invalid-key' | 'tab-duplicate-key' | 'tab-ref-resolves' | 'tab-key-unused';
6
+ interface TableDiagnostic {
7
+ readonly code: TableDiagnosticCode;
8
+ readonly severity: 'error' | 'warning';
9
+ readonly message: string;
10
+ readonly file?: string;
11
+ readonly lessonId?: string;
12
+ readonly key?: string;
13
+ }
14
+ type TableLabelRecord = KeyedFloatLabelRecord;
15
+ type TableLessonInput = KeyedFloatLessonInput;
16
+ interface TableValidation extends KeyedFloatValidation {
17
+ readonly diagnostics: readonly TableDiagnostic[];
18
+ }
19
+ declare function validateTables(lessons: readonly TableLessonInput[]): TableValidation;
20
+
21
+ export { type TableDiagnostic, type TableDiagnosticCode, type TableLabelRecord, type TableLessonInput, type TableValidation, validateTables };
@@ -0,0 +1,13 @@
1
+ import {
2
+ validateKeyedFloats
3
+ } from "./keyed-float-validate.js";
4
+ function validateTables(lessons) {
5
+ return validateKeyedFloats(lessons, {
6
+ tagName: "Table",
7
+ prefix: "tab",
8
+ noun: "table"
9
+ });
10
+ }
11
+ export {
12
+ validateTables
13
+ };
@@ -97,7 +97,25 @@ const UNITS_VOCAB = /* @__PURE__ */ new Set([
97
97
  "quantity",
98
98
  "quoted",
99
99
  "explicitly",
100
- "calculations"
100
+ "calculations",
101
+ // motion / physical quantities (playground corpus)
102
+ "meter",
103
+ "meters",
104
+ "second",
105
+ "seconds",
106
+ "speed",
107
+ "velocity",
108
+ "acceleration",
109
+ "gravitational",
110
+ "launch",
111
+ "angle",
112
+ "degrees",
113
+ "horizontal",
114
+ "vertical",
115
+ "level",
116
+ "ground",
117
+ "squared",
118
+ "above"
101
119
  ]);
102
120
  function unitsTokens(units) {
103
121
  return units.toLowerCase().split(/[\s]+/).flatMap((word) => word.split(/(?<=[a-z0-9])-(?=[a-z])/)).map((word) => word.replace(/^[^a-z0-9_]+|[^a-z0-9_]+$/g, "")).filter((word) => word.length > 0 && !/^\d+$/.test(word));
@@ -0,0 +1,66 @@
1
+ # Versioning
2
+
3
+ `explico` follows [Semantic Versioning](https://semver.org/). The installed
4
+ `package.json` `version` is authoritative for what a consumer has;
5
+ [`CHANGELOG.md`](../CHANGELOG.md) records what changed; this file defines what a
6
+ bump *means*.
7
+
8
+ There are two independent version streams. This document is about the **engine**.
9
+ A course built on explico carries its own **knowledge version** over its
10
+ content — see [`authoring/versioning.md`](authoring/versioning.md).
11
+
12
+ ## The public surface
13
+
14
+ A change is "breaking" when it can break a consumer who followed the docs. The
15
+ consumer-visible surface is:
16
+
17
+ | Surface | Where |
18
+ | --- | --- |
19
+ | Published module entry points | `package.json` `publishConfig.exports` — the `explico/*` specifiers and their `.d.ts` |
20
+ | Astro / React components | names + props of everything under `components/` that the README wires |
21
+ | remark / rehype adapter options | the option objects of `reference/*.mjs` (`remarkNotation`, `remarkCitation`, `createNotationKatexOptions`, …) |
22
+ | The compiler manifest | `MANIFEST_SCHEMA_VERSION` and the shape of `Manifest` |
23
+ | The `content` CLI | the commands, their flags, and every diagnostic `code` string |
24
+ | `CourseConfig` | the interface in `compiler/course-config.ts` |
25
+ | Build-enforced authoring rules | the `N*` / `V*` / `C*` rules marked *Build-enforced* in `docs/authoring/` |
26
+
27
+ ## What each bump means
28
+
29
+ **MAJOR** — an incompatible change to any surface above: removing or renaming an
30
+ export, a component, a prop, or a CLI command; adding a required `CourseConfig`
31
+ field; a breaking change to the manifest shape (which also bumps
32
+ `MANIFEST_SCHEMA_VERSION`); tightening a build-enforced rule so content that
33
+ passed now fails.
34
+
35
+ **MINOR** — a backward-compatible addition: a new export, component, or optional
36
+ `CourseConfig` field; a new opt-in authoring rule; a new manifest field
37
+ (additive — still bump `MANIFEST_SCHEMA_VERSION`, note it additive in
38
+ `CHANGELOG.md`).
39
+
40
+ **PATCH** — a bug fix, a docs change, an internal refactor with no surface
41
+ change, or a new **Tier-3 warning** (warnings never fail a build, so surfacing
42
+ one does not break a consumer).
43
+
44
+ ### Pre-1.0
45
+
46
+ While the version is `0.x`, a **MINOR** bump *may* include a breaking change —
47
+ every one is listed under a `### Breaking` heading in `CHANGELOG.md` for that
48
+ version. A **PATCH** never breaks. Once the API settles this clause is dropped
49
+ and normal semver applies.
50
+
51
+ ## Cutting a release
52
+
53
+ 1. Decide the next version from the rules above and the `## [Unreleased]`
54
+ section of `CHANGELOG.md`.
55
+ 2. `CHANGELOG.md`: rename `## [Unreleased]` to `## [x.y.z] - YYYY-MM-DD`, add a
56
+ fresh empty `## [Unreleased]`, and fix up the compare links at the bottom.
57
+ 3. Bump `package.json` `version` (`npm version x.y.z --no-git-tag-version`).
58
+ 4. `pnpm build && pnpm test`.
59
+ 5. Commit, then `git tag vx.y.z`.
60
+
61
+ ## When you change the engine
62
+
63
+ Every PR that touches the public surface adds a bullet under `## [Unreleased]`
64
+ in `CHANGELOG.md`, in the right group (`Added` / `Changed` / `Fixed` /
65
+ `Removed`, plus `Breaking` when it is). Pick the group by the rules above; the
66
+ version number itself is set only at release time. See [`AGENTS.md`](../AGENTS.md).