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
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Auto-generated accessible name for a table header cell that is 100%
3
+ * KaTeX-rendered math (N9: a unit is typeset in `$…$` too, so a header like
4
+ * `$t\ \text{(s)}$` has no plain DOM text at all). axe-core's
5
+ * `empty-table-header` rule does not read `<math>` content when computing a
6
+ * `<th>`'s accessible name, so a sighted or MathML-aware reader sees the
7
+ * symbol fine while a screen reader hears nothing.
8
+ *
9
+ * This was first tried with a plain `aria-label` — wrong, confirmed by
10
+ * reading axe-core's own rule (`node_modules/axe-core/axe.js`): unlike most
11
+ * name-checking rules, `empty-table-header` only runs the `has-visible-text`
12
+ * check (`any: ['has-visible-text']`, no `aria-label`/`aria-labelledby`
13
+ * alternative in that list), which calls `subtreeText` — it wants real
14
+ * *content*, not just an accessible name. So this instead inserts a real
15
+ * `<span class="sr-only">` text node as a child of the `<th>`, using
16
+ * Starlight's own `.sr-only` utility (`clip: rect(0,0,0,0)`, not
17
+ * `display:none` — invisible to sighted readers, present in the
18
+ * accessibility tree, satisfying `subtreeText` the same way any other
19
+ * visually-hidden accessible label on this site already does, e.g.
20
+ * `rehype-heading-anchors.mjs`'s anchor labels).
21
+ *
22
+ * The label text is composed straight from the notation metadata already
23
+ * authored for the bound symbol(s): every `\explain{key}{…}` in the header's
24
+ * LaTeX source names a key, and `remark-notation.mjs` has already resolved
25
+ * every key used anywhere on the page into `file.data.notationResolved`
26
+ * (`{key: {label, units, ...}}`) by the time this plugin runs — nothing here
27
+ * re-derives notation scope.
28
+ *
29
+ * Runs after `rehypeKatex` (needs its rendered spans, specifically the
30
+ * `<annotation encoding="application/x-tex">` KaTeX always embeds with the
31
+ * exact source it was given, `\explain{}` calls included — whether the
32
+ * author typed them or `remark-notation.mjs` auto-inserted them).
33
+ */
34
+
35
+ // Mirrors `gloss.ts`'s exported `formulaExplainKeys` regex. Not imported:
36
+ // this file is a plain `.mjs` reference module, copied verbatim into `dist/`
37
+ // by `scripts/copy-assets.mjs`, never touched by the tsup build that
38
+ // compiles `gloss.ts` — no existing `.mjs` in this directory imports a `.ts`
39
+ // sibling's compiled output, so this one doesn't start.
40
+ const EXPLAIN_KEY = /\\explain\s*\{\s*([a-z0-9]+(?:[.-][a-z0-9]+)*)\s*\}/g;
41
+
42
+ function isKatexRoot(node) {
43
+ return (
44
+ node.type === 'element' &&
45
+ node.tagName === 'span' &&
46
+ Array.isArray(node.properties?.className) &&
47
+ node.properties.className.includes('katex')
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Whether `node` already has real, visible text a screen reader would read
53
+ * as this `<th>`'s accessible name — anything outside a `.katex` subtree. A
54
+ * `.katex` span is treated as opaque here on purpose: its `.katex-mathml`
55
+ * child holds the MathML `<math>` axe won't read, and its `.katex-html`
56
+ * child is `aria-hidden="true"`, so neither counts either way — a header
57
+ * that already carries a descriptive word (`Launch speed $\text{(m/s)}$`)
58
+ * sees that word as a normal text-node sibling and is left alone.
59
+ */
60
+ export function hasVisibleText(node) {
61
+ if (!node || typeof node !== 'object') return false;
62
+ if (node.type === 'text') {
63
+ return typeof node.value === 'string' && node.value.trim().length > 0;
64
+ }
65
+ if (node.type !== 'element') return false;
66
+ if (isKatexRoot(node)) return false;
67
+ return (node.children ?? []).some(hasVisibleText);
68
+ }
69
+
70
+ /** Every `application/x-tex` annotation's raw source inside `node`, in order. */
71
+ export function collectMathSource(node, out = []) {
72
+ if (!node || typeof node !== 'object') return out;
73
+ if (
74
+ node.type === 'element' &&
75
+ node.tagName === 'annotation' &&
76
+ node.properties?.encoding === 'application/x-tex'
77
+ ) {
78
+ out.push(
79
+ (node.children ?? [])
80
+ .filter((child) => child.type === 'text')
81
+ .map((child) => child.value)
82
+ .join(''),
83
+ );
84
+ }
85
+ for (const child of node.children ?? []) collectMathSource(child, out);
86
+ return out;
87
+ }
88
+
89
+ /** Every `\explain{key}{…}` key named across one or more LaTeX sources, in order, deduped. */
90
+ export function explainKeysIn(sources) {
91
+ const seen = new Set();
92
+ const keys = [];
93
+ for (const source of sources) {
94
+ EXPLAIN_KEY.lastIndex = 0;
95
+ let match;
96
+ while ((match = EXPLAIN_KEY.exec(source))) {
97
+ const key = match[1];
98
+ if (seen.has(key)) continue;
99
+ seen.add(key);
100
+ keys.push(key);
101
+ }
102
+ }
103
+ return keys;
104
+ }
105
+
106
+ /**
107
+ * `"Label (units)"` per key, joined with `" / "` for a header that binds
108
+ * more than one (a ratio like `\mathrm{KE}/m`, explain-wrapped on both
109
+ * sides). `undefined` when nothing resolved, so the caller leaves the
110
+ * header exactly as it was rather than inserting an empty/placeholder label.
111
+ */
112
+ export function composeHeaderLabel(keys, notationResolved) {
113
+ const parts = keys
114
+ .map((key) => notationResolved[key])
115
+ .filter((entry) => entry && typeof entry.label === 'string')
116
+ .map((entry) => (entry.units ? `${entry.label} (${entry.units})` : entry.label));
117
+ return parts.length > 0 ? parts.join(' / ') : undefined;
118
+ }
119
+
120
+ /** A visually-hidden text node — see the module doc for why this, not `aria-label`. */
121
+ function srOnlySpan(label) {
122
+ return {
123
+ type: 'element',
124
+ tagName: 'span',
125
+ properties: { className: ['sr-only'] },
126
+ children: [{ type: 'text', value: label }],
127
+ };
128
+ }
129
+
130
+ function labelTableHeaders(node, notationResolved) {
131
+ if (!node || typeof node !== 'object') return;
132
+
133
+ if (node.type === 'element' && node.tagName === 'th' && !hasVisibleText(node)) {
134
+ const keys = explainKeysIn(collectMathSource(node));
135
+ const label = composeHeaderLabel(keys, notationResolved);
136
+ if (label) {
137
+ node.children = [srOnlySpan(label), ...(node.children ?? [])];
138
+ }
139
+ }
140
+
141
+ for (const child of node.children ?? []) labelTableHeaders(child, notationResolved);
142
+ }
143
+
144
+ /**
145
+ * See the module doc above. No plugin options — everything it needs is
146
+ * already on `file.data.notationResolved` by the time it runs.
147
+ */
148
+ export default function rehypeTableHeaderLabels() {
149
+ return function addTableHeaderLabels(tree, file) {
150
+ labelTableHeaders(tree, file?.data?.notationResolved ?? {});
151
+ };
152
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @param {{ abbreviations?: readonly object[] | (() => readonly object[]), base?: string }} [options]
3
+ */
4
+ export default function remarkAbbreviations(options?: {
5
+ abbreviations?: readonly object[] | (() => readonly object[]);
6
+ base?: string;
7
+ }): (tree: any) => void;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Remark adapter for the abbreviations table (rule N14).
3
+ *
4
+ * Unlike citations, this has no authoring syntax of its own — N14 already
5
+ * requires every abbreviation-shaped word in prose to either be universal or
6
+ * carry a `content/abbreviations/*.md` entry, so the literal written form
7
+ * (`abbr`, or one of its `aliases`) is itself the trigger. This plugin scans
8
+ * ordinary prose for that literal text and wraps the **first** occurrence per
9
+ * document in a real link (`/glossary/#abbr-<key>`, so it still navigates
10
+ * with no JS) carrying the `data-abbr-key` an island script reads to render a
11
+ * hover/click card, exactly the way `remarkCitation` wraps `[@id]` markers.
12
+ * Later occurrences of the same term on the same page stay plain text, so a
13
+ * common short word isn't turned into a link everywhere it appears.
14
+ *
15
+ * Matches only ever come from the known table by construction, so unlike
16
+ * citation's `[@id]` there is no "unknown id" failure path here.
17
+ *
18
+ * `abbreviations` is either the compiled manifest's `abbreviations` array
19
+ * (`{key, abbr, expansion, aliases, domain?}` records) or a loader that
20
+ * returns it, supplied by the Astro config — same shape citation's `sources`
21
+ * option accepts, re-evaluated for every document transform.
22
+ */
23
+
24
+ const PROSE_SKIP_TYPES = new Set([
25
+ 'code',
26
+ 'definition',
27
+ 'html',
28
+ 'image',
29
+ 'imageReference',
30
+ 'inlineCode',
31
+ 'link',
32
+ 'linkReference',
33
+ 'math',
34
+ 'mdxFlowExpression',
35
+ 'mdxTextExpression',
36
+ 'mdxjsEsm',
37
+ 'toml',
38
+ 'yaml',
39
+ ]);
40
+
41
+ function escapeRegExp(text) {
42
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
+ }
44
+
45
+ // A dotted form (`w.r.t.`) ends in `.`, where a trailing `\b` is unreliable —
46
+ // the same reason `gate-abbreviations.ts`'s own `DOTTED_ABBR` regex has no
47
+ // trailing boundary either. Only add the trailing boundary when the form
48
+ // actually ends in a word character.
49
+ function endsWithWordChar(text) {
50
+ return /\w$/.test(text);
51
+ }
52
+
53
+ /**
54
+ * One case-sensitive alternation regex from every entry's `abbr` + `aliases`,
55
+ * longest-first so a shorter alias that is a prefix of a longer one (`e.g`
56
+ * vs. `e.g.`) never wins the match. Also returns the exact-text → key lookup
57
+ * the matched alternative resolves to.
58
+ */
59
+ function buildMatcher(entries) {
60
+ const alternatives = [];
61
+ const keyByMatch = new Map();
62
+
63
+ for (const entry of entries) {
64
+ const key = entry?.key;
65
+ if (typeof key !== 'string' || key.length === 0) continue;
66
+
67
+ const forms = [entry.abbr, ...(Array.isArray(entry.aliases) ? entry.aliases : [])];
68
+ for (const form of forms) {
69
+ if (typeof form !== 'string' || form.length === 0) continue;
70
+ if (keyByMatch.has(form)) continue;
71
+ keyByMatch.set(form, key);
72
+ alternatives.push(form);
73
+ }
74
+ }
75
+
76
+ if (alternatives.length === 0) return { regex: null, keyByMatch };
77
+
78
+ alternatives.sort((a, b) => b.length - a.length);
79
+ const pattern = alternatives
80
+ .map(
81
+ (form) =>
82
+ `\\b${escapeRegExp(form)}${endsWithWordChar(form) ? '\\b' : ''}`,
83
+ )
84
+ .join('|');
85
+
86
+ return { regex: new RegExp(pattern, 'g'), keyByMatch };
87
+ }
88
+
89
+ function hrefFor(key, base) {
90
+ return `${base.replace(/\/$/, '')}/glossary/#abbr-${key}`;
91
+ }
92
+
93
+ function termNode(key, matchedText, base) {
94
+ return {
95
+ type: 'link',
96
+ url: hrefFor(key, base),
97
+ children: [{ type: 'text', value: matchedText }],
98
+ data: {
99
+ hProperties: {
100
+ className: ['abbreviation-term'],
101
+ 'data-abbr-key': key,
102
+ },
103
+ },
104
+ };
105
+ }
106
+
107
+ /**
108
+ * @param {{ abbreviations?: readonly object[] | (() => readonly object[]), base?: string }} [options]
109
+ */
110
+ export default function remarkAbbreviations(options = {}) {
111
+ return function transform(tree) {
112
+ const suppliedAbbreviations =
113
+ typeof options.abbreviations === 'function'
114
+ ? options.abbreviations()
115
+ : (options.abbreviations ?? []);
116
+ const base = options.base ?? '';
117
+
118
+ const { regex, keyByMatch } = buildMatcher(suppliedAbbreviations);
119
+ if (!regex) return;
120
+
121
+ // Fresh per file, same lifetime as citation's own `pairs` Map: only the
122
+ // first occurrence of a given key on this page gets wrapped.
123
+ const usedKeys = new Set();
124
+
125
+ const replaceText = (value) => {
126
+ regex.lastIndex = 0;
127
+ const output = [];
128
+ let cursor = 0;
129
+ let changed = false;
130
+ let match;
131
+
132
+ while ((match = regex.exec(value))) {
133
+ const key = keyByMatch.get(match[0]);
134
+ if (!key || usedKeys.has(key)) continue;
135
+
136
+ if (match.index > cursor) {
137
+ output.push({ type: 'text', value: value.slice(cursor, match.index) });
138
+ }
139
+ output.push(termNode(key, match[0], base));
140
+ usedKeys.add(key);
141
+ changed = true;
142
+ cursor = match.index + match[0].length;
143
+ }
144
+
145
+ if (!changed) return undefined;
146
+ if (cursor < value.length) {
147
+ output.push({ type: 'text', value: value.slice(cursor) });
148
+ }
149
+ return output;
150
+ };
151
+
152
+ const visit = (node, skipped) => {
153
+ if (!node || typeof node !== 'object' || !Array.isArray(node.children)) {
154
+ return;
155
+ }
156
+ if (skipped || PROSE_SKIP_TYPES.has(node.type)) return;
157
+
158
+ for (let index = 0; index < node.children.length; index++) {
159
+ const child = node.children[index];
160
+ if (child?.type === 'text') {
161
+ const replacement = replaceText(String(child.value ?? ''));
162
+ if (replacement) {
163
+ node.children.splice(index, 1, ...replacement);
164
+ index += replacement.length - 1;
165
+ }
166
+ continue;
167
+ }
168
+ visit(child, false);
169
+ }
170
+ };
171
+
172
+ visit(tree, false);
173
+ };
174
+ }
@@ -0,0 +1,16 @@
1
+ /** A same-page memo reference payload, e.g. `memo-full-derivation`. */
2
+ export function parseMemoRef(raw: any): {
3
+ key: string;
4
+ } | undefined;
5
+ /**
6
+ * Turn otherwise-unparseable inline/block memo syntax into ordinary MDX while
7
+ * retaining author attributes and body source verbatim.
8
+ */
9
+ export function preprocessMemos(source: any): string;
10
+ /**
11
+ * Number memo blocks from one on every page and resolve same-page
12
+ * `[[memo-id]]` references. Numbering is intentionally page-local.
13
+ *
14
+ * @this {any}
15
+ */
16
+ export default function remarkMemo(this: any): (tree: any, file: any) => void;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Remark adapter for substantial, referenceable memo disclosures.
3
+ *
4
+ * Authors place a memo as a normal flow block and cite it from earlier prose:
5
+ *
6
+ * The complete calculation is recorded in [[memo-derivation]].
7
+ *
8
+ * <Memo id="derivation" title="Derivation" description="The algebra.">
9
+ * Several paragraphs, equations, tables, or figures.
10
+ *
11
+ * </Memo>
12
+ *
13
+ * MDX normally rejects this before remark transforms run: an inline JSX node
14
+ * cannot contain block Markdown. The attacher therefore wraps remark-parse's
15
+ * parser and tags each memo before parsing. Legacy inline placements are still
16
+ * split into a temporary marker plus a flow `<Memo>`; standalone blocks do not
17
+ * emit a marker at their authored location. The transformer resolves every
18
+ * `[[memo-id]]` to a numbered link and injects the memo's per-page number and
19
+ * anchor into the real component. Temporary attributes never reach output.
20
+ */
21
+
22
+ import { stripFencedCode } from './equations.mjs';
23
+ import { KEYED_FLOAT_KEY_PATTERN } from './keyed-jsx-floats.mjs';
24
+ import {
25
+ injectMdxStringAttributes,
26
+ mdxAttributesNamed,
27
+ removeMdxAttribute,
28
+ } from './mdx-attributes.mjs';
29
+
30
+ const BLOCK_ATTRIBUTE = 'data-explico-memo-block';
31
+ const MARKER_URL_PREFIX = '#explico-internal-memo-marker-';
32
+ const MEMO_REFERENCE_PATTERN =
33
+ /\[\[\s*memo-([a-z0-9]+(?:-[a-z0-9]+)*)\s*\]\]/g;
34
+
35
+ /** A same-page memo reference payload, e.g. `memo-full-derivation`. */
36
+ export function parseMemoRef(raw) {
37
+ const match = /^memo-([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(String(raw).trim());
38
+ return match ? { key: match[1] } : undefined;
39
+ }
40
+
41
+ // An MDX attribute may be a quoted string, a brace expression (including one
42
+ // level of nested braces), or ordinary unquoted source. Required memo props are
43
+ // validated as literal strings after parsing; this breadth is for preserving
44
+ // unrelated props such as `rememberState={condition}` intact.
45
+ const ATTRIBUTE_SOURCE = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'])*`;
46
+ const MEMO_PAIR = new RegExp(
47
+ String.raw`<Memo\b(${ATTRIBUTE_SOURCE})>([\s\S]*?)<\/Memo\s*>`,
48
+ 'g',
49
+ );
50
+
51
+ function maskBlock(block) {
52
+ return block.replace(/[^\n]/g, ' ');
53
+ }
54
+
55
+ /** Protect examples/comments whose literal `<Memo>` text is not authoring. */
56
+ function searchableMemoSource(source) {
57
+ return stripFencedCode(source)
58
+ .replace(/<!--[\s\S]*?-->/g, maskBlock)
59
+ .replace(/\{\/\*[\s\S]*?\*\/\}/g, maskBlock);
60
+ }
61
+
62
+ function isStandaloneBlock(source, start, end) {
63
+ const lineStart = source.lastIndexOf('\n', start - 1) + 1;
64
+ const followingNewline = source.indexOf('\n', end);
65
+ const lineEnd = followingNewline === -1 ? source.length : followingNewline;
66
+ return (
67
+ source.slice(lineStart, start).trim().length === 0 &&
68
+ source.slice(end, lineEnd).trim().length === 0
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Turn otherwise-unparseable inline/block memo syntax into ordinary MDX while
74
+ * retaining author attributes and body source verbatim.
75
+ */
76
+ export function preprocessMemos(source) {
77
+ const input = String(source);
78
+ const searchable = searchableMemoSource(input);
79
+ const matches = [...searchable.matchAll(MEMO_PAIR)];
80
+ if (matches.length === 0) return input;
81
+
82
+ let cursor = 0;
83
+ let output = '';
84
+ for (let index = 0; index < matches.length; index++) {
85
+ const match = matches[index];
86
+ const start = match.index ?? 0;
87
+ if (start < cursor) continue;
88
+
89
+ const whole = match[0];
90
+ const maskedAttributes = match[1] ?? '';
91
+ const openLength = '<Memo'.length + maskedAttributes.length + 1;
92
+ const closeOffset = whole.lastIndexOf('</Memo');
93
+ const bodyStart = start + openLength;
94
+ const closeStart = start + closeOffset;
95
+ const attributes = input.slice(start + '<Memo'.length, bodyStart - 1);
96
+ const body = input.slice(bodyStart, closeStart);
97
+ const searchableBody = searchable.slice(bodyStart, closeStart);
98
+ if (/<Memo\b/.test(searchableBody)) {
99
+ throw new Error('A <Memo> cannot be nested inside another <Memo>.');
100
+ }
101
+
102
+ const closingTag = input.slice(closeStart, start + whole.length);
103
+ const end = start + whole.length;
104
+ output += input.slice(cursor, start);
105
+ if (isStandaloneBlock(input, start, end)) {
106
+ output += `<Memo ${BLOCK_ATTRIBUTE}="${index}"${attributes}>\n\n`;
107
+ output += body;
108
+ output += `\n\n${closingTag}`;
109
+ } else {
110
+ output += `[memo](${MARKER_URL_PREFIX}${index})\n\n`;
111
+ output += `<Memo ${BLOCK_ATTRIBUTE}="${index}"${attributes}>\n\n`;
112
+ output += body;
113
+ output += `\n\n${closingTag}\n\n`;
114
+ }
115
+ cursor = end;
116
+ }
117
+ output += input.slice(cursor);
118
+ return output;
119
+ }
120
+
121
+ function fail(file, message, node) {
122
+ if (typeof file?.fail === 'function') file.fail(message, node);
123
+ const location = file?.path ? `${file.path}: ` : '';
124
+ throw new Error(`${location}${message}`);
125
+ }
126
+
127
+ function literalAttribute(node, name, file, { optional = false } = {}) {
128
+ const matches = mdxAttributesNamed(node, name);
129
+ if (matches.length > 1) {
130
+ fail(file, `<Memo> has more than one \`${name}\` prop.`, node);
131
+ }
132
+ if (matches.length === 0) {
133
+ if (optional) return undefined;
134
+ fail(file, `<Memo> requires a non-empty literal \`${name}\` prop.`, node);
135
+ }
136
+ const value = matches[0]?.value;
137
+ if (typeof value !== 'string' || !value.trim()) {
138
+ const qualifier = optional ? 'when provided' : '';
139
+ fail(
140
+ file,
141
+ `<Memo> \`${name}\` must be a non-empty literal string${qualifier ? ` ${qualifier}` : ''}; expressions are not supported for build-time memo markers.`,
142
+ matches[0] ?? node,
143
+ );
144
+ }
145
+ return value;
146
+ }
147
+
148
+ function markerNode(memo) {
149
+ const link = {
150
+ type: 'link',
151
+ url: `#${memo.anchorId}`,
152
+ children: [
153
+ {
154
+ type: 'text',
155
+ value: memo.label ?? `[${memo.number}]`,
156
+ },
157
+ ],
158
+ data: {
159
+ hProperties: {
160
+ className: ['memo-marker'],
161
+ 'data-memo-key': memo.id,
162
+ 'data-memo-number': memo.number,
163
+ 'aria-controls': memo.anchorId,
164
+ 'aria-expanded': 'false',
165
+ 'aria-label': memo.label
166
+ ? `${memo.label}: ${memo.title}`
167
+ : `Memo ${memo.number}: ${memo.title}`,
168
+ title: memo.title,
169
+ },
170
+ },
171
+ };
172
+ if (memo.label) return link;
173
+ return {
174
+ type: 'emphasis',
175
+ children: [link],
176
+ data: {
177
+ hName: 'sup',
178
+ hProperties: { className: ['memo-marker-wrap'] },
179
+ },
180
+ };
181
+ }
182
+
183
+ function visit(node, callback, parent) {
184
+ if (!node || typeof node !== 'object') return;
185
+ callback(node, parent);
186
+ if (!Array.isArray(node.children)) return;
187
+ for (const child of node.children) visit(child, callback, node);
188
+ }
189
+
190
+ function rewriteMemoReferences(node, memos, file) {
191
+ if (!node || typeof node !== 'object' || !Array.isArray(node.children)) return;
192
+ if (node.type === 'link' || node.type === 'linkReference') return;
193
+
194
+ for (let index = 0; index < node.children.length; index++) {
195
+ const child = node.children[index];
196
+ if (child?.type !== 'text' || typeof child.value !== 'string') {
197
+ rewriteMemoReferences(child, memos, file);
198
+ continue;
199
+ }
200
+
201
+ const replacements = [];
202
+ let cursor = 0;
203
+ let match;
204
+ MEMO_REFERENCE_PATTERN.lastIndex = 0;
205
+ while ((match = MEMO_REFERENCE_PATTERN.exec(child.value))) {
206
+ if (match.index > cursor) {
207
+ replacements.push({ type: 'text', value: child.value.slice(cursor, match.index) });
208
+ }
209
+ const memo = memos.get(match[1]);
210
+ if (!memo) {
211
+ fail(
212
+ file,
213
+ `Unknown memo reference [[memo-${match[1]}]]: no <Memo> on this page carries id="${match[1]}".`,
214
+ child,
215
+ );
216
+ }
217
+ replacements.push(markerNode({ ...memo, label: undefined }));
218
+ cursor = match.index + match[0].length;
219
+ }
220
+ if (replacements.length === 0) continue;
221
+ if (cursor < child.value.length) {
222
+ replacements.push({ type: 'text', value: child.value.slice(cursor) });
223
+ }
224
+ node.children.splice(index, 1, ...replacements);
225
+ index += replacements.length - 1;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Number memo blocks from one on every page and resolve same-page
231
+ * `[[memo-id]]` references. Numbering is intentionally page-local.
232
+ *
233
+ * @this {any}
234
+ */
235
+ export default function remarkMemo() {
236
+ const processor = this;
237
+ const parse = processor.parser;
238
+ if (typeof parse !== 'function') {
239
+ throw new Error(
240
+ 'remarkMemo must be registered after the Markdown parser (and after remark-mdx when configured explicitly).',
241
+ );
242
+ }
243
+ Reflect.set(processor, 'parser', (document, file) =>
244
+ parse(preprocessMemos(document), file),
245
+ );
246
+
247
+ return function transform(tree, file) {
248
+ const blocks = new Map();
249
+ const markerLocations = new Map();
250
+ const unprocessed = [];
251
+
252
+ visit(tree, (node, parent) => {
253
+ if (
254
+ node.type === 'link' &&
255
+ typeof node.url === 'string' &&
256
+ node.url.startsWith(MARKER_URL_PREFIX)
257
+ ) {
258
+ const token = node.url.slice(MARKER_URL_PREFIX.length);
259
+ if (markerLocations.has(token)) {
260
+ fail(file, 'Internal memo marker collision.', node);
261
+ }
262
+ markerLocations.set(token, { node, parent });
263
+ }
264
+ if (node.name !== 'Memo') return;
265
+ const internal = mdxAttributesNamed(node, BLOCK_ATTRIBUTE);
266
+ if (internal.length === 0) {
267
+ unprocessed.push(node);
268
+ return;
269
+ }
270
+ const token = literalAttribute(node, BLOCK_ATTRIBUTE, file);
271
+ if (blocks.has(token)) fail(file, 'Internal memo block collision.', node);
272
+ blocks.set(token, node);
273
+ });
274
+
275
+ if (unprocessed.length > 0) {
276
+ fail(
277
+ file,
278
+ '<Memo> must use a paired opening and closing tag so remarkMemo can place its inline marker and block body.',
279
+ unprocessed[0],
280
+ );
281
+ }
282
+ for (const token of markerLocations.keys()) {
283
+ if (!blocks.has(token)) fail(file, 'Internal memo marker/block pairing failed.', tree);
284
+ }
285
+
286
+ const seenIds = new Set();
287
+ const memos = new Map();
288
+ let number = 0;
289
+ for (const [token, node] of blocks) {
290
+ const marker = markerLocations.get(token);
291
+ if (marker && (!marker.parent || !Array.isArray(marker.parent.children))) {
292
+ fail(file, 'Internal memo marker has no paragraph parent.', marker.node);
293
+ }
294
+ if (node.type !== 'mdxJsxFlowElement') {
295
+ fail(file, '<Memo> did not become a block-level MDX element.', node);
296
+ }
297
+
298
+ const id = literalAttribute(node, 'id', file);
299
+ const title = literalAttribute(node, 'title', file);
300
+ literalAttribute(node, 'description', file);
301
+ const label = literalAttribute(node, 'label', file, { optional: true });
302
+ if (!KEYED_FLOAT_KEY_PATTERN.test(id)) {
303
+ fail(
304
+ file,
305
+ `Invalid <Memo id="${id}">; use lowercase letters/numbers separated by single hyphens.`,
306
+ node,
307
+ );
308
+ }
309
+ if (seenIds.has(id)) {
310
+ fail(file, `Duplicate <Memo id="${id}"> on this page.`, node);
311
+ }
312
+ seenIds.add(id);
313
+
314
+ number += 1;
315
+ const anchorId = `memo-${id}`;
316
+ removeMdxAttribute(node, BLOCK_ATTRIBUTE);
317
+ injectMdxStringAttributes(node, {
318
+ number: String(number),
319
+ anchorId,
320
+ });
321
+
322
+ const memo = {
323
+ id,
324
+ title,
325
+ label,
326
+ number: String(number),
327
+ anchorId,
328
+ };
329
+ memos.set(id, memo);
330
+
331
+ if (marker) {
332
+ const markerIndex = marker.parent.children.indexOf(marker.node);
333
+ marker.parent.children[markerIndex] = markerNode(memo);
334
+ }
335
+ }
336
+
337
+ rewriteMemoReferences(tree, memos, file);
338
+ };
339
+ }