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
@@ -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,13 @@
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: object, values: Record<string, string>): void;
10
+ /** Return every ordinary MDX JSX attribute with `name`. */
11
+ export function mdxAttributesNamed(node: any, name: any): any;
12
+ /** Remove an internal adapter-owned attribute from an MDX JSX node. */
13
+ export function removeMdxAttribute(node: any, name: any): void;
@@ -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,17 @@
1
+ import { ScopeEntry } from './gate-math.js';
2
+ import './types.js';
3
+
4
+ type ContentSegment = {
5
+ readonly type: 'text';
6
+ readonly value: string;
7
+ } | {
8
+ readonly type: 'math';
9
+ readonly latex: string;
10
+ };
11
+ /**
12
+ * Split `text` into ordered segments, resolving each math span's glyphs
13
+ * against `scope`. Never throws.
14
+ */
15
+ declare function splitMixedContent(text: string, scope: readonly ScopeEntry[]): ContentSegment[];
16
+
17
+ export { type ContentSegment, splitMixedContent };
@@ -0,0 +1,29 @@
1
+ import { resolveMathGlyphs } from "./math-glyphs.mjs";
2
+ const MATH_SPAN = /\$\$([\s\S]+?)\$\$|(?<![\\$])\$(?!\s)([^$\n]+?)(?<![\\\s])\$(?!\d)/g;
3
+ function splitMixedContent(text, scope) {
4
+ const segments = [];
5
+ let cursor = 0;
6
+ const pattern = new RegExp(MATH_SPAN);
7
+ let match;
8
+ while (match = pattern.exec(text)) {
9
+ if (match.index > cursor) {
10
+ segments.push({ type: "text", value: text.slice(cursor, match.index) });
11
+ }
12
+ const raw = match[1] ?? match[2] ?? "";
13
+ let latex = raw;
14
+ try {
15
+ latex = resolveMathGlyphs(raw, [...scope]).latex;
16
+ } catch {
17
+ latex = raw;
18
+ }
19
+ segments.push({ type: "math", latex });
20
+ cursor = match.index + match[0].length;
21
+ }
22
+ if (cursor < text.length) {
23
+ segments.push({ type: "text", value: text.slice(cursor) });
24
+ }
25
+ return segments;
26
+ }
27
+ export {
28
+ splitMixedContent
29
+ };
@@ -0,0 +1,52 @@
1
+ import { Manifest, ManifestLessonNotation } from '../compiler/manifest.js';
2
+ import '../curriculum/validation.js';
3
+ import './consistency.js';
4
+ import '../compiler/course-config.js';
5
+ import '../compiler/collections.js';
6
+ import './types.js';
7
+ import './lint-ignore.js';
8
+ import './equations-validate.js';
9
+ import './tables-validate.js';
10
+ import './keyed-float-validate.js';
11
+ import './figures-validate.js';
12
+ import './diagrams-validate.js';
13
+ import './knowledge-checks-validate.js';
14
+ import './raw-floats-validate.js';
15
+ import './gate-math.js';
16
+ import './mixed-content.js';
17
+ import './gloss.js';
18
+ import '../compiler/knowledge-version.js';
19
+ import '../compiler/lesson-derivation.js';
20
+
21
+ interface PageNotationDependency {
22
+ readonly key: string;
23
+ readonly context: string;
24
+ }
25
+ /**
26
+ * Record notation keys emitted by a nested server component for this request.
27
+ * The request-local collector is deliberately keyed by `Astro.locals`: static
28
+ * builds render pages concurrently, so a process-global set would leak symbols
29
+ * between lessons and defeat page-local notation scope.
30
+ */
31
+ declare function registerPageNotationDependencies(locals: object, keys: Iterable<string>, context: string): void;
32
+ declare function pageNotationDependencies(locals: object): PageNotationDependency[];
33
+ /** Starlight's route id as exposed to nested Astro components. */
34
+ declare function routeIdFromLocals(locals: object): string | undefined;
35
+ /** Collect the notation keys that survived into a sanitized KaTeX tree. */
36
+ declare function notationKeysInMathNodes(nodes: readonly {
37
+ readonly type: string;
38
+ readonly properties?: Readonly<Record<string, string>>;
39
+ readonly children?: readonly unknown[];
40
+ }[]): string[];
41
+ /**
42
+ * Extend one compiled lesson bundle with only the definitions requested by
43
+ * components rendered on that page (plus those definitions' normal closure).
44
+ */
45
+ declare function notationDefinitionsForRenderedPage(manifest: Manifest, lessonId: string, dependencies: readonly PageNotationDependency[]): ManifestLessonNotation[];
46
+ /**
47
+ * Structural backstop over final HTML: every trigger must have a source entry
48
+ * from which the popup enhancer can populate a card.
49
+ */
50
+ declare function missingNotationPayloadKeys(html: string): string[];
51
+
52
+ export { type PageNotationDependency, missingNotationPayloadKeys, notationDefinitionsForRenderedPage, notationKeysInMathNodes, pageNotationDependencies, registerPageNotationDependencies, routeIdFromLocals };