explico 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +273 -1
  2. package/README.md +103 -8
  3. package/dist/compiler/astro.d.ts +9 -1
  4. package/dist/compiler/astro.js +182 -3
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +28 -4
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +58 -1
  10. package/dist/compiler/course-config.js +28 -1
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/manifest.d.ts +90 -6
  14. package/dist/compiler/manifest.js +150 -17
  15. package/dist/compiler/resolution-report.d.ts +4 -0
  16. package/dist/reference/base-abbreviations.d.mts +30 -0
  17. package/dist/reference/base-abbreviations.mjs +60 -0
  18. package/dist/reference/base-operators.d.mts +15 -0
  19. package/dist/reference/base-operators.mjs +40 -0
  20. package/dist/reference/base-roman-labels.d.mts +35 -0
  21. package/dist/reference/base-roman-labels.mjs +43 -0
  22. package/dist/reference/consistency.d.ts +8 -2
  23. package/dist/reference/consistency.js +50 -3
  24. package/dist/reference/diagrams-validate.d.ts +21 -0
  25. package/dist/reference/diagrams-validate.js +13 -0
  26. package/dist/reference/equations.d.mts +10 -15
  27. package/dist/reference/equations.mjs +67 -14
  28. package/dist/reference/gate-abbreviations.d.ts +17 -0
  29. package/dist/reference/gate-abbreviations.js +64 -0
  30. package/dist/reference/gate-math.d.ts +28 -5
  31. package/dist/reference/gate-math.js +5 -2
  32. package/dist/reference/gate-prose-notation.d.ts +60 -0
  33. package/dist/reference/gate-prose-notation.js +125 -0
  34. package/dist/reference/keyed-float-validate.d.ts +5 -5
  35. package/dist/reference/keyed-jsx-floats.d.mts +7 -12
  36. package/dist/reference/keyed-jsx-floats.mjs +50 -25
  37. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  38. package/dist/reference/knowledge-checks-validate.js +78 -0
  39. package/dist/reference/knowledge-checks.d.mts +25 -0
  40. package/dist/reference/knowledge-checks.mjs +45 -0
  41. package/dist/reference/lab-math-scope.d.ts +9 -1
  42. package/dist/reference/lab-math-scope.js +32 -4
  43. package/dist/reference/math-glyphs.d.mts +30 -0
  44. package/dist/reference/math-glyphs.mjs +367 -0
  45. package/dist/reference/mdx-attributes.d.mts +13 -0
  46. package/dist/reference/mdx-attributes.mjs +38 -0
  47. package/dist/reference/mixed-content.d.ts +17 -0
  48. package/dist/reference/mixed-content.js +29 -0
  49. package/dist/reference/page-notation.d.ts +52 -0
  50. package/dist/reference/page-notation.js +171 -0
  51. package/dist/reference/raw-floats-validate.d.ts +18 -0
  52. package/dist/reference/raw-floats-validate.js +53 -0
  53. package/dist/reference/raw-floats.d.mts +18 -0
  54. package/dist/reference/raw-floats.mjs +129 -0
  55. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  56. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  57. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  58. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  59. package/dist/reference/remark-abbreviations.d.mts +7 -0
  60. package/dist/reference/remark-abbreviations.mjs +174 -0
  61. package/dist/reference/remark-memo.d.mts +16 -0
  62. package/dist/reference/remark-memo.mjs +339 -0
  63. package/dist/reference/remark-notation.d.mts +57 -9
  64. package/dist/reference/remark-notation.mjs +354 -42
  65. package/dist/reference/remark-short-note.d.mts +4 -0
  66. package/dist/reference/remark-short-note.mjs +237 -0
  67. package/dist/reference/types.d.ts +16 -1
  68. package/dist/reference/units-vocab.js +4 -0
  69. package/docs/VERSIONING.md +33 -4
  70. package/docs/authoring/README.md +8 -0
  71. package/docs/authoring/agents.md +17 -2
  72. package/docs/authoring/components.md +135 -0
  73. package/docs/authoring/migrating.md +92 -7
  74. package/docs/authoring/notation.md +560 -1
  75. package/docs/authoring/structure.md +120 -8
  76. package/docs/authoring/visualizations.md +36 -3
  77. package/package.json +5 -2
  78. package/src/compiler/astro.ts +208 -3
  79. package/src/compiler/cli.ts +29 -2
  80. package/src/compiler/collections.ts +45 -4
  81. package/src/compiler/course-config.ts +92 -0
  82. package/src/compiler/exports/standalone-html.ts +963 -0
  83. package/src/compiler/manifest.ts +268 -22
  84. package/src/components/HoverPanel.astro +45 -4
  85. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  86. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  87. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  88. package/src/components/assessments/AssessmentSet.astro +98 -14
  89. package/src/components/assessments/RenderedContent.tsx +55 -0
  90. package/src/components/assessments/assessment-randomization.ts +16 -0
  91. package/src/components/diagram/Diagram.astro +444 -83
  92. package/src/components/disclosure/hover-preview.ts +202 -0
  93. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  94. package/src/components/examples/CompactExamples.astro +2 -0
  95. package/src/components/figure/Figure.astro +279 -10
  96. package/src/components/memo/Memo.astro +453 -0
  97. package/src/components/memo/ShortNote.astro +223 -0
  98. package/src/components/notation/NotationFormula.astro +18 -1
  99. package/src/components/notation/NotationLayer.astro +105 -8
  100. package/src/components/notation/render-notation-math.ts +39 -2
  101. package/src/components/notation/safe-math-to-react.ts +50 -0
  102. package/src/components/reference/FloatRefLayer.astro +233 -15
  103. package/src/components/starlight/CollapsibleSections.astro +461 -0
  104. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  105. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  106. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  107. package/src/components/starlight/LayoutHeader.astro +50 -0
  108. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  109. package/src/components/starlight/LessonExportActions.astro +436 -0
  110. package/src/components/starlight/LessonFooter.astro +27 -2
  111. package/src/components/starlight/section-parts.ts +31 -0
  112. package/src/components/table/Table.astro +293 -7
  113. package/src/components/table/TableOverflowExpander.astro +489 -0
  114. package/src/content-config.ts +20 -0
  115. package/src/reference/base-abbreviations.mjs +60 -0
  116. package/src/reference/base-operators.mjs +40 -0
  117. package/src/reference/base-roman-labels.mjs +43 -0
  118. package/src/reference/consistency.ts +73 -2
  119. package/src/reference/diagrams-validate.ts +40 -0
  120. package/src/reference/equations.mjs +67 -14
  121. package/src/reference/gate-abbreviations.ts +128 -0
  122. package/src/reference/gate-math.ts +27 -7
  123. package/src/reference/gate-prose-notation.ts +224 -0
  124. package/src/reference/keyed-float-validate.ts +5 -5
  125. package/src/reference/keyed-jsx-floats.mjs +50 -25
  126. package/src/reference/knowledge-checks-validate.ts +130 -0
  127. package/src/reference/knowledge-checks.mjs +45 -0
  128. package/src/reference/lab-math-scope.ts +45 -3
  129. package/src/reference/math-glyphs.mjs +367 -0
  130. package/src/reference/mdx-attributes.mjs +38 -0
  131. package/src/reference/mixed-content.ts +69 -0
  132. package/src/reference/page-notation.ts +252 -0
  133. package/src/reference/raw-floats-validate.ts +103 -0
  134. package/src/reference/raw-floats.mjs +129 -0
  135. package/src/reference/rehype-heading-anchors.mjs +107 -1
  136. package/src/reference/rehype-table-header-labels.mjs +152 -0
  137. package/src/reference/remark-abbreviations.mjs +174 -0
  138. package/src/reference/remark-memo.mjs +339 -0
  139. package/src/reference/remark-notation.mjs +354 -42
  140. package/src/reference/remark-short-note.mjs +237 -0
  141. package/src/reference/types.ts +16 -0
  142. package/src/reference/units-vocab.ts +4 -0
  143. package/src/styles/global.css +996 -50
@@ -1,6 +1,7 @@
1
1
  import katex from 'katex';
2
2
 
3
3
  import { BASE_LIBRARY } from './base-library.mjs';
4
+ import { PARENTHESIZED_FUNCTIONS } from './base-operators.mjs';
4
5
 
5
6
  /**
6
7
  * Page-level glyph-map resolver (cleanup plan, D1 decision 2026-09-02).
@@ -181,6 +182,52 @@ function readBracedGroup(value, start) {
181
182
  return undefined;
182
183
  }
183
184
 
185
+ const MARKER_COMMANDS = [EXPLAIN_COMMAND, '\\slot'];
186
+
187
+ /**
188
+ * Strip Explico's own `\explain{key}{latex}` / `\slot{key}{latex}` markers
189
+ * (see `katex-options.mjs`) down to the LaTeX they wrap, recursively. Lesson
190
+ * math — both a notation card's `formula` and an equation written directly
191
+ * in prose — carries these markers inline so every symbol is hoverable; a
192
+ * reader copying the equation out of this system wants the plain math
193
+ * underneath, not internal notation-key plumbing that means nothing to
194
+ * KaTeX, MathJax, or LaTeX anywhere else. Shared by `render-notation-math.ts`
195
+ * (a notation card's copy button) and `remark-notation.mjs` (a numbered
196
+ * display equation's copy button) — kept here, rather than in either, so
197
+ * neither module has to import the other.
198
+ */
199
+ export function stripNotationMarkers(latex) {
200
+ let result = '';
201
+ let cursor = 0;
202
+
203
+ while (cursor < latex.length) {
204
+ const command = MARKER_COMMANDS.find(
205
+ (name) =>
206
+ latex.startsWith(name, cursor) &&
207
+ !isEscaped(latex, cursor) &&
208
+ !/[A-Za-z@]/.test(latex[cursor + name.length] ?? ''),
209
+ );
210
+
211
+ const keyGroup = command
212
+ ? readBracedGroup(latex, skipWhitespace(latex, cursor + command.length))
213
+ : undefined;
214
+ const contentGroup = keyGroup
215
+ ? readBracedGroup(latex, skipWhitespace(latex, keyGroup.end))
216
+ : undefined;
217
+
218
+ if (contentGroup) {
219
+ result += stripNotationMarkers(contentGroup.content);
220
+ cursor = contentGroup.end;
221
+ continue;
222
+ }
223
+
224
+ result += latex[cursor];
225
+ cursor += 1;
226
+ }
227
+
228
+ return result;
229
+ }
230
+
184
231
  /**
185
232
  * Every `\explain{key}{latex}` in the source (the escape hatch). Each key must
186
233
  * already be in the page glyph table. The returned span points at the wrapped
@@ -768,6 +815,243 @@ function unresolvedIdentifiers(latex, identifiers, bindings) {
768
815
  .sort((left, right) => left.start - right.start || left.end - right.end);
769
816
  }
770
817
 
818
+ // --- N13: nonlinear functions take a parenthesized argument --------------
819
+
820
+ // Opening delimiters that visibly bracket a function's argument. A bare `{`
821
+ // (KaTeX `ordgroup`) does not count — it renders no visible mark at all, so
822
+ // `\sin{x}` reads identically to the bare `\sin x` this rule forbids.
823
+ const FUNCTION_ARG_OPENERS = new Set(['(', '[', '\\{']);
824
+
825
+ // Node types that plausibly start a bare (unparenthesized) argument right
826
+ // after a function name — a letter, a digit, an invisible `{…}` group,
827
+ // another function, an exponent/subscript structure, a fraction, a root, or
828
+ // a styled/labelled run (`\mathrm{…}`, `\color{…}{…}`, …). Anything else
829
+ // (a relation, a binary operator, closing punctuation, or nothing at all) is
830
+ // left alone: the function has no argument in this position to complain
831
+ // about.
832
+ const BARE_ARGUMENT_NODE_TYPES = new Set([
833
+ 'mathord',
834
+ 'textord',
835
+ 'ordgroup',
836
+ 'op',
837
+ 'operatorname',
838
+ 'supsub',
839
+ 'genfrac',
840
+ 'sqrt',
841
+ 'font',
842
+ 'accent',
843
+ 'accentUnder',
844
+ 'overline',
845
+ 'underline',
846
+ 'phantom',
847
+ 'raisebox',
848
+ 'href',
849
+ 'color',
850
+ 'styling',
851
+ 'mclass',
852
+ ]);
853
+
854
+ /** `\,`, `\;`, `\!`, `~`, … — spacing that sits between a function and its
855
+ * argument without being the argument itself. */
856
+ function isSpacingNode(node) {
857
+ return isNode(node) && node.type === 'kern';
858
+ }
859
+
860
+ /** True when `node` is a visible parenthesis/bracket group opening here. */
861
+ function opensFunctionArgument(node) {
862
+ if (!isNode(node)) return false;
863
+ if (node.type === 'leftright') return true;
864
+ return (
865
+ node.type === 'atom' &&
866
+ node.family === 'open' &&
867
+ FUNCTION_ARG_OPENERS.has(node.text)
868
+ );
869
+ }
870
+
871
+ /**
872
+ * The bare command name (`sin`, `log`, …) when `node` is a call to one of
873
+ * {@link PARENTHESIZED_FUNCTIONS} — either the `op` node itself, or one
874
+ * wrapped in a `supsub` for `\sin^2 x` / `\log_2 x`.
875
+ */
876
+ function parenthesizedFunctionName(node) {
877
+ if (!isNode(node)) return null;
878
+ if (node.type === 'op' && typeof node.name === 'string') {
879
+ const name = node.name.replace(/^\\/, '');
880
+ return PARENTHESIZED_FUNCTIONS.has(name) ? name : null;
881
+ }
882
+ if (node.type === 'supsub') return parenthesizedFunctionName(node.base);
883
+ return null;
884
+ }
885
+
886
+ /**
887
+ * Every `\sin x`-shaped violation of N13 in one already-parsed KaTeX tree: a
888
+ * {@link PARENTHESIZED_FUNCTIONS} name immediately followed, in its own
889
+ * sibling run, by something that reads as a bare argument rather than a
890
+ * parenthesized (or bracketed) one. Mirrors `collectOperators`'s tree walk,
891
+ * but needs sibling context a plain node visitor doesn't carry, so it walks
892
+ * array bodies explicitly instead of reusing that helper.
893
+ *
894
+ * @returns {string[]} one function name per violation, in document order.
895
+ */
896
+ function findUnparenthesizedFunctionCalls(tree) {
897
+ const violations = [];
898
+
899
+ const visitSiblings = (nodes) => {
900
+ for (let i = 0; i < nodes.length; i++) {
901
+ const name = parenthesizedFunctionName(nodes[i]);
902
+ if (!name) continue;
903
+ let j = i + 1;
904
+ while (j < nodes.length && isSpacingNode(nodes[j])) j += 1;
905
+ const next = j < nodes.length ? nodes[j] : undefined;
906
+ if (opensFunctionArgument(next)) continue;
907
+ if (isNode(next) && BARE_ARGUMENT_NODE_TYPES.has(next.type)) {
908
+ violations.push(name);
909
+ }
910
+ }
911
+ };
912
+
913
+ const visit = (value) => {
914
+ if (Array.isArray(value)) {
915
+ visitSiblings(value);
916
+ for (const child of value) visit(child);
917
+ return;
918
+ }
919
+ if (!isNode(value)) return;
920
+ for (const key of Object.keys(value)) {
921
+ if (key === 'loc') continue;
922
+ visit(value[key]);
923
+ }
924
+ };
925
+
926
+ visit(tree);
927
+ return violations;
928
+ }
929
+
930
+ // --- N15: nested visible delimiters need \left/\right sizing -------------
931
+
932
+ // The four visible delimiter glyphs this rule regulates, keyed by their
933
+ // KaTeX source text. `{`/`}` bare is invisible LaTeX grouping, not a visible
934
+ // mark, so only the escaped, visible `\{`/`\}` counts — the same distinction
935
+ // `FUNCTION_ARG_OPENERS` above already draws.
936
+ const NESTED_DELIM_OPEN = new Map([
937
+ ['(', 'paren'],
938
+ ['[', 'bracket'],
939
+ ['\\{', 'brace'],
940
+ ]);
941
+ const NESTED_DELIM_CLOSE = new Map([
942
+ [')', 'paren'],
943
+ [']', 'bracket'],
944
+ ['\\}', 'brace'],
945
+ ]);
946
+ // Recognised on either side of a `\left…\right` pair so a mixed pair like
947
+ // `\left.\right|` (an evaluation bar with an invisible left side) still
948
+ // counts as already-sized.
949
+ const NESTED_DELIM_CHARS = new Set(['(', ')', '[', ']', '\\{', '\\}', '|']);
950
+
951
+ /**
952
+ * Every N15 violation in one already-parsed KaTeX tree: a bare `(`, `[`,
953
+ * `\{`, or `|` that either sits inside another of these four delimiters, or
954
+ * itself contains one, without either side switching to the `\left`/`\right`
955
+ * auto-sizing form. A `\left…\right` pair is never itself reported — it is
956
+ * the fix the rule asks for — but a bare delimiter one level inside it still
957
+ * needs its own `\left`/`\right`, since sizing one level of a nest does not
958
+ * size the rest.
959
+ *
960
+ * Mirrors {@link findUnparenthesizedFunctionCalls}'s tree walk, but needs one
961
+ * stack carried across the whole recursion (nesting here is "does an
962
+ * ancestor node in the tree still have an open delimiter", not sibling
963
+ * adjacency), so it walks the tree itself rather than reusing a sibling-only
964
+ * visitor.
965
+ *
966
+ * `|` has no distinct open/close glyph, so a bare `|` toggles: the first one
967
+ * at the current depth opens a pipe scope, the next one closes it — matching
968
+ * how `\lvert x \rvert`-less absolute value is actually written in this
969
+ * corpus (N9's `\lvert…\rvert` guidance is about *outside*-math prose; a bare
970
+ * `|` pair is unremarkable inside `$…$` on its own).
971
+ *
972
+ * @returns {{ type: string, char: string }[]} one entry per delimiter
973
+ * instance that must become `\left`/`\right`, in document order.
974
+ */
975
+ function findNestedBareDelimiters(tree) {
976
+ const stack = [];
977
+ const violations = [];
978
+
979
+ const flag = (entry) => {
980
+ if (entry.reported) return;
981
+ entry.reported = true;
982
+ violations.push({ type: entry.type, char: entry.char });
983
+ };
984
+
985
+ const pushOpen = (type, char, bare) => {
986
+ const entry = { type, char, bare, reported: false };
987
+ if (stack.length > 0) {
988
+ const parent = stack[stack.length - 1];
989
+ // The parent already contains a nested delimiter: if it is bare, it
990
+ // now needs sizing too, regardless of whether this new one is sized.
991
+ if (parent.bare) flag(parent);
992
+ // This one is nested inside another delimiter's span: if it is bare,
993
+ // it needs sizing, regardless of whether the parent is already sized.
994
+ if (bare) flag(entry);
995
+ }
996
+ stack.push(entry);
997
+ };
998
+
999
+ const visit = (node) => {
1000
+ if (Array.isArray(node)) {
1001
+ for (const child of node) visit(child);
1002
+ return;
1003
+ }
1004
+ if (!isNode(node)) return;
1005
+
1006
+ // A visible delimiter only counts in math mode — the same character
1007
+ // inside `\text{…}` is display text, not a sizing delimiter (it parses
1008
+ // to a plain `textord` in text mode, never reaching this branch).
1009
+ if (node.type === 'atom' && node.mode === 'math') {
1010
+ if (node.family === 'open' && NESTED_DELIM_OPEN.has(node.text)) {
1011
+ pushOpen(NESTED_DELIM_OPEN.get(node.text), node.text, true);
1012
+ return;
1013
+ }
1014
+ if (node.family === 'close' && NESTED_DELIM_CLOSE.has(node.text)) {
1015
+ stack.pop();
1016
+ return;
1017
+ }
1018
+ }
1019
+ if (node.type === 'textord' && node.mode === 'math' && node.text === '|') {
1020
+ const top = stack[stack.length - 1];
1021
+ if (top && top.type === 'pipe') {
1022
+ stack.pop();
1023
+ } else {
1024
+ pushOpen('pipe', '|', true);
1025
+ }
1026
+ return;
1027
+ }
1028
+ if (node.type === 'leftright') {
1029
+ // Already sized. Still an enclosing scope for whatever nests inside it
1030
+ // (a bare delimiter one level in still needs its own sizing), but only
1031
+ // when a tracked glyph is actually on one of its two sides — an
1032
+ // unrelated pair like `\left\langle…\right\rangle` is transparent to
1033
+ // this rule, matching the "with each other" scope of the four glyphs
1034
+ // it names.
1035
+ if (NESTED_DELIM_CHARS.has(node.left) || NESTED_DELIM_CHARS.has(node.right)) {
1036
+ pushOpen('sized', node.left, false);
1037
+ visit(node.body);
1038
+ stack.pop();
1039
+ } else {
1040
+ visit(node.body);
1041
+ }
1042
+ return;
1043
+ }
1044
+
1045
+ for (const key of Object.keys(node)) {
1046
+ if (key === 'loc') continue;
1047
+ visit(node[key]);
1048
+ }
1049
+ };
1050
+
1051
+ visit(tree);
1052
+ return violations;
1053
+ }
1054
+
771
1055
  /**
772
1056
  * Resolve and annotate one KaTeX expression against a page's flat glyph table.
773
1057
  * Source offsets in the result always refer to the original, unmodified LaTeX.
@@ -786,6 +1070,33 @@ export function resolveMathGlyphs(latex, definitions) {
786
1070
  sourceWithoutExplainPrefixes(latex, explicitCalls),
787
1071
  'lesson math',
788
1072
  );
1073
+
1074
+ const parenViolations = findUnparenthesizedFunctionCalls(tree);
1075
+ if (parenViolations.length > 0) {
1076
+ const names = [...new Set(parenViolations)].map((name) => `\\${name}`).join(', ');
1077
+ fail(
1078
+ 'unparenthesized-function',
1079
+ `${names} used without a parenthesized argument in ${JSON.stringify(latex)}. ` +
1080
+ `A nonlinear function always takes a parenthesized argument, even a single ` +
1081
+ `symbol — write \\sin(x), not \\sin x (rule N13).`,
1082
+ { violations: parenViolations },
1083
+ );
1084
+ }
1085
+ const nestingViolations = findNestedBareDelimiters(tree);
1086
+ if (nestingViolations.length > 0) {
1087
+ const shown = [...new Set(nestingViolations.map(({ char }) => char))]
1088
+ .map((char) => `\\left${char}`)
1089
+ .join(', ');
1090
+ fail(
1091
+ 'nested-bare-delimiter',
1092
+ `${shown} nested with another (, [, {, or | in ${JSON.stringify(latex)}. ` +
1093
+ `(, [, { and | can be used bare only when not nested — once one of ` +
1094
+ `them is nested inside another, every level of the nest must use the ` +
1095
+ `sizing form (\\left(/\\right), \\left[/\\right], \\left\\{/\\right\\}, ` +
1096
+ `\\left|/\\right|) instead of the bare character (rule N15).`,
1097
+ { violations: nestingViolations },
1098
+ );
1099
+ }
789
1100
  const { containers, identifiers, scriptSpans } = collectSourceStructure(tree);
790
1101
 
791
1102
  const canonical = matchCanonicalForms(latex, containers, glyphs);
@@ -920,3 +1231,59 @@ export function collectOperators(latex) {
920
1231
  visit(tree);
921
1232
  return operators;
922
1233
  }
1234
+
1235
+ // --- named roman labels ----------------------------------------------
1236
+
1237
+ /**
1238
+ * Every multi-letter `\mathrm{…}` label in one KaTeX expression (`KE`, `PV`,
1239
+ * `opt`) — the upright-label counterpart to {@link collectOperators}. A
1240
+ * single-letter body (`\mathrm{d}`, the N7 differential) is not collected:
1241
+ * that one is a fixed typographic operator, not a named quantity a reader
1242
+ * would need explained.
1243
+ *
1244
+ * Pure read: nothing here rewrites the source. Used by the
1245
+ * `roman-label-explained` consistency check (rule N10); the glyph resolver
1246
+ * still skips `\mathrm{…}` bodies whole ([N3](../../docs/authoring/notation.md#n3)).
1247
+ *
1248
+ * @param {string} latex
1249
+ * @returns {Array<{ name: string }>}
1250
+ */
1251
+ export function collectRomanLabels(latex) {
1252
+ if (typeof latex !== 'string' || latex.trim() === '') return [];
1253
+
1254
+ let tree;
1255
+ try {
1256
+ tree = parseMath(latex, 'roman-label scan');
1257
+ } catch (error) {
1258
+ if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
1259
+ return [];
1260
+ }
1261
+ throw error;
1262
+ }
1263
+
1264
+ const seen = new Set();
1265
+ const labels = [];
1266
+ const visit = (value) => {
1267
+ if (Array.isArray(value)) {
1268
+ for (const child of value) visit(child);
1269
+ return;
1270
+ }
1271
+ if (!isNode(value)) return;
1272
+
1273
+ if (value.type === 'font' && value.font === ROMAN_LABEL_FONT) {
1274
+ const name = fontBodyText(value.body);
1275
+ if (typeof name === 'string' && name.length >= 2 && !seen.has(name)) {
1276
+ seen.add(name);
1277
+ labels.push({ name });
1278
+ }
1279
+ }
1280
+
1281
+ for (const key of Object.keys(value)) {
1282
+ if (key === 'loc') continue;
1283
+ visit(value[key]);
1284
+ }
1285
+ };
1286
+
1287
+ visit(tree);
1288
+ return labels;
1289
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Replace computed string attributes on an MDX JSX node without disturbing
3
+ * author-owned props. Remark adapters use this when the build, rather than the
4
+ * author, owns values such as a visible number or DOM anchor.
5
+ *
6
+ * @param {object} node
7
+ * @param {Record<string, string>} values
8
+ */
9
+ export function injectMdxStringAttributes(node, values) {
10
+ const names = new Set(Object.keys(values));
11
+ node.attributes = (node.attributes ?? []).filter(
12
+ (attribute) =>
13
+ !(
14
+ attribute?.type === 'mdxJsxAttribute' &&
15
+ typeof attribute.name === 'string' &&
16
+ names.has(attribute.name)
17
+ ),
18
+ );
19
+ for (const [name, value] of Object.entries(values)) {
20
+ node.attributes.push({ type: 'mdxJsxAttribute', name, value });
21
+ }
22
+ }
23
+
24
+ /** Return every ordinary MDX JSX attribute with `name`. */
25
+ export function mdxAttributesNamed(node, name) {
26
+ return (node?.attributes ?? []).filter(
27
+ (attribute) =>
28
+ attribute?.type === 'mdxJsxAttribute' && attribute.name === name,
29
+ );
30
+ }
31
+
32
+ /** Remove an internal adapter-owned attribute from an MDX JSX node. */
33
+ export function removeMdxAttribute(node, name) {
34
+ node.attributes = (node.attributes ?? []).filter(
35
+ (attribute) =>
36
+ !(attribute?.type === 'mdxJsxAttribute' && attribute.name === name),
37
+ );
38
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Split a plain string that mixes prose and `$…$` / `$$…$$` math into an
3
+ * ordered list of text and math segments, resolving each math span's glyphs
4
+ * against a page scope on the way — the shared step behind rendering an
5
+ * assessment `prompt` / `explanation` through the same KaTeX pipeline as
6
+ * lesson body math (`AssessmentSet.astro` calls `renderNotationMath` on each
7
+ * `math` segment's `latex`; nothing here touches KaTeX itself).
8
+ *
9
+ * Unlike `gate-math.ts`'s `mathSpans` (which only needs the math, for
10
+ * diagnostics), this keeps the surrounding text too, in order, since the
11
+ * caller has to reproduce the original sentence around the rendered math.
12
+ *
13
+ * Resolution failure is swallowed here, not thrown: a bad span falls back to
14
+ * its own raw source as a `math` segment KaTeX will very likely also reject,
15
+ * degrading the one span instead of crashing manifest compilation. The actual
16
+ * enforcement — failing the build — is `gate-math.ts`'s job (context
17
+ * `'assessment'`) and `gate-prose-notation.ts`'s (context `'prose'`); this
18
+ * module's contract is "never throw", so a manifest build never depends on
19
+ * content being clean to finish running.
20
+ */
21
+ import { resolveMathGlyphs } from './math-glyphs.mjs';
22
+ import type { ScopeEntry } from './gate-math';
23
+
24
+ export type ContentSegment =
25
+ | { readonly type: 'text'; readonly value: string }
26
+ | { readonly type: 'math'; readonly latex: string };
27
+
28
+ // Byte-identical to `gate-math.ts`'s `DISPLAY_MATH` / `INLINE_MATH` — both
29
+ // scan the same author-facing `$…$` syntax and must agree on what counts as
30
+ // a span. Assessment prompt/explanation strings have no code fences or MDX
31
+ // to strip first, unlike a lesson body, so there is no shared `stripCode`
32
+ // step to reuse here.
33
+ const MATH_SPAN =
34
+ /\$\$([\s\S]+?)\$\$|(?<![\\$])\$(?!\s)([^$\n]+?)(?<![\\\s])\$(?!\d)/g;
35
+
36
+ /**
37
+ * Split `text` into ordered segments, resolving each math span's glyphs
38
+ * against `scope`. Never throws.
39
+ */
40
+ export function splitMixedContent(
41
+ text: string,
42
+ scope: readonly ScopeEntry[],
43
+ ): ContentSegment[] {
44
+ const segments: ContentSegment[] = [];
45
+ let cursor = 0;
46
+ const pattern = new RegExp(MATH_SPAN);
47
+ let match: RegExpExecArray | null;
48
+
49
+ while ((match = pattern.exec(text))) {
50
+ if (match.index > cursor) {
51
+ segments.push({ type: 'text', value: text.slice(cursor, match.index) });
52
+ }
53
+ const raw = match[1] ?? match[2] ?? '';
54
+ let latex = raw;
55
+ try {
56
+ latex = resolveMathGlyphs(raw, [...scope]).latex;
57
+ } catch {
58
+ latex = raw;
59
+ }
60
+ segments.push({ type: 'math', latex });
61
+ cursor = match.index + match[0].length;
62
+ }
63
+
64
+ if (cursor < text.length) {
65
+ segments.push({ type: 'text', value: text.slice(cursor) });
66
+ }
67
+
68
+ return segments;
69
+ }