explico 0.1.1 → 0.3.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 (94) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +46 -5
  3. package/dist/compiler/cli.d.ts +17 -0
  4. package/dist/compiler/cli.js +41 -3
  5. package/dist/compiler/collections.js +31 -1
  6. package/dist/compiler/course-config.d.ts +52 -1
  7. package/dist/compiler/knowledge-version.d.ts +58 -0
  8. package/dist/compiler/knowledge-version.js +80 -0
  9. package/dist/compiler/manifest.d.ts +87 -3
  10. package/dist/compiler/manifest.js +133 -20
  11. package/dist/compiler/resolution-report.d.ts +5 -0
  12. package/dist/reference/base-operators.d.mts +38 -0
  13. package/dist/reference/base-operators.mjs +103 -0
  14. package/dist/reference/consistency.d.ts +7 -2
  15. package/dist/reference/consistency.js +142 -2
  16. package/dist/reference/diagram-layout.d.ts +63 -0
  17. package/dist/reference/diagram-layout.js +122 -0
  18. package/dist/reference/equations-validate.d.ts +5 -0
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/equations.d.mts +76 -18
  21. package/dist/reference/equations.mjs +118 -41
  22. package/dist/reference/figures-validate.d.ts +21 -0
  23. package/dist/reference/figures-validate.js +13 -0
  24. package/dist/reference/gate-math.d.ts +5 -4
  25. package/dist/reference/gate-math.js +73 -2
  26. package/dist/reference/gloss.d.ts +87 -0
  27. package/dist/reference/gloss.js +59 -0
  28. package/dist/reference/keyed-float-validate.d.ts +67 -0
  29. package/dist/reference/keyed-float-validate.js +110 -0
  30. package/dist/reference/keyed-jsx-floats.d.mts +56 -0
  31. package/dist/reference/keyed-jsx-floats.mjs +167 -0
  32. package/dist/reference/math-glyphs.d.mts +18 -0
  33. package/dist/reference/math-glyphs.mjs +92 -0
  34. package/dist/reference/registry.js +25 -0
  35. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  36. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  37. package/dist/reference/remark-notation.d.mts +12 -0
  38. package/dist/reference/remark-notation.mjs +228 -32
  39. package/dist/reference/tables-validate.d.ts +21 -0
  40. package/dist/reference/tables-validate.js +13 -0
  41. package/dist/reference/types.d.ts +25 -2
  42. package/dist/reference/units-vocab.js +19 -1
  43. package/docs/VERSIONING.md +66 -0
  44. package/docs/authoring/README.md +96 -0
  45. package/docs/authoring/agents.md +63 -0
  46. package/docs/authoring/migrating.md +107 -0
  47. package/docs/authoring/notation.md +296 -0
  48. package/docs/authoring/versioning.md +116 -0
  49. package/docs/authoring/visualizations.md +195 -0
  50. package/package.json +20 -5
  51. package/src/compiler/cli.ts +49 -0
  52. package/src/compiler/collections.ts +52 -3
  53. package/src/compiler/course-config.ts +54 -0
  54. package/src/compiler/knowledge-version.ts +157 -0
  55. package/src/compiler/manifest.ts +270 -17
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/HoverPanel.astro +42 -0
  58. package/src/components/citation/CitationLayer.astro +1 -0
  59. package/src/components/diagram/Diagram.astro +317 -0
  60. package/src/components/examples/CompactExamples.astro +1 -1
  61. package/src/components/figure/Figure.astro +104 -0
  62. package/src/components/hover-panel.ts +71 -5
  63. package/src/components/notation/NotationFormula.astro +154 -0
  64. package/src/components/notation/NotationGlossary.astro +60 -0
  65. package/src/components/notation/NotationLayer.astro +248 -103
  66. package/src/components/notation/render-notation-math.ts +66 -0
  67. package/src/components/popup-stack.ts +345 -0
  68. package/src/components/reference/FloatRefLayer.astro +671 -0
  69. package/src/components/starlight/AboutPanel.astro +255 -0
  70. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  71. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  72. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  73. package/src/components/starlight/LessonFooter.astro +8 -2
  74. package/src/components/table/Table.astro +104 -0
  75. package/src/content-config.ts +38 -0
  76. package/src/reference/base-operators.mjs +103 -0
  77. package/src/reference/consistency.ts +236 -2
  78. package/src/reference/diagram-layout.ts +246 -0
  79. package/src/reference/equations-validate.ts +6 -1
  80. package/src/reference/equations.mjs +118 -41
  81. package/src/reference/figures-validate.ts +40 -0
  82. package/src/reference/gate-math.ts +104 -7
  83. package/src/reference/gloss.ts +153 -0
  84. package/src/reference/keyed-float-validate.ts +207 -0
  85. package/src/reference/keyed-jsx-floats.mjs +167 -0
  86. package/src/reference/math-glyphs.mjs +92 -0
  87. package/src/reference/registry.ts +43 -0
  88. package/src/reference/rehype-heading-anchors.mjs +144 -0
  89. package/src/reference/remark-notation.mjs +228 -32
  90. package/src/reference/tables-validate.ts +40 -0
  91. package/src/reference/types.ts +26 -1
  92. package/src/reference/units-vocab.ts +18 -0
  93. package/src/styles/global.css +193 -3
  94. package/src/components/starlight/EquationEnhancer.astro +0 -72
@@ -0,0 +1,110 @@
1
+ import {
2
+ KEYED_FLOAT_KEY_PATTERN,
3
+ parseKeyedFloatRef,
4
+ scanKeyedJsxFloats
5
+ } from "./keyed-jsx-floats.mjs";
6
+ const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
7
+ function stripCode(text) {
8
+ return text.replace(/```[\s\S]*?```/g, " ").replace(/~~~[\s\S]*?~~~/g, " ").replace(/`[^`\n]*`/g, " ");
9
+ }
10
+ function bodyFloatRefs(lesson, prefix) {
11
+ const refs = [];
12
+ for (const match of stripCode(lesson.body).matchAll(BRACKET_REF)) {
13
+ const parsed = parseKeyedFloatRef((match[1] ?? "").trim(), { prefix });
14
+ if (!parsed) continue;
15
+ refs.push({
16
+ key: parsed.key,
17
+ lessonId: lesson.lessonId,
18
+ ...parsed.lesson ? { targetSlug: parsed.lesson } : {}
19
+ });
20
+ }
21
+ return refs;
22
+ }
23
+ function validateKeyedFloats(lessons, kind) {
24
+ const { tagName, prefix, noun } = kind;
25
+ const labels = [];
26
+ const diagnostics = [];
27
+ const numbersBySlug = {};
28
+ for (const lesson of [...lessons].sort(
29
+ (a, b) => a.lessonId.localeCompare(b.lessonId)
30
+ )) {
31
+ const seen = /* @__PURE__ */ new Set();
32
+ const table = {};
33
+ for (const raw of scanKeyedJsxFloats(lesson.body, {
34
+ tagName,
35
+ chapter: lesson.chapter
36
+ })) {
37
+ if (raw.key == null) continue;
38
+ if (!KEYED_FLOAT_KEY_PATTERN.test(raw.key)) {
39
+ diagnostics.push({
40
+ code: `${prefix}-invalid-key`,
41
+ severity: "error",
42
+ message: `${lesson.lessonId} has a <${tagName} id="${raw.key}"> whose id is not a valid key \u2014 expected lowercase letters, digits, and hyphens only.`,
43
+ file: lesson.file,
44
+ lessonId: lesson.lessonId,
45
+ key: raw.key
46
+ });
47
+ continue;
48
+ }
49
+ if (seen.has(raw.key)) {
50
+ diagnostics.push({
51
+ code: `${prefix}-duplicate-key`,
52
+ severity: "error",
53
+ message: `${lesson.lessonId} has two <${tagName}> elements with id="${raw.key}"; an id is unique per lesson.`,
54
+ file: lesson.file,
55
+ lessonId: lesson.lessonId,
56
+ key: raw.key
57
+ });
58
+ continue;
59
+ }
60
+ seen.add(raw.key);
61
+ table[raw.key] = raw.number;
62
+ labels.push({
63
+ key: raw.key,
64
+ number: raw.number,
65
+ lessonId: lesson.lessonId,
66
+ slug: lesson.slug,
67
+ section: raw.section,
68
+ indexInSection: raw.indexInSection
69
+ });
70
+ }
71
+ numbersBySlug[lesson.slug] = table;
72
+ }
73
+ const referenced = /* @__PURE__ */ new Set();
74
+ for (const lesson of lessons) {
75
+ const slug = lesson.slug;
76
+ for (const ref of bodyFloatRefs(lesson, prefix)) {
77
+ const targetSlug = ref.targetSlug ?? slug;
78
+ const number = numbersBySlug[targetSlug]?.[ref.key];
79
+ referenced.add(`${targetSlug}#${ref.key}`);
80
+ if (number === void 0) {
81
+ diagnostics.push({
82
+ code: `${prefix}-ref-resolves`,
83
+ severity: "error",
84
+ message: ref.targetSlug ? `${lesson.lessonId} references [[${ref.targetSlug}#${prefix}-${ref.key}]] but that lesson has no <${tagName} id="${ref.key}">` : `${lesson.lessonId} references [[${prefix}-${ref.key}]] but no <${tagName}> on the page carries id="${ref.key}"`,
85
+ file: lesson.file,
86
+ lessonId: lesson.lessonId,
87
+ key: ref.key
88
+ });
89
+ }
90
+ }
91
+ }
92
+ for (const label of labels) {
93
+ if (referenced.has(`${label.slug}#${label.key}`)) continue;
94
+ diagnostics.push({
95
+ code: `${prefix}-key-unused`,
96
+ severity: "warning",
97
+ message: `${label.lessonId} has a ${noun} id="${label.key}" (${label.number}) but nothing references it with [[${prefix}-${label.key}]]`,
98
+ file: label.slug,
99
+ lessonId: label.lessonId,
100
+ key: label.key
101
+ });
102
+ }
103
+ diagnostics.sort(
104
+ (a, b) => a.code.localeCompare(b.code) || (a.lessonId ?? "").localeCompare(b.lessonId ?? "") || (a.key ?? "").localeCompare(b.key ?? "") || a.message.localeCompare(b.message)
105
+ );
106
+ return { labels, numbersBySlug, diagnostics };
107
+ }
108
+ export {
109
+ validateKeyedFloats
110
+ };
@@ -0,0 +1,56 @@
1
+ /** The anchor id for a float, e.g. `{prefix:'tab', key:'foo'}` → `tab-foo`. */
2
+ export function keyedFloatAnchorId({ key, number, prefix }: {
3
+ key: any;
4
+ number: any;
5
+ prefix: any;
6
+ }): string;
7
+ /**
8
+ * A `[[ … ]]` payload that targets this kind of float: `<prefix>-<key>` on
9
+ * the current page, or `<lesson-slug>#<prefix>-<key>` across pages.
10
+ */
11
+ export function parseKeyedFloatRef(raw: any, { prefix }: {
12
+ prefix: any;
13
+ }): {
14
+ lesson: string;
15
+ key: string;
16
+ } | {
17
+ key: string;
18
+ lesson?: undefined;
19
+ } | undefined;
20
+ /**
21
+ * The raw-text equivalent of `collectKeyedJsxFloats`, mirroring
22
+ * `scanEquationLabels`: every top-level `<TagName …>` in appearance order.
23
+ * With `chapter` (the lesson's course ordinal) the number is `‹chapter›.‹n›`,
24
+ * `n` sequential over the whole lesson; without it, `##` headings advance a
25
+ * section counter. Other JSX components' bodies are blanked first so a
26
+ * `<Table>` nested inside e.g. `<CompactExample>` (and any `##`-like text
27
+ * inside an unrelated component) is never counted.
28
+ */
29
+ export function scanKeyedJsxFloats(markdown: any, { tagName, chapter }: {
30
+ tagName: any;
31
+ chapter: any;
32
+ }): {
33
+ key: string | null | undefined;
34
+ number: string;
35
+ section: number;
36
+ indexInSection: number;
37
+ }[];
38
+ /**
39
+ * The mdast equivalent of `scanKeyedJsxFloats`: walk the tree's *top-level*
40
+ * children in document order, advancing the section counter on every
41
+ * depth-2 heading and emitting a record for every `mdxJsxFlowElement` named
42
+ * `tagName` — keyed or not, exactly like `collectDisplayEquations`. `chapter`
43
+ * is threaded the same way as in `scanKeyedJsxFloats`.
44
+ */
45
+ export function collectKeyedJsxFloats(tree: any, { tagName, chapter }: {
46
+ tagName: any;
47
+ chapter: any;
48
+ }): {
49
+ key: any;
50
+ number: string;
51
+ section: number;
52
+ indexInSection: number;
53
+ node: any;
54
+ }[];
55
+ /** Same shape as `EQ_KEY_PATTERN` — the notation-key grammar without dots. */
56
+ export const KEYED_FLOAT_KEY_PATTERN: RegExp;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Keyed-float identity for a JSX-wrapped table or figure, shared between
3
+ * `<Table>` and `<Figure>` (Step F-tables-figures).
4
+ *
5
+ * This mirrors `equations.mjs`, but the key comes from a plain `id` prop on
6
+ * the wrapper component rather than a `\label{eq:key}` embedded in shared
7
+ * LaTeX source — a `<Table>`/`<Figure>` is purpose-built, so there is no
8
+ * ambiguity an embedded marker would need to resolve. An author writes the
9
+ * bare key (`id="kinematics-summary"`); the rendered DOM id and the `[[…]]`
10
+ * reference prefix are computed the same way `\label{eq:key}` becomes
11
+ * `#eq-key` — `keyedFloatAnchorId` prepends `tab-`/`fig-`.
12
+ *
13
+ * Only top-level occurrences count, exactly like `collectDisplayEquations`:
14
+ * a `<Table>` nested inside a `<CompactExample>` (or any other component) is
15
+ * illustrative, not part of the lesson's official sequence, so both the
16
+ * mdast walker and the raw-text scanner only look at the document's direct
17
+ * top-level blocks.
18
+ *
19
+ * `scanKeyedJsxFloats` (raw Markdown, for the manifest builder) and
20
+ * `collectKeyedJsxFloats` (mdast, for `remark-notation.mjs`) MUST agree —
21
+ * `tests/notation/keyed-floats.test.ts` checks that, the same contract
22
+ * `equations.test.ts` holds equations to.
23
+ */
24
+
25
+ import { stripFencedCode, stripJsxComponents } from './equations.mjs';
26
+
27
+ /** Same shape as `EQ_KEY_PATTERN` — the notation-key grammar without dots. */
28
+ export const KEYED_FLOAT_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
29
+
30
+ /** The visible number for the `index`-th float of one kind under `section`. */
31
+ function floatNumber(section, index) {
32
+ return section > 0 ? `${section}.${index}` : String(index);
33
+ }
34
+
35
+ /**
36
+ * The visible number for one float. With `chapter` (the lesson's course
37
+ * ordinal) the scheme is `‹chapter›.‹indexInLesson›` and `##` headings are
38
+ * ignored; without it, the legacy `##`-section scheme. Mirrors
39
+ * `displayEquationNumber` in `equations.mjs`.
40
+ */
41
+ function keyedFloatNumber({ chapter, section, indexInSection, indexInLesson }) {
42
+ return chapter == null
43
+ ? floatNumber(section, indexInSection)
44
+ : `${chapter}.${indexInLesson}`;
45
+ }
46
+
47
+ /** The anchor id for a float, e.g. `{prefix:'tab', key:'foo'}` → `tab-foo`. */
48
+ export function keyedFloatAnchorId({ key, number, prefix }) {
49
+ return key ? `${prefix}-${key}` : `${prefix}-${String(number).replace(/\./g, '-')}`;
50
+ }
51
+
52
+ /**
53
+ * A `[[ … ]]` payload that targets this kind of float: `<prefix>-<key>` on
54
+ * the current page, or `<lesson-slug>#<prefix>-<key>` across pages.
55
+ */
56
+ export function parseKeyedFloatRef(raw, { prefix }) {
57
+ const pattern = new RegExp(
58
+ `^(?:([a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*)#)?${prefix}-([a-z0-9]+(?:-[a-z0-9]+)*)$`,
59
+ );
60
+ const match = pattern.exec(String(raw).trim());
61
+ if (!match) return undefined;
62
+ return match[1] ? { lesson: match[1], key: match[2] } : { key: match[2] };
63
+ }
64
+
65
+ /** The `id="…"` attribute value of a JSX opening tag's attribute source. */
66
+ function readIdAttribute(attributesSource) {
67
+ const match = /\bid\s*=\s*(["'])([^"']*)\1/.exec(attributesSource);
68
+ return match ? match[2] : undefined;
69
+ }
70
+
71
+ /**
72
+ * The raw-text equivalent of `collectKeyedJsxFloats`, mirroring
73
+ * `scanEquationLabels`: every top-level `<TagName …>` in appearance order.
74
+ * With `chapter` (the lesson's course ordinal) the number is `‹chapter›.‹n›`,
75
+ * `n` sequential over the whole lesson; without it, `##` headings advance a
76
+ * section counter. Other JSX components' bodies are blanked first so a
77
+ * `<Table>` nested inside e.g. `<CompactExample>` (and any `##`-like text
78
+ * inside an unrelated component) is never counted.
79
+ */
80
+ export function scanKeyedJsxFloats(markdown, { tagName, chapter }) {
81
+ const text = stripJsxComponents(stripFencedCode(markdown), tagName);
82
+ const tokens = [];
83
+ const heading = /^[ \t]{0,3}##[ \t]+\S/gm;
84
+ const opening = new RegExp(`<${tagName}\\b([^>]*)>`, 'g');
85
+ for (const match of text.matchAll(heading)) {
86
+ tokens.push({ kind: 'section', index: match.index });
87
+ }
88
+ for (const match of text.matchAll(opening)) {
89
+ const key = readIdAttribute(match[1] ?? '');
90
+ tokens.push({ kind: 'float', index: match.index, key: key ?? null });
91
+ }
92
+ tokens.sort((a, b) => a.index - b.index);
93
+
94
+ const labels = [];
95
+ let section = 0;
96
+ let indexInSection = 0;
97
+ let indexInLesson = 0;
98
+ for (const token of tokens) {
99
+ if (token.kind === 'section') {
100
+ section += 1;
101
+ indexInSection = 0;
102
+ continue;
103
+ }
104
+ indexInSection += 1;
105
+ indexInLesson += 1;
106
+ labels.push({
107
+ key: token.key,
108
+ number: keyedFloatNumber({
109
+ chapter,
110
+ section,
111
+ indexInSection,
112
+ indexInLesson,
113
+ }),
114
+ section,
115
+ indexInSection,
116
+ });
117
+ }
118
+ return labels;
119
+ }
120
+
121
+ /**
122
+ * The mdast equivalent of `scanKeyedJsxFloats`: walk the tree's *top-level*
123
+ * children in document order, advancing the section counter on every
124
+ * depth-2 heading and emitting a record for every `mdxJsxFlowElement` named
125
+ * `tagName` — keyed or not, exactly like `collectDisplayEquations`. `chapter`
126
+ * is threaded the same way as in `scanKeyedJsxFloats`.
127
+ */
128
+ export function collectKeyedJsxFloats(tree, { tagName, chapter }) {
129
+ const floats = [];
130
+ let section = 0;
131
+ let indexInSection = 0;
132
+ let indexInLesson = 0;
133
+
134
+ const children = Array.isArray(tree?.children) ? tree.children : [];
135
+ for (const node of children) {
136
+ if (!node || typeof node !== 'object') continue;
137
+ if (node.type === 'heading' && node.depth === 2) {
138
+ section += 1;
139
+ indexInSection = 0;
140
+ continue;
141
+ }
142
+ if (node.type === 'mdxJsxFlowElement' && node.name === tagName) {
143
+ indexInSection += 1;
144
+ indexInLesson += 1;
145
+ const idAttribute = (node.attributes ?? []).find(
146
+ (attribute) =>
147
+ attribute?.type === 'mdxJsxAttribute' && attribute.name === 'id',
148
+ );
149
+ const key =
150
+ typeof idAttribute?.value === 'string' ? idAttribute.value : undefined;
151
+ floats.push({
152
+ key: key ?? null,
153
+ number: keyedFloatNumber({
154
+ chapter,
155
+ section,
156
+ indexInSection,
157
+ indexInLesson,
158
+ }),
159
+ section,
160
+ indexInSection,
161
+ node,
162
+ });
163
+ }
164
+ }
165
+ return floats;
166
+ }
167
+
@@ -18,6 +18,24 @@ export function resolveMathGlyphs(latex: string, definitions: Array<{
18
18
  }[];
19
19
  unresolved: any;
20
20
  };
21
+ /**
22
+ * Every named operator in one KaTeX expression: a built-in `op` node
23
+ * (`\exp`, `\int`, `\sum`, …) or an author-declared `\operatorname{…}` /
24
+ * `\mathop{…}` (`Var`, `Cov`, …). The `name` is the KaTeX command without its
25
+ * backslash for a built-in, and the verbatim body for a declared one — the
26
+ * form `BASE_OPERATORS` and `course.config.ts` `operators` are keyed by.
27
+ *
28
+ * Pure read: unlike `resolveMathGlyphs`, nothing here rewrites the source.
29
+ * Used by the `operator-explained` consistency check (rule N8); the glyph
30
+ * resolver still skips operator bodies whole.
31
+ *
32
+ * @param {string} latex
33
+ * @returns {Array<{ name: string, source: 'named' | 'operatorname' }>}
34
+ */
35
+ export function collectOperators(latex: string): Array<{
36
+ name: string;
37
+ source: "named" | "operatorname";
38
+ }>;
21
39
  /**
22
40
  * Page-level glyph-map resolver (cleanup plan, D1 decision 2026-09-02).
23
41
  *
@@ -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
+ }
@@ -15,6 +15,7 @@ function buildNotationRegistry(rawInput) {
15
15
  diagnostics
16
16
  );
17
17
  const availableDefinitionIds = new Set(definitions.keys());
18
+ validateGlossKeys(definitions, diagnostics);
18
19
  for (const candidate of definitions.values()) {
19
20
  const references = sortedReferences(candidate.input.references);
20
21
  const resolved = [];
@@ -298,6 +299,26 @@ function normalizeLocalDefinition(input) {
298
299
  references: sortedReferences(input.references)
299
300
  };
300
301
  }
302
+ function validateGlossKeys(definitions, diagnostics) {
303
+ const cardKeys = new Set(
304
+ [...definitions.values()].map((candidate) => candidate.input.key)
305
+ );
306
+ for (const candidate of definitions.values()) {
307
+ for (const gloss of candidate.input.glosses) {
308
+ if (!cardKeys.has(gloss.key)) continue;
309
+ diagnostics.push(
310
+ diagnostic({
311
+ code: "gloss-collides-with-card",
312
+ message: `${candidate.id} glosses ${JSON.stringify(gloss.label)} as ${gloss.key}, which is also a notation card; delete the gloss or rename it`,
313
+ key: gloss.key,
314
+ lessonId: candidate.kind === "local" ? candidate.lesson.lessonId : void 0,
315
+ definitionId: candidate.id,
316
+ source: candidate.input.source
317
+ })
318
+ );
319
+ }
320
+ }
321
+ }
301
322
  function validateReferenceKey(reference, owner, diagnostics) {
302
323
  if (isNotationKey(reference.key)) return true;
303
324
  diagnostics.push(
@@ -371,6 +392,7 @@ function toDefinitionRecord(candidate) {
371
392
  status: input2.status,
372
393
  aiAssisted: input2.aiAssisted,
373
394
  body: input2.body,
395
+ glosses: input2.glosses,
374
396
  source: input2.source,
375
397
  resolvedReferences: candidate.resolvedReferences,
376
398
  ...input2.formula === void 0 ? {} : { formula: input2.formula },
@@ -392,6 +414,7 @@ function toDefinitionRecord(candidate) {
392
414
  seeAlso: input.seeAlso,
393
415
  alignment: input.alignment,
394
416
  status: candidate.lesson.status,
417
+ glosses: input.glosses,
395
418
  source: input.source,
396
419
  resolvedReferences: candidate.resolvedReferences,
397
420
  ...input.formula === void 0 ? {} : { formula: input.formula },
@@ -432,6 +455,7 @@ function definitionFingerprint(candidate) {
432
455
  status: input2.status,
433
456
  aiAssisted: input2.aiAssisted,
434
457
  body: normalizeText(input2.body),
458
+ glosses: input2.glosses,
435
459
  references
436
460
  });
437
461
  }
@@ -447,6 +471,7 @@ function definitionFingerprint(candidate) {
447
471
  sources: [...input.sources].sort(),
448
472
  seeAlso: [...input.seeAlso].sort(),
449
473
  alignment,
474
+ glosses: input.glosses,
450
475
  references
451
476
  });
452
477
  }
@@ -0,0 +1 @@
1
+ export default function rehypeHeadingAnchors(): (tree: any, file: any) => void;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Deep-linkable content headings (Step F3).
3
+ *
4
+ * The lesson pipeline uses a custom Markdown `processor` (see `astro.config.mjs`),
5
+ * which bypasses Starlight's own heading-link rehype pass. This plugin restores
6
+ * it with the *same* markup Starlight emits, so its bundled `anchor-links.css`
7
+ * (shipped globally because `markdown.headingLinks` defaults on) styles it for
8
+ * free — including the hover/focus reveal of an otherwise invisible anchor:
9
+ *
10
+ * <div class="sl-heading-wrapper level-h2">
11
+ * <h2 id="slug">Heading</h2>
12
+ * <a class="sl-anchor-link" href="#slug"> <icon> <sr-only label> </a>
13
+ * </div>
14
+ *
15
+ * The `:target` flash, sticky-header scroll offset, and the JS niceties
16
+ * (re-trigger on repeat activation, smooth scroll, `history.pushState`, brief
17
+ * section highlight) live in `global.css` + `HeadingAnchorEnhancer.astro`, the
18
+ * same split the keyed-equation feature uses.
19
+ *
20
+ * `rehypeHeadingIds` from `@astrojs/markdown-remark` normally runs *after* the
21
+ * user rehype plugins, so heading `id`s do not exist yet when this runs. It is
22
+ * idempotent (only assigns a missing `id`, and rebuilds `file.data.astro.headings`
23
+ * from an unchanged document order), so this plugin runs it first and Astro's
24
+ * later pass becomes a no-op for these headings.
25
+ */
26
+
27
+ import { rehypeHeadingIds } from '@astrojs/markdown-remark';
28
+
29
+ const HEADING_TAG = /^h([1-6])$/;
30
+
31
+ // Starlight's `link-alt` icon (`components-internals/Icons.ts`), the same glyph
32
+ // its own `rehypeAutolinkHeadings` uses.
33
+ const LINK_ICON_PATH =
34
+ 'm12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42';
35
+
36
+ function headingText(node) {
37
+ let text = '';
38
+ const walk = (current) => {
39
+ if (!current || typeof current !== 'object') return;
40
+ if (current.type === 'text' || current.type === 'raw') {
41
+ text += String(current.value ?? '');
42
+ return;
43
+ }
44
+ if (Array.isArray(current.children)) current.children.forEach(walk);
45
+ };
46
+ walk(node);
47
+ return text.replace(/\s+/g, ' ').trim();
48
+ }
49
+
50
+ function isHeadingWrapper(node) {
51
+ const className = node?.properties?.className;
52
+ return (
53
+ node?.type === 'element' &&
54
+ Array.isArray(className) &&
55
+ className.includes('sl-heading-wrapper')
56
+ );
57
+ }
58
+
59
+ function anchorLink(id, label) {
60
+ return {
61
+ type: 'element',
62
+ tagName: 'a',
63
+ properties: { className: ['sl-anchor-link'], href: `#${id}` },
64
+ children: [
65
+ {
66
+ type: 'element',
67
+ tagName: 'span',
68
+ properties: { ariaHidden: 'true', className: ['sl-anchor-icon'] },
69
+ children: [
70
+ {
71
+ type: 'element',
72
+ tagName: 'svg',
73
+ properties: {
74
+ width: 16,
75
+ height: 16,
76
+ viewBox: '0 0 24 24',
77
+ fill: 'currentColor',
78
+ },
79
+ children: [
80
+ {
81
+ type: 'element',
82
+ tagName: 'path',
83
+ properties: { d: LINK_ICON_PATH },
84
+ children: [],
85
+ },
86
+ ],
87
+ },
88
+ ],
89
+ },
90
+ {
91
+ type: 'element',
92
+ tagName: 'span',
93
+ properties: { className: ['sr-only'], 'data-pagefind-ignore': 'true' },
94
+ children: [{ type: 'text', value: label }],
95
+ },
96
+ ],
97
+ };
98
+ }
99
+
100
+ export default function rehypeHeadingAnchors() {
101
+ const assignHeadingIds = rehypeHeadingIds();
102
+
103
+ return function transform(tree, file) {
104
+ assignHeadingIds(tree, file);
105
+
106
+ const walk = (node) => {
107
+ if (!node || typeof node !== 'object' || !Array.isArray(node.children)) {
108
+ return;
109
+ }
110
+ if (isHeadingWrapper(node)) return; // already processed
111
+
112
+ for (let index = 0; index < node.children.length; index++) {
113
+ const child = node.children[index];
114
+ const tag =
115
+ child?.type === 'element' && typeof child.tagName === 'string'
116
+ ? HEADING_TAG.exec(child.tagName)
117
+ : null;
118
+
119
+ if (
120
+ tag &&
121
+ tag[1] !== '1' &&
122
+ typeof child.properties?.id === 'string' &&
123
+ child.properties.id.length > 0
124
+ ) {
125
+ const id = child.properties.id;
126
+ const label = `Section titled “${headingText(child)}”`;
127
+ node.children[index] = {
128
+ type: 'element',
129
+ tagName: 'div',
130
+ properties: {
131
+ className: ['sl-heading-wrapper', `level-${child.tagName}`],
132
+ },
133
+ children: [child, anchorLink(id, label)],
134
+ };
135
+ continue; // the heading is a leaf for our purposes
136
+ }
137
+
138
+ walk(child);
139
+ }
140
+ };
141
+
142
+ walk(tree);
143
+ };
144
+ }