explico 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +273 -1
  2. package/README.md +103 -8
  3. package/dist/compiler/astro.d.ts +9 -1
  4. package/dist/compiler/astro.js +182 -3
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +28 -4
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +58 -1
  10. package/dist/compiler/course-config.js +28 -1
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/manifest.d.ts +90 -6
  14. package/dist/compiler/manifest.js +150 -17
  15. package/dist/compiler/resolution-report.d.ts +4 -0
  16. package/dist/reference/base-abbreviations.d.mts +30 -0
  17. package/dist/reference/base-abbreviations.mjs +60 -0
  18. package/dist/reference/base-operators.d.mts +15 -0
  19. package/dist/reference/base-operators.mjs +40 -0
  20. package/dist/reference/base-roman-labels.d.mts +35 -0
  21. package/dist/reference/base-roman-labels.mjs +43 -0
  22. package/dist/reference/consistency.d.ts +8 -2
  23. package/dist/reference/consistency.js +50 -3
  24. package/dist/reference/diagrams-validate.d.ts +21 -0
  25. package/dist/reference/diagrams-validate.js +13 -0
  26. package/dist/reference/equations.d.mts +10 -15
  27. package/dist/reference/equations.mjs +67 -14
  28. package/dist/reference/gate-abbreviations.d.ts +17 -0
  29. package/dist/reference/gate-abbreviations.js +64 -0
  30. package/dist/reference/gate-math.d.ts +28 -5
  31. package/dist/reference/gate-math.js +5 -2
  32. package/dist/reference/gate-prose-notation.d.ts +60 -0
  33. package/dist/reference/gate-prose-notation.js +125 -0
  34. package/dist/reference/keyed-float-validate.d.ts +5 -5
  35. package/dist/reference/keyed-jsx-floats.d.mts +7 -12
  36. package/dist/reference/keyed-jsx-floats.mjs +50 -25
  37. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  38. package/dist/reference/knowledge-checks-validate.js +78 -0
  39. package/dist/reference/knowledge-checks.d.mts +25 -0
  40. package/dist/reference/knowledge-checks.mjs +45 -0
  41. package/dist/reference/lab-math-scope.d.ts +9 -1
  42. package/dist/reference/lab-math-scope.js +32 -4
  43. package/dist/reference/math-glyphs.d.mts +30 -0
  44. package/dist/reference/math-glyphs.mjs +367 -0
  45. package/dist/reference/mdx-attributes.d.mts +13 -0
  46. package/dist/reference/mdx-attributes.mjs +38 -0
  47. package/dist/reference/mixed-content.d.ts +17 -0
  48. package/dist/reference/mixed-content.js +29 -0
  49. package/dist/reference/page-notation.d.ts +52 -0
  50. package/dist/reference/page-notation.js +171 -0
  51. package/dist/reference/raw-floats-validate.d.ts +18 -0
  52. package/dist/reference/raw-floats-validate.js +53 -0
  53. package/dist/reference/raw-floats.d.mts +18 -0
  54. package/dist/reference/raw-floats.mjs +129 -0
  55. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  56. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  57. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  58. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  59. package/dist/reference/remark-abbreviations.d.mts +7 -0
  60. package/dist/reference/remark-abbreviations.mjs +174 -0
  61. package/dist/reference/remark-memo.d.mts +16 -0
  62. package/dist/reference/remark-memo.mjs +339 -0
  63. package/dist/reference/remark-notation.d.mts +57 -9
  64. package/dist/reference/remark-notation.mjs +354 -42
  65. package/dist/reference/remark-short-note.d.mts +4 -0
  66. package/dist/reference/remark-short-note.mjs +237 -0
  67. package/dist/reference/types.d.ts +16 -1
  68. package/dist/reference/units-vocab.js +4 -0
  69. package/docs/VERSIONING.md +33 -4
  70. package/docs/authoring/README.md +8 -0
  71. package/docs/authoring/agents.md +17 -2
  72. package/docs/authoring/components.md +135 -0
  73. package/docs/authoring/migrating.md +92 -7
  74. package/docs/authoring/notation.md +560 -1
  75. package/docs/authoring/structure.md +120 -8
  76. package/docs/authoring/visualizations.md +36 -3
  77. package/package.json +5 -2
  78. package/src/compiler/astro.ts +208 -3
  79. package/src/compiler/cli.ts +29 -2
  80. package/src/compiler/collections.ts +45 -4
  81. package/src/compiler/course-config.ts +92 -0
  82. package/src/compiler/exports/standalone-html.ts +963 -0
  83. package/src/compiler/manifest.ts +268 -22
  84. package/src/components/HoverPanel.astro +45 -4
  85. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  86. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  87. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  88. package/src/components/assessments/AssessmentSet.astro +98 -14
  89. package/src/components/assessments/RenderedContent.tsx +55 -0
  90. package/src/components/assessments/assessment-randomization.ts +16 -0
  91. package/src/components/diagram/Diagram.astro +444 -83
  92. package/src/components/disclosure/hover-preview.ts +202 -0
  93. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  94. package/src/components/examples/CompactExamples.astro +2 -0
  95. package/src/components/figure/Figure.astro +279 -10
  96. package/src/components/memo/Memo.astro +453 -0
  97. package/src/components/memo/ShortNote.astro +223 -0
  98. package/src/components/notation/NotationFormula.astro +18 -1
  99. package/src/components/notation/NotationLayer.astro +105 -8
  100. package/src/components/notation/render-notation-math.ts +39 -2
  101. package/src/components/notation/safe-math-to-react.ts +50 -0
  102. package/src/components/reference/FloatRefLayer.astro +233 -15
  103. package/src/components/starlight/CollapsibleSections.astro +461 -0
  104. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  105. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  106. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  107. package/src/components/starlight/LayoutHeader.astro +50 -0
  108. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  109. package/src/components/starlight/LessonExportActions.astro +436 -0
  110. package/src/components/starlight/LessonFooter.astro +27 -2
  111. package/src/components/starlight/section-parts.ts +31 -0
  112. package/src/components/table/Table.astro +293 -7
  113. package/src/components/table/TableOverflowExpander.astro +489 -0
  114. package/src/content-config.ts +20 -0
  115. package/src/reference/base-abbreviations.mjs +60 -0
  116. package/src/reference/base-operators.mjs +40 -0
  117. package/src/reference/base-roman-labels.mjs +43 -0
  118. package/src/reference/consistency.ts +73 -2
  119. package/src/reference/diagrams-validate.ts +40 -0
  120. package/src/reference/equations.mjs +67 -14
  121. package/src/reference/gate-abbreviations.ts +128 -0
  122. package/src/reference/gate-math.ts +27 -7
  123. package/src/reference/gate-prose-notation.ts +224 -0
  124. package/src/reference/keyed-float-validate.ts +5 -5
  125. package/src/reference/keyed-jsx-floats.mjs +50 -25
  126. package/src/reference/knowledge-checks-validate.ts +130 -0
  127. package/src/reference/knowledge-checks.mjs +45 -0
  128. package/src/reference/lab-math-scope.ts +45 -3
  129. package/src/reference/math-glyphs.mjs +367 -0
  130. package/src/reference/mdx-attributes.mjs +38 -0
  131. package/src/reference/mixed-content.ts +69 -0
  132. package/src/reference/page-notation.ts +252 -0
  133. package/src/reference/raw-floats-validate.ts +103 -0
  134. package/src/reference/raw-floats.mjs +129 -0
  135. package/src/reference/rehype-heading-anchors.mjs +107 -1
  136. package/src/reference/rehype-table-header-labels.mjs +152 -0
  137. package/src/reference/remark-abbreviations.mjs +174 -0
  138. package/src/reference/remark-memo.mjs +339 -0
  139. package/src/reference/remark-notation.mjs +354 -42
  140. package/src/reference/remark-short-note.mjs +237 -0
  141. package/src/reference/types.ts +16 -0
  142. package/src/reference/units-vocab.ts +4 -0
  143. package/src/styles/global.css +996 -50
@@ -0,0 +1,252 @@
1
+ import type { Manifest, ManifestLessonNotation } from '../compiler/manifest';
2
+ import {
3
+ formulaExplainKeys,
4
+ formulaGlyphScope,
5
+ type GlyphScopeEntry,
6
+ } from './gloss';
7
+ import { resolveReferenceId } from './references';
8
+ import type { NotationDefinitionRecord } from './types';
9
+
10
+ export interface PageNotationDependency {
11
+ readonly key: string;
12
+ readonly context: string;
13
+ }
14
+
15
+ interface StarlightLocals {
16
+ readonly starlightRoute?: {
17
+ readonly entry?: { readonly id?: unknown };
18
+ };
19
+ }
20
+
21
+ const dependenciesByLocals = new WeakMap<object, Map<string, Set<string>>>();
22
+
23
+ /**
24
+ * Record notation keys emitted by a nested server component for this request.
25
+ * The request-local collector is deliberately keyed by `Astro.locals`: static
26
+ * builds render pages concurrently, so a process-global set would leak symbols
27
+ * between lessons and defeat page-local notation scope.
28
+ */
29
+ export function registerPageNotationDependencies(
30
+ locals: object,
31
+ keys: Iterable<string>,
32
+ context: string,
33
+ ): void {
34
+ let byKey = dependenciesByLocals.get(locals);
35
+ if (!byKey) {
36
+ byKey = new Map();
37
+ dependenciesByLocals.set(locals, byKey);
38
+ }
39
+
40
+ for (const key of keys) {
41
+ let contexts = byKey.get(key);
42
+ if (!contexts) {
43
+ contexts = new Set();
44
+ byKey.set(key, contexts);
45
+ }
46
+ contexts.add(context);
47
+ }
48
+ }
49
+
50
+ export function pageNotationDependencies(
51
+ locals: object,
52
+ ): PageNotationDependency[] {
53
+ const byKey = dependenciesByLocals.get(locals);
54
+ if (!byKey) return [];
55
+ return [...byKey]
56
+ .flatMap(([key, contexts]) =>
57
+ [...contexts].map((context) => ({ key, context })),
58
+ )
59
+ .sort(
60
+ (left, right) =>
61
+ left.key.localeCompare(right.key) ||
62
+ left.context.localeCompare(right.context),
63
+ );
64
+ }
65
+
66
+ /** Starlight's route id as exposed to nested Astro components. */
67
+ export function routeIdFromLocals(locals: object): string | undefined {
68
+ const id = (locals as StarlightLocals).starlightRoute?.entry?.id;
69
+ return typeof id === 'string' ? id : undefined;
70
+ }
71
+
72
+ /** Collect the notation keys that survived into a sanitized KaTeX tree. */
73
+ export function notationKeysInMathNodes(
74
+ nodes: readonly {
75
+ readonly type: string;
76
+ readonly properties?: Readonly<Record<string, string>>;
77
+ readonly children?: readonly unknown[];
78
+ }[],
79
+ ): string[] {
80
+ const keys = new Set<string>();
81
+ const visit = (node: unknown): void => {
82
+ if (!node || typeof node !== 'object') return;
83
+ const candidate = node as {
84
+ properties?: Readonly<Record<string, string>>;
85
+ children?: readonly unknown[];
86
+ };
87
+ const key = candidate.properties?.['data-notation-key'];
88
+ if (key) keys.add(key);
89
+ for (const child of candidate.children ?? []) visit(child);
90
+ };
91
+ for (const node of nodes) visit(node);
92
+ return [...keys].sort();
93
+ }
94
+
95
+ function manifestDefinition(
96
+ definition: NotationDefinitionRecord,
97
+ cardsByKey: ReadonlyMap<string, GlyphScopeEntry>,
98
+ ): ManifestLessonNotation {
99
+ return {
100
+ id: definition.id,
101
+ key: definition.key,
102
+ scope: definition.kind === 'shared' ? 'shared' : 'local',
103
+ notation: definition.notation,
104
+ label: definition.label,
105
+ summary: definition.summary,
106
+ editorialStatus: definition.status,
107
+ seeAlso: [...definition.seeAlso],
108
+ glosses: [...definition.glosses],
109
+ formulaScope: formulaGlyphScope({
110
+ key: definition.key,
111
+ notation: definition.notation,
112
+ glosses: definition.glosses,
113
+ cardsByKey,
114
+ ...(definition.formula === undefined
115
+ ? {}
116
+ : { formula: definition.formula }),
117
+ }),
118
+ ...(definition.units === undefined ? {} : { units: definition.units }),
119
+ ...(definition.formula === undefined
120
+ ? {}
121
+ : { formula: definition.formula }),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Extend one compiled lesson bundle with only the definitions requested by
127
+ * components rendered on that page (plus those definitions' normal closure).
128
+ */
129
+ export function notationDefinitionsForRenderedPage(
130
+ manifest: Manifest,
131
+ lessonId: string,
132
+ dependencies: readonly PageNotationDependency[],
133
+ ): ManifestLessonNotation[] {
134
+ const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
135
+ if (!lesson) return [];
136
+
137
+ const definitionsById = new Map(
138
+ manifest.notation.definitions.map((definition) => [definition.id, definition]),
139
+ );
140
+ const availableIds = new Set(definitionsById.keys());
141
+ const ids = new Set(lesson.notation.definitionIds);
142
+ const contextsByKey = new Map<string, Set<string>>();
143
+
144
+ for (const dependency of dependencies) {
145
+ let contexts = contextsByKey.get(dependency.key);
146
+ if (!contexts) {
147
+ contexts = new Set();
148
+ contextsByKey.set(dependency.key, contexts);
149
+ }
150
+ contexts.add(dependency.context);
151
+
152
+ const definitionId = resolveReferenceId(
153
+ dependency.key,
154
+ { kind: 'page', lessonId },
155
+ availableIds,
156
+ );
157
+ if (!definitionId) {
158
+ throw new Error(
159
+ `Notation payload error in lesson "${lessonId}": ${dependency.context} ` +
160
+ `emitted data-notation-key="${dependency.key}", but that key is not ` +
161
+ `defined in this lesson or the shared notation registry.`,
162
+ );
163
+ }
164
+ ids.add(definitionId);
165
+ }
166
+
167
+ const pending = [...ids];
168
+ while (pending.length > 0) {
169
+ const id = pending.pop();
170
+ if (!id) continue;
171
+ const definition = definitionsById.get(id);
172
+ for (const reference of definition?.resolvedReferences ?? []) {
173
+ if (ids.has(reference.definitionId)) continue;
174
+ ids.add(reference.definitionId);
175
+ pending.push(reference.definitionId);
176
+ }
177
+ // A card formula's explicit symbols are intentionally not curriculum
178
+ // reference edges, but they are UI dependencies: NotationFormula emits
179
+ // triggers for them, so their cards must travel with the parent card.
180
+ for (const key of formulaExplainKeys(definition?.formula ?? '')) {
181
+ if (!definition) continue;
182
+ const formulaDependencyId = resolveReferenceId(
183
+ key,
184
+ definition.scope,
185
+ availableIds,
186
+ );
187
+ // A gloss has no card record; NotationLayer emits its hidden source from
188
+ // the owning definition's `glosses` collection instead.
189
+ if (!formulaDependencyId || ids.has(formulaDependencyId)) continue;
190
+ ids.add(formulaDependencyId);
191
+ pending.push(formulaDependencyId);
192
+ }
193
+ }
194
+
195
+ // A local definition shadows the shared card with the same key. A shared
196
+ // dependency closure must never reintroduce that second meaning on the page.
197
+ const definitionIdByKey = new Map<string, string>();
198
+ for (const id of ids) {
199
+ const definition = definitionsById.get(id);
200
+ if (!definition) continue;
201
+ const previous = definitionIdByKey.get(definition.key);
202
+ if (previous && previous !== id) {
203
+ const context = [...(contextsByKey.get(definition.key) ?? [])].join(', ');
204
+ throw new Error(
205
+ `Notation payload error in lesson "${lessonId}": key "${definition.key}" ` +
206
+ `would have two meanings (${previous} and ${id})` +
207
+ (context ? ` after ${context} emitted it.` : '.'),
208
+ );
209
+ }
210
+ definitionIdByKey.set(definition.key, id);
211
+ }
212
+
213
+ const cardsByKey = new Map<string, GlyphScopeEntry>();
214
+ for (const definition of manifest.notation.definitions) {
215
+ if (definition.kind !== 'shared') continue;
216
+ cardsByKey.set(definition.key, {
217
+ key: definition.key,
218
+ notation: definition.notation,
219
+ });
220
+ }
221
+
222
+ const existingById = new Map(
223
+ lesson.notation.definitions.map((definition) => [definition.id, definition]),
224
+ );
225
+ return [...ids]
226
+ .sort()
227
+ .flatMap((id) => {
228
+ const existing = existingById.get(id);
229
+ if (existing) return [existing];
230
+ const definition = definitionsById.get(id);
231
+ return definition ? [manifestDefinition(definition, cardsByKey)] : [];
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Structural backstop over final HTML: every trigger must have a source entry
237
+ * from which the popup enhancer can populate a card.
238
+ */
239
+ export function missingNotationPayloadKeys(html: string): string[] {
240
+ const attributeValues = (name: string): Set<string> => {
241
+ const values = new Set<string>();
242
+ const pattern = new RegExp(`${name}=(?:"([^"]+)"|'([^']+)')`, 'g');
243
+ for (const match of html.matchAll(pattern)) {
244
+ const value = match[1] ?? match[2];
245
+ if (value) values.add(value);
246
+ }
247
+ return values;
248
+ };
249
+ const triggers = attributeValues('data-notation-key');
250
+ const entries = attributeValues('data-notation-entry');
251
+ return [...triggers].filter((key) => !entries.has(key)).sort();
252
+ }
@@ -0,0 +1,103 @@
1
+ /** Diagnostics for raw/unwrapped floats — see `raw-floats.mjs`. */
2
+
3
+ import { scanRawFloats } from './raw-floats.mjs';
4
+
5
+ export type RawFloatDiagnosticCode =
6
+ | 'raw-table-markdown'
7
+ | 'raw-table-html'
8
+ | 'diagram-unkeyed'
9
+ | 'diagram-nested';
10
+
11
+ export interface RawFloatDiagnostic {
12
+ readonly code: RawFloatDiagnosticCode;
13
+ readonly severity: 'warning';
14
+ readonly message: string;
15
+ readonly file?: string;
16
+ readonly lessonId?: string;
17
+ }
18
+
19
+ export interface RawFloatLessonInput {
20
+ readonly lessonId: string;
21
+ readonly slug: string;
22
+ readonly file: string;
23
+ readonly body: string;
24
+ }
25
+
26
+ function plural(count: number, noun: string): string {
27
+ return count === 1 ? noun : `${noun}s`;
28
+ }
29
+
30
+ export function validateRawFloats(
31
+ lessons: readonly RawFloatLessonInput[],
32
+ ): readonly RawFloatDiagnostic[] {
33
+ const diagnostics: RawFloatDiagnostic[] = [];
34
+
35
+ const push = (
36
+ lesson: RawFloatLessonInput,
37
+ code: RawFloatDiagnosticCode,
38
+ count: number,
39
+ message: (count: number) => string,
40
+ ) => {
41
+ if (count === 0) return;
42
+ diagnostics.push({
43
+ code,
44
+ severity: 'warning',
45
+ message: message(count),
46
+ file: lesson.file,
47
+ lessonId: lesson.lessonId,
48
+ });
49
+ };
50
+
51
+ for (const lesson of [...lessons].sort((a, b) =>
52
+ a.lessonId.localeCompare(b.lessonId),
53
+ )) {
54
+ const found = scanRawFloats(lesson.body);
55
+
56
+ push(
57
+ lesson,
58
+ 'raw-table-markdown',
59
+ found.markdownTableCount,
60
+ (count) =>
61
+ `${lesson.lessonId} has ${count} raw Markdown ${plural(count, 'table')} ` +
62
+ `not wrapped in <Table>; ${count === 1 ? 'it has' : 'they have'} no ` +
63
+ `stable number and cannot be referenced with [[tab-…]]. Wrap it in ` +
64
+ `<Table id="…">…</Table> to give it one.`,
65
+ );
66
+ push(
67
+ lesson,
68
+ 'raw-table-html',
69
+ found.htmlTableCount,
70
+ (count) =>
71
+ `${lesson.lessonId} has ${count} raw HTML <table> ${plural(count, 'element')} ` +
72
+ `not wrapped in <Table>; ${count === 1 ? 'it has' : 'they have'} no ` +
73
+ `stable number and cannot be referenced with [[tab-…]]. Wrap it in ` +
74
+ `<Table id="…">…</Table> to give it one.`,
75
+ );
76
+ push(
77
+ lesson,
78
+ 'diagram-unkeyed',
79
+ found.unkeyedDiagramCount,
80
+ (count) =>
81
+ `${lesson.lessonId} has ${count} <Diagram> ${plural(count, 'component')} ` +
82
+ `with no id; ${count === 1 ? 'it' : 'they'} cannot be referenced with ` +
83
+ `[[dia-…]]. Add an id to give it a stable reference.`,
84
+ );
85
+ push(
86
+ lesson,
87
+ 'diagram-nested',
88
+ found.nestedDiagramCount,
89
+ (count) =>
90
+ `${lesson.lessonId} has ${count} <Diagram> ${plural(count, 'component')} ` +
91
+ `nested inside another component; ${count === 1 ? 'it is' : 'they are'} ` +
92
+ `invisible to diagram numbering. Move it to the top level (or inside ` +
93
+ `<Memo>/<ShortNote>), or wrap it in <Figure> to give it identity.`,
94
+ );
95
+ }
96
+
97
+ diagnostics.sort(
98
+ (a, b) =>
99
+ a.code.localeCompare(b.code) ||
100
+ (a.lessonId ?? '').localeCompare(b.lessonId ?? ''),
101
+ );
102
+ return diagnostics;
103
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Detection for content invisible to keyed-float validation:
3
+ *
4
+ * - a raw Markdown pipe table or raw HTML `<table>` that was never wrapped in
5
+ * `<Table>`/`<Figure>`, so it can never get a stable number or be
6
+ * referenced with `[[tab-…]]`;
7
+ * - an Explico `<Diagram>` with no `id` (can't be referenced with
8
+ * `[[dia-…]]` even though it participates in diagram numbering);
9
+ * - an Explico `<Diagram>` nested inside some other, non-transparent
10
+ * component (anything but `<Memo>`/`<ShortNote>`, or `<Table>`/`<Figure>`
11
+ * which legitimately gives it identity) — invisible to
12
+ * `keyed-jsx-floats.mjs`'s top-level-only scan entirely, so it never even
13
+ * reaches diagram numbering.
14
+ *
15
+ * A raw table nested in `<Table>`/`<Figure>` is the intended, wrapped case
16
+ * and is not flagged; nesting inside anything else (`<CompactExample>`,
17
+ * `<Aside>`, `<Memo>`, …) still is, because a raw table has no numbering
18
+ * scheme of its own to opt into the way a `<Diagram>`'s top-level/`<Memo>`-
19
+ * transparent scan does.
20
+ *
21
+ * `scanRawFloats` (raw Markdown, for the manifest builder) and
22
+ * `collectRawFloats` (mdast, for `remark-notation.mjs`) MUST agree on counts
23
+ * — `tests/notation/raw-floats.test.ts` checks that, the same contract
24
+ * `keyed-jsx-floats.mjs` holds `scanKeyedJsxFloats`/`collectKeyedJsxFloats` to.
25
+ */
26
+
27
+ import { stripFencedCode } from './equations.mjs';
28
+ import { collectKeyedJsxFloats, scanKeyedJsxFloats } from './keyed-jsx-floats.mjs';
29
+
30
+ /** Blank `<Table>…</Table>` and `<Figure>…</Figure>` bodies — legitimately wrapped, not raw. */
31
+ function blankFloatWrappers(text) {
32
+ return text.replace(
33
+ /<(Table|Figure)\b(?:\s[^>]*)?>[\s\S]*?<\/\1\s*>/g,
34
+ (block) => block.replace(/[^\n]/g, ' '),
35
+ );
36
+ }
37
+
38
+ /** Line index of every GFM pipe-table header + delimiter-row pair. */
39
+ function findMarkdownTableStarts(text) {
40
+ const lines = text.split('\n');
41
+ const delimiterRow =
42
+ /^[ \t]{0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
43
+ const hits = [];
44
+ for (let index = 0; index < lines.length - 1; index++) {
45
+ const header = lines[index];
46
+ const delimiter = lines[index + 1];
47
+ if (!header.includes('|')) continue;
48
+ if (!delimiter.includes('-') || !delimiterRow.test(delimiter)) continue;
49
+ hits.push(index);
50
+ }
51
+ return hits;
52
+ }
53
+
54
+ function findHtmlTableTags(text) {
55
+ return [...text.matchAll(/<table\b/gi)];
56
+ }
57
+
58
+ function findDiagramTags(text) {
59
+ return [...text.matchAll(/<Diagram\b/g)];
60
+ }
61
+
62
+ /**
63
+ * Counts of raw/unwrapped content in one lesson body. See the module doc for
64
+ * what each count means and why `<Diagram>` needs the extra visible/nested
65
+ * split that raw tables don't.
66
+ */
67
+ export function scanRawFloats(markdown) {
68
+ const codeStripped = stripFencedCode(String(markdown));
69
+ const withoutWrappedFloats = blankFloatWrappers(codeStripped);
70
+
71
+ const markdownTableCount = findMarkdownTableStarts(withoutWrappedFloats).length;
72
+ const htmlTableCount = findHtmlTableTags(withoutWrappedFloats).length;
73
+
74
+ const diagramsOutsideWrappers = findDiagramTags(withoutWrappedFloats).length;
75
+ const visibleDiagrams = scanKeyedJsxFloats(markdown, { tagName: 'Diagram' });
76
+ const nestedDiagramCount = Math.max(
77
+ 0,
78
+ diagramsOutsideWrappers - visibleDiagrams.length,
79
+ );
80
+ const unkeyedDiagramCount = visibleDiagrams.filter(
81
+ (diagram) => diagram.key == null,
82
+ ).length;
83
+
84
+ return {
85
+ markdownTableCount,
86
+ htmlTableCount,
87
+ nestedDiagramCount,
88
+ unkeyedDiagramCount,
89
+ };
90
+ }
91
+
92
+ /** The mdast equivalent of `scanRawFloats` — same counts, plus the nodes. */
93
+ export function collectRawFloats(tree) {
94
+ const markdownTableNodes = [];
95
+ const htmlTableNodes = [];
96
+ const allDiagramNodes = [];
97
+
98
+ const walk = (node) => {
99
+ if (!node || typeof node !== 'object') return;
100
+ if (
101
+ node.type === 'mdxJsxFlowElement' &&
102
+ (node.name === 'Table' || node.name === 'Figure')
103
+ ) {
104
+ return; // legitimately wrapped — don't descend
105
+ }
106
+ if (node.type === 'table') markdownTableNodes.push(node);
107
+ if (node.type === 'html' && /<table\b/i.test(String(node.value ?? ''))) {
108
+ htmlTableNodes.push(node);
109
+ }
110
+ if (node.type === 'mdxJsxFlowElement' && node.name === 'Diagram') {
111
+ allDiagramNodes.push(node);
112
+ }
113
+ if (Array.isArray(node.children)) {
114
+ for (const child of node.children) walk(child);
115
+ }
116
+ };
117
+ walk(tree);
118
+
119
+ const visibleDiagrams = collectKeyedJsxFloats(tree, { tagName: 'Diagram' });
120
+ const visibleNodes = new Set(visibleDiagrams.map((diagram) => diagram.node));
121
+ const nestedDiagramNodes = allDiagramNodes.filter(
122
+ (node) => !visibleNodes.has(node),
123
+ );
124
+ const unkeyedDiagramNodes = visibleDiagrams
125
+ .filter((diagram) => diagram.key == null)
126
+ .map((diagram) => diagram.node);
127
+
128
+ return { markdownTableNodes, htmlTableNodes, nestedDiagramNodes, unkeyedDiagramNodes };
129
+ }
@@ -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
- export default function rehypeHeadingAnchors() {
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
  };