explico 0.4.0 → 0.6.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.
- package/CHANGELOG.md +382 -1
- package/README.md +103 -8
- package/dist/compiler/astro.d.ts +9 -1
- package/dist/compiler/astro.js +182 -3
- package/dist/compiler/cli.d.ts +13 -0
- package/dist/compiler/cli.js +28 -4
- package/dist/compiler/collections.d.ts +3 -2
- package/dist/compiler/collections.js +28 -1
- package/dist/compiler/course-config.d.ts +88 -1
- package/dist/compiler/course-config.js +35 -1
- package/dist/compiler/exports/standalone-html.d.ts +34 -0
- package/dist/compiler/exports/standalone-html.js +803 -0
- package/dist/compiler/manifest.d.ts +98 -6
- package/dist/compiler/manifest.js +154 -17
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-abbreviations.d.mts +30 -0
- package/dist/reference/base-abbreviations.mjs +60 -0
- package/dist/reference/base-operators.d.mts +15 -0
- package/dist/reference/base-operators.mjs +40 -0
- package/dist/reference/base-roman-labels.d.mts +35 -0
- package/dist/reference/base-roman-labels.mjs +43 -0
- package/dist/reference/consistency.d.ts +8 -2
- package/dist/reference/consistency.js +50 -3
- package/dist/reference/diagrams-validate.d.ts +21 -0
- package/dist/reference/diagrams-validate.js +13 -0
- package/dist/reference/equations.d.mts +10 -15
- package/dist/reference/equations.mjs +67 -14
- package/dist/reference/gate-abbreviations.d.ts +17 -0
- package/dist/reference/gate-abbreviations.js +64 -0
- package/dist/reference/gate-heading-length.d.ts +12 -0
- package/dist/reference/gate-heading-length.js +79 -0
- package/dist/reference/gate-math.d.ts +32 -5
- package/dist/reference/gate-math.js +6 -2
- package/dist/reference/gate-prose-notation.d.ts +60 -0
- package/dist/reference/gate-prose-notation.js +125 -0
- package/dist/reference/keyed-float-validate.d.ts +5 -5
- package/dist/reference/keyed-jsx-floats.d.mts +7 -12
- package/dist/reference/keyed-jsx-floats.mjs +50 -25
- package/dist/reference/knowledge-checks-validate.d.ts +35 -0
- package/dist/reference/knowledge-checks-validate.js +78 -0
- package/dist/reference/knowledge-checks.d.mts +25 -0
- package/dist/reference/knowledge-checks.mjs +45 -0
- package/dist/reference/lab-math-scope.d.ts +9 -1
- package/dist/reference/lab-math-scope.js +32 -4
- package/dist/reference/math-glyphs.d.mts +42 -0
- package/dist/reference/math-glyphs.mjs +548 -40
- package/dist/reference/mdx-attributes.d.mts +13 -0
- package/dist/reference/mdx-attributes.mjs +38 -0
- package/dist/reference/mixed-content.d.ts +17 -0
- package/dist/reference/mixed-content.js +29 -0
- package/dist/reference/page-notation.d.ts +52 -0
- package/dist/reference/page-notation.js +171 -0
- package/dist/reference/raw-floats-validate.d.ts +18 -0
- package/dist/reference/raw-floats-validate.js +53 -0
- package/dist/reference/raw-floats.d.mts +18 -0
- package/dist/reference/raw-floats.mjs +129 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -1
- package/dist/reference/rehype-heading-anchors.mjs +107 -1
- package/dist/reference/rehype-table-header-labels.d.mts +26 -0
- package/dist/reference/rehype-table-header-labels.mjs +152 -0
- package/dist/reference/remark-abbreviations.d.mts +7 -0
- package/dist/reference/remark-abbreviations.mjs +174 -0
- package/dist/reference/remark-citation.d.mts +5 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-memo.d.mts +16 -0
- package/dist/reference/remark-memo.mjs +339 -0
- package/dist/reference/remark-notation.d.mts +57 -9
- package/dist/reference/remark-notation.mjs +354 -42
- package/dist/reference/remark-short-note.d.mts +4 -0
- package/dist/reference/remark-short-note.mjs +237 -0
- package/dist/reference/types.d.ts +16 -1
- package/dist/reference/units-vocab.js +4 -0
- package/docs/VERSIONING.md +53 -4
- package/docs/authoring/README.md +10 -0
- package/docs/authoring/agents.md +17 -2
- package/docs/authoring/components.md +138 -0
- package/docs/authoring/migrating.md +275 -7
- package/docs/authoring/notation.md +615 -1
- package/docs/authoring/structure-rules.md +69 -0
- package/docs/authoring/structure.md +120 -8
- package/docs/authoring/visualizations.md +36 -3
- package/package.json +5 -2
- package/src/compiler/astro.ts +208 -3
- package/src/compiler/cli.ts +29 -2
- package/src/compiler/collections.ts +45 -4
- package/src/compiler/course-config.ts +132 -0
- package/src/compiler/exports/standalone-html.ts +963 -0
- package/src/compiler/manifest.ts +281 -22
- package/src/components/HoverPanel.astro +45 -4
- package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
- package/src/components/assessments/AssessmentRunner.tsx +52 -17
- package/src/components/assessments/AssessmentSet.astro +98 -14
- package/src/components/assessments/RenderedContent.tsx +55 -0
- package/src/components/assessments/assessment-randomization.ts +16 -0
- package/src/components/diagram/Diagram.astro +409 -103
- package/src/components/disclosure/hover-preview.ts +202 -0
- package/src/components/disclosure/persisted-disclosure.ts +66 -0
- package/src/components/examples/CompactExamples.astro +2 -0
- package/src/components/figure/Figure.astro +279 -10
- package/src/components/memo/Memo.astro +453 -0
- package/src/components/memo/ShortNote.astro +223 -0
- package/src/components/notation/NotationFormula.astro +18 -1
- package/src/components/notation/NotationLayer.astro +260 -83
- package/src/components/notation/render-notation-math.ts +39 -2
- package/src/components/notation/safe-math-to-react.ts +50 -0
- package/src/components/reference/FloatRefLayer.astro +233 -15
- package/src/components/starlight/CollapsibleSections.astro +461 -0
- package/src/components/starlight/DiagramEnhancer.astro +385 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
- package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
- package/src/components/starlight/LayoutHeader.astro +50 -0
- package/src/components/starlight/LayoutSocialIcons.astro +41 -2
- package/src/components/starlight/LessonExportActions.astro +436 -0
- package/src/components/starlight/LessonFooter.astro +30 -3
- package/src/components/starlight/section-parts.ts +31 -0
- package/src/components/table/Table.astro +293 -7
- package/src/components/table/TableOverflowExpander.astro +489 -0
- package/src/content-config.ts +36 -1
- package/src/reference/base-abbreviations.mjs +60 -0
- package/src/reference/base-operators.mjs +40 -0
- package/src/reference/base-roman-labels.mjs +43 -0
- package/src/reference/consistency.ts +73 -2
- package/src/reference/diagrams-validate.ts +40 -0
- package/src/reference/equations.mjs +67 -14
- package/src/reference/gate-abbreviations.ts +128 -0
- package/src/reference/gate-heading-length.ts +131 -0
- package/src/reference/gate-math.ts +32 -7
- package/src/reference/gate-prose-notation.ts +224 -0
- package/src/reference/keyed-float-validate.ts +5 -5
- package/src/reference/keyed-jsx-floats.mjs +50 -25
- package/src/reference/knowledge-checks-validate.ts +130 -0
- package/src/reference/knowledge-checks.mjs +45 -0
- package/src/reference/lab-math-scope.ts +45 -3
- package/src/reference/math-glyphs.mjs +548 -40
- package/src/reference/mdx-attributes.mjs +38 -0
- package/src/reference/mixed-content.ts +69 -0
- package/src/reference/page-notation.ts +252 -0
- package/src/reference/raw-floats-validate.ts +103 -0
- package/src/reference/raw-floats.mjs +129 -0
- package/src/reference/rehype-heading-anchors.mjs +107 -1
- package/src/reference/rehype-table-header-labels.mjs +152 -0
- package/src/reference/remark-abbreviations.mjs +174 -0
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-memo.mjs +339 -0
- package/src/reference/remark-notation.mjs +354 -42
- package/src/reference/remark-short-note.mjs +237 -0
- package/src/reference/types.ts +16 -0
- package/src/reference/units-vocab.ts +4 -0
- package/src/styles/global.css +996 -50
|
@@ -22,6 +22,27 @@
|
|
|
22
22
|
* idempotent (only assigns a missing `id`, and rebuilds `file.data.astro.headings`
|
|
23
23
|
* from an unchanged document order), so this plugin runs it first and Astro's
|
|
24
24
|
* later pass becomes a no-op for these headings.
|
|
25
|
+
*
|
|
26
|
+
* Collapsibility (docs/plans/collapsible-disclosure.md Part 3) has a
|
|
27
|
+
* site-wide default — `defaultCollapsible: { sections, subsections }`, an
|
|
28
|
+
* option to this plugin (see `astro.config.mjs`), each of `{ collapsible,
|
|
29
|
+
* defaultOpen }` — plus a per-heading marker an author can append to
|
|
30
|
+
* override it for one heading:
|
|
31
|
+
*
|
|
32
|
+
* - `[collapsible]` — collapsible, starts open, regardless of the default.
|
|
33
|
+
* - `[collapsed]` — collapsible, starts *closed*, regardless of the default.
|
|
34
|
+
* - `[no-collapsible]` — never collapsible, regardless of the default.
|
|
35
|
+
* - no marker — falls back to the site default for this heading's rank:
|
|
36
|
+
* `sections` governs `h2`, `subsections` governs `h3`-`h6`. `collapsible`
|
|
37
|
+
* decides whether it's wrapped at all; `defaultOpen` decides its initial
|
|
38
|
+
* state when it is (ignored, like the rest of the category, when
|
|
39
|
+
* `collapsible` is `false`).
|
|
40
|
+
*
|
|
41
|
+
* The marker is stripped from the rendered text and the *resolved* decision
|
|
42
|
+
* (not the raw marker) becomes `data-collapsible="true"` +
|
|
43
|
+
* `data-default-open="true"|"false"` on the wrapper — `CollapsibleSections.astro`
|
|
44
|
+
* only wraps headings carrying `data-collapsible` and never needs to know
|
|
45
|
+
* about the config or the marker syntax itself.
|
|
25
46
|
*/
|
|
26
47
|
|
|
27
48
|
import { rehypeHeadingIds } from '@astrojs/markdown-remark';
|
|
@@ -47,6 +68,50 @@ function headingText(node) {
|
|
|
47
68
|
return text.replace(/\s+/g, ' ').trim();
|
|
48
69
|
}
|
|
49
70
|
|
|
71
|
+
// A heading's marker, e.g. `## Some title [collapsed]`, stripped from the
|
|
72
|
+
// rendered text. Square brackets, not curly braces (`{collapsed}`) — every
|
|
73
|
+
// lesson here is authored as `.mdx`, where `{...}` is a JS expression
|
|
74
|
+
// container (MDX tries to evaluate `collapsed` as a variable and the build
|
|
75
|
+
// fails), not literal text the way it is in plain Markdown. An unmatched
|
|
76
|
+
// `[collapsed]` with no link/reference definition of that name is just
|
|
77
|
+
// literal text in both dialects. Deliberately not a general attribute syntax
|
|
78
|
+
// (e.g. `{#id .class}`) — this codebase has no remark-attr-style plugin, and
|
|
79
|
+
// three purpose-built tokens are enough for what an author needs to say
|
|
80
|
+
// about a heading here.
|
|
81
|
+
const COLLAPSIBLE_MARKER = /\s*\[(collapsible|collapsed|no-collapsible)\]\s*$/i;
|
|
82
|
+
|
|
83
|
+
function stripCollapsibleMarker(headingNode) {
|
|
84
|
+
const children = headingNode.children;
|
|
85
|
+
for (let index = children.length - 1; index >= 0; index--) {
|
|
86
|
+
const node = children[index];
|
|
87
|
+
if (node.type !== 'text') return null; // trailing non-text node: no marker
|
|
88
|
+
const match = COLLAPSIBLE_MARKER.exec(node.value);
|
|
89
|
+
if (match) {
|
|
90
|
+
node.value = node.value.slice(0, match.index);
|
|
91
|
+
if (!node.value) children.splice(index, 1);
|
|
92
|
+
return match[1].toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
if (node.value.trim()) return null; // real trailing text, no marker present
|
|
95
|
+
// trailing whitespace-only text node: keep looking further back
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a heading's marker (or lack of one) plus the site-wide default for
|
|
102
|
+
* its rank into a final `{ collapsible, defaultOpen }` decision.
|
|
103
|
+
*/
|
|
104
|
+
function resolveCollapsible(marker, tagName, defaultCollapsible) {
|
|
105
|
+
if (marker === 'no-collapsible') return { collapsible: false };
|
|
106
|
+
if (marker === 'collapsed') return { collapsible: true, defaultOpen: false };
|
|
107
|
+
if (marker === 'collapsible') return { collapsible: true, defaultOpen: true };
|
|
108
|
+
const rankDefault =
|
|
109
|
+
tagName === 'h2' ? defaultCollapsible.sections : defaultCollapsible.subsections;
|
|
110
|
+
return rankDefault.collapsible
|
|
111
|
+
? { collapsible: true, defaultOpen: rankDefault.defaultOpen }
|
|
112
|
+
: { collapsible: false };
|
|
113
|
+
}
|
|
114
|
+
|
|
50
115
|
function isHeadingWrapper(node) {
|
|
51
116
|
const className = node?.properties?.className;
|
|
52
117
|
return (
|
|
@@ -97,10 +162,44 @@ function anchorLink(id, label) {
|
|
|
97
162
|
};
|
|
98
163
|
}
|
|
99
164
|
|
|
100
|
-
|
|
165
|
+
const RANK_DEFAULT = { collapsible: false, defaultOpen: true };
|
|
166
|
+
|
|
167
|
+
export default function rehypeHeadingAnchors(options = {}) {
|
|
168
|
+
const defaultCollapsible = {
|
|
169
|
+
sections: { ...RANK_DEFAULT, ...options.defaultCollapsible?.sections },
|
|
170
|
+
subsections: { ...RANK_DEFAULT, ...options.defaultCollapsible?.subsections },
|
|
171
|
+
};
|
|
101
172
|
const assignHeadingIds = rehypeHeadingIds();
|
|
102
173
|
|
|
103
174
|
return function transform(tree, file) {
|
|
175
|
+
// A marker must be stripped *before* `rehypeHeadingIds` runs — otherwise
|
|
176
|
+
// the generated slug is built from text that still contains it (e.g.
|
|
177
|
+
// `two-ways-to-name-the-same-point-collapsed` instead of the clean
|
|
178
|
+
// title). `collapsibleHeadings` records each heading node's *resolved*
|
|
179
|
+
// decision, since the marker text is gone by the time the main wrapping
|
|
180
|
+
// walk below runs.
|
|
181
|
+
const collapsibleHeadings = new WeakMap();
|
|
182
|
+
const stripMarkers = (node) => {
|
|
183
|
+
if (!node || typeof node !== 'object' || !Array.isArray(node.children)) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const child of node.children) {
|
|
187
|
+
if (
|
|
188
|
+
child?.type === 'element' &&
|
|
189
|
+
typeof child.tagName === 'string' &&
|
|
190
|
+
HEADING_TAG.test(child.tagName)
|
|
191
|
+
) {
|
|
192
|
+
const marker = stripCollapsibleMarker(child);
|
|
193
|
+
collapsibleHeadings.set(
|
|
194
|
+
child,
|
|
195
|
+
resolveCollapsible(marker, child.tagName, defaultCollapsible),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
stripMarkers(child);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
stripMarkers(tree);
|
|
202
|
+
|
|
104
203
|
assignHeadingIds(tree, file);
|
|
105
204
|
|
|
106
205
|
const walk = (node) => {
|
|
@@ -123,12 +222,19 @@ export default function rehypeHeadingAnchors() {
|
|
|
123
222
|
child.properties.id.length > 0
|
|
124
223
|
) {
|
|
125
224
|
const id = child.properties.id;
|
|
225
|
+
const decision = collapsibleHeadings.get(child) ?? { collapsible: false };
|
|
126
226
|
const label = `Section titled “${headingText(child)}”`;
|
|
127
227
|
node.children[index] = {
|
|
128
228
|
type: 'element',
|
|
129
229
|
tagName: 'div',
|
|
130
230
|
properties: {
|
|
131
231
|
className: ['sl-heading-wrapper', `level-${child.tagName}`],
|
|
232
|
+
...(decision.collapsible
|
|
233
|
+
? {
|
|
234
|
+
'data-collapsible': 'true',
|
|
235
|
+
'data-default-open': String(decision.defaultOpen),
|
|
236
|
+
}
|
|
237
|
+
: {}),
|
|
132
238
|
},
|
|
133
239
|
children: [child, anchorLink(id, label)],
|
|
134
240
|
};
|
|
@@ -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,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
|
+
}
|
|
Binary file
|