explico 0.7.0 → 0.11.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 (236) hide show
  1. package/CHANGELOG.md +1409 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +54 -3
  5. package/dist/compiler/cli.js +80 -6
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +84 -8
  8. package/dist/compiler/course-config.d.ts +3 -194
  9. package/dist/compiler/course-config.js +78 -5
  10. package/dist/compiler/exports/standalone-html.d.ts +6 -1
  11. package/dist/compiler/knowledge-version.js +1 -1
  12. package/dist/compiler/lesson-derivation.d.ts +27 -10
  13. package/dist/compiler/lesson-derivation.js +23 -8
  14. package/dist/compiler/logging.d.ts +14 -1
  15. package/dist/compiler/logging.js +6 -2
  16. package/dist/compiler/manifest.d.ts +204 -31
  17. package/dist/compiler/manifest.js +270 -55
  18. package/dist/compiler/resolution-report.d.ts +6 -1
  19. package/dist/course-config-Dsnri9Tk.d.ts +464 -0
  20. package/dist/reference/base-accents.d.mts +26 -0
  21. package/dist/reference/base-accents.mjs +31 -0
  22. package/dist/reference/base-roman-labels.d.mts +2 -2
  23. package/dist/reference/base-roman-labels.mjs +2 -2
  24. package/dist/reference/consistency.d.ts +14 -2
  25. package/dist/reference/consistency.js +108 -4
  26. package/dist/reference/delimiter-size.d.ts +21 -11
  27. package/dist/reference/delimiter-size.js +15 -39
  28. package/dist/reference/diagrams-validate.d.ts +7 -2
  29. package/dist/reference/diagrams-validate.js +9 -1
  30. package/dist/reference/equations-validate.d.ts +8 -5
  31. package/dist/reference/equations-validate.js +1 -0
  32. package/dist/reference/equations.d.mts +69 -34
  33. package/dist/reference/equations.mjs +90 -44
  34. package/dist/reference/examples-validate.d.ts +31 -0
  35. package/dist/reference/examples-validate.js +48 -0
  36. package/dist/reference/figures-validate.d.ts +1 -1
  37. package/dist/reference/figures-validate.js +4 -1
  38. package/dist/reference/folds-validate.d.ts +33 -0
  39. package/dist/reference/folds-validate.js +32 -0
  40. package/dist/reference/gate-abbreviations.d.ts +3 -1
  41. package/dist/reference/gate-abbreviations.js +3 -2
  42. package/dist/reference/gate-course-rules.d.ts +32 -0
  43. package/dist/reference/gate-course-rules.js +85 -0
  44. package/dist/reference/gate-heading-length.d.ts +5 -2
  45. package/dist/reference/gate-heading-length.js +47 -23
  46. package/dist/reference/gate-heading-structure.d.ts +41 -0
  47. package/dist/reference/gate-heading-structure.js +113 -0
  48. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  49. package/dist/reference/gate-math-punctuation.js +251 -0
  50. package/dist/reference/gate-math.d.ts +44 -6
  51. package/dist/reference/gate-math.js +63 -10
  52. package/dist/reference/gate-part-pages.d.ts +50 -0
  53. package/dist/reference/gate-part-pages.js +118 -0
  54. package/dist/reference/gate-prose-notation.d.ts +32 -11
  55. package/dist/reference/gate-prose-notation.js +30 -8
  56. package/dist/reference/katex-delimiters.d.mts +15 -0
  57. package/dist/reference/katex-delimiters.mjs +138 -0
  58. package/dist/reference/katex-options.mjs +4 -0
  59. package/dist/reference/keyed-float-validate.d.ts +27 -10
  60. package/dist/reference/keyed-float-validate.js +15 -1
  61. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  62. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  63. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  64. package/dist/reference/knowledge-checks.d.mts +3 -1
  65. package/dist/reference/knowledge-checks.mjs +9 -4
  66. package/dist/reference/lab-math-scope.d.ts +1 -0
  67. package/dist/reference/lab-math-slot.d.ts +34 -0
  68. package/dist/reference/lab-math-slot.js +6 -0
  69. package/dist/reference/math-glyphs.d.mts +80 -4
  70. package/dist/reference/math-glyphs.mjs +588 -39
  71. package/dist/reference/page-notation.d.ts +6 -1
  72. package/dist/reference/raw-floats-validate.js +1 -1
  73. package/dist/reference/raw-floats.mjs +1 -1
  74. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  75. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  76. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  77. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  78. package/dist/reference/remark-asides.d.mts +3 -0
  79. package/dist/reference/remark-asides.mjs +180 -0
  80. package/dist/reference/remark-memo.d.mts +3 -1
  81. package/dist/reference/remark-memo.mjs +42 -11
  82. package/dist/reference/remark-notation.d.mts +60 -10
  83. package/dist/reference/remark-notation.mjs +387 -36
  84. package/dist/reference/render-lab-math.d.ts +4 -26
  85. package/dist/reference/render-lab-math.js +5 -5
  86. package/dist/reference/sections-validate.d.ts +27 -0
  87. package/dist/reference/sections-validate.js +41 -0
  88. package/dist/reference/sections.d.mts +97 -0
  89. package/dist/reference/sections.mjs +306 -0
  90. package/dist/reference/tables-validate.d.ts +1 -1
  91. package/dist/reference/types.d.ts +24 -1
  92. package/dist/reference/unit-words.d.ts +37 -0
  93. package/dist/reference/unit-words.js +170 -0
  94. package/dist/rules/catalog.d.ts +176 -0
  95. package/dist/rules/catalog.js +983 -0
  96. package/dist/rules/course-rules.d.ts +115 -0
  97. package/dist/rules/course-rules.js +292 -0
  98. package/dist/rules/course-rules.schema.json +138 -0
  99. package/dist/rules/in-effect.d.ts +53 -0
  100. package/dist/rules/in-effect.js +142 -0
  101. package/dist/rules/rules.schema.json +1072 -0
  102. package/dist/rules/settings.d.ts +3 -0
  103. package/dist/rules/settings.js +290 -0
  104. package/dist/rules/waivers.d.ts +53 -0
  105. package/dist/rules/waivers.js +132 -0
  106. package/dist/session/PreferenceStore.d.ts +5 -0
  107. package/dist/session/PreferenceStore.js +9 -0
  108. package/docs/VERSIONING.md +2 -2
  109. package/docs/authoring/README.md +232 -40
  110. package/docs/authoring/agents.md +55 -9
  111. package/docs/authoring/components.md +178 -22
  112. package/docs/authoring/migrating.md +1324 -2
  113. package/docs/authoring/notation.md +616 -344
  114. package/docs/authoring/prose.md +551 -0
  115. package/docs/authoring/retired.md +35 -0
  116. package/docs/authoring/structure-rules.md +422 -40
  117. package/docs/authoring/structure.md +445 -62
  118. package/docs/authoring/versioning.md +20 -28
  119. package/docs/authoring/visualizations.md +142 -20
  120. package/docs/katex-bracket-scaling.md +191 -0
  121. package/package.json +3 -2
  122. package/src/compiler/cli.ts +139 -6
  123. package/src/compiler/collections.ts +144 -10
  124. package/src/compiler/course-config.ts +273 -34
  125. package/src/compiler/knowledge-version.ts +1 -1
  126. package/src/compiler/lesson-derivation.ts +54 -15
  127. package/src/compiler/logging.ts +21 -4
  128. package/src/compiler/manifest.ts +573 -104
  129. package/src/components/BlockHeading.astro +113 -0
  130. package/src/components/CourseContents.astro +221 -0
  131. package/src/components/CurriculumMap.astro +5 -6
  132. package/src/components/FloatIndex.astro +200 -0
  133. package/src/components/HoverPanel.astro +431 -79
  134. package/src/components/PartBridge.astro +27 -0
  135. package/src/components/PartOverview.astro +401 -0
  136. package/src/components/PartScope.astro +27 -0
  137. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  138. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  139. package/src/components/assessments/AssessmentSet.astro +23 -26
  140. package/src/components/block-heading.ts +146 -0
  141. package/src/components/card-arrow.ts +243 -0
  142. package/src/components/card-drag.ts +631 -0
  143. package/src/components/card-jump.ts +45 -0
  144. package/src/components/card-manager.ts +1589 -0
  145. package/src/components/card-memory.ts +177 -0
  146. package/src/components/card-placement.ts +504 -0
  147. package/src/components/card-side.ts +336 -0
  148. package/src/components/card-travel.ts +197 -0
  149. package/src/components/citation/CitationLayer.astro +77 -76
  150. package/src/components/diagram/Diagram.astro +161 -316
  151. package/src/components/disclosure/course-disclosure.ts +24 -0
  152. package/src/components/disclosure/hover-preview.ts +122 -42
  153. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  154. package/src/components/disclosure/print-as-shown.ts +252 -0
  155. package/src/components/examples/CompactExample.astro +3 -1
  156. package/src/components/examples/CompactExamples.astro +101 -94
  157. package/src/components/figure/Figure.astro +174 -274
  158. package/src/components/figure/FloatCaption.astro +87 -0
  159. package/src/components/figure/figure-state.ts +58 -0
  160. package/src/components/figure/figure-tools.ts +87 -0
  161. package/src/components/float-name.ts +101 -0
  162. package/src/components/fold/Fold.astro +125 -0
  163. package/src/components/fold/fold-id.ts +40 -0
  164. package/src/components/memo/Memo.astro +162 -231
  165. package/src/components/memo/ShortNote.astro +6 -6
  166. package/src/components/notation/NotationFormula.astro +5 -1
  167. package/src/components/notation/NotationGlossary.astro +24 -14
  168. package/src/components/notation/NotationLayer.astro +169 -162
  169. package/src/components/notation/NotationMath.astro +10 -1
  170. package/src/components/notation/render-notation-math.ts +111 -10
  171. package/src/components/reference/FloatRefLayer.astro +485 -423
  172. package/src/components/starlight/AboutPanel.astro +22 -6
  173. package/src/components/starlight/CollapsibleSections.astro +207 -93
  174. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  175. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  176. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  177. package/src/components/starlight/LayoutHeader.astro +298 -19
  178. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  179. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  180. package/src/components/starlight/LayoutSidebar.astro +229 -11
  181. package/src/components/starlight/LessonExportActions.astro +53 -72
  182. package/src/components/starlight/LessonFooter.astro +26 -4
  183. package/src/components/starlight/section-parts.ts +15 -2
  184. package/src/components/starlight/section-summary.ts +181 -0
  185. package/src/components/table/Table.astro +95 -76
  186. package/src/components/table/TableOverflowExpander.astro +65 -28
  187. package/src/content-config.ts +40 -4
  188. package/src/reference/base-accents.mjs +31 -0
  189. package/src/reference/base-roman-labels.mjs +2 -2
  190. package/src/reference/consistency.ts +176 -5
  191. package/src/reference/delimiter-size.ts +40 -61
  192. package/src/reference/diagrams-validate.ts +17 -1
  193. package/src/reference/equations-validate.ts +10 -5
  194. package/src/reference/equations.mjs +90 -44
  195. package/src/reference/examples-validate.ts +97 -0
  196. package/src/reference/figures-validate.ts +6 -1
  197. package/src/reference/folds-validate.ts +65 -0
  198. package/src/reference/gate-abbreviations.ts +3 -2
  199. package/src/reference/gate-course-rules.ts +134 -0
  200. package/src/reference/gate-heading-length.ts +68 -37
  201. package/src/reference/gate-heading-structure.ts +193 -0
  202. package/src/reference/gate-math-punctuation.ts +381 -0
  203. package/src/reference/gate-math.ts +119 -12
  204. package/src/reference/gate-part-pages.ts +217 -0
  205. package/src/reference/gate-prose-notation.ts +74 -13
  206. package/src/reference/katex-delimiters.mjs +138 -0
  207. package/src/reference/katex-options.mjs +4 -0
  208. package/src/reference/keyed-float-validate.ts +45 -11
  209. package/src/reference/keyed-jsx-floats.mjs +73 -20
  210. package/src/reference/knowledge-checks-validate.ts +1 -1
  211. package/src/reference/knowledge-checks.mjs +9 -4
  212. package/src/reference/lab-math-slot.ts +36 -0
  213. package/src/reference/math-glyphs.mjs +588 -39
  214. package/src/reference/raw-floats-validate.ts +1 -1
  215. package/src/reference/raw-floats.mjs +1 -1
  216. package/src/reference/rehype-heading-anchors.mjs +116 -17
  217. package/src/reference/rehype-table-header-labels.mjs +21 -3
  218. package/src/reference/remark-asides.mjs +180 -0
  219. package/src/reference/remark-memo.mjs +42 -11
  220. package/src/reference/remark-notation.mjs +387 -36
  221. package/src/reference/render-lab-math.ts +17 -32
  222. package/src/reference/sections-validate.ts +81 -0
  223. package/src/reference/sections.mjs +306 -0
  224. package/src/reference/tables-validate.ts +2 -1
  225. package/src/reference/types.ts +24 -0
  226. package/src/reference/unit-words.ts +233 -0
  227. package/src/rules/catalog.ts +1298 -0
  228. package/src/rules/course-rules.ts +456 -0
  229. package/src/rules/in-effect.ts +235 -0
  230. package/src/rules/settings.ts +448 -0
  231. package/src/rules/waivers.ts +249 -0
  232. package/src/session/PreferenceStore.ts +15 -0
  233. package/src/styles/global.css +1471 -163
  234. package/src/styles/surfaces.css +182 -0
  235. package/src/components/hover-panel.ts +0 -339
  236. package/src/components/popup-stack.ts +0 -345
@@ -90,7 +90,7 @@ export function validateRawFloats(
90
90
  `${lesson.lessonId} has ${count} <Diagram> ${plural(count, 'component')} ` +
91
91
  `nested inside another component; ${count === 1 ? 'it is' : 'they are'} ` +
92
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.`,
93
+ `<Memo>/<ShortNote>/<Fold>), or wrap it in <Figure> to give it identity.`,
94
94
  );
95
95
  }
96
96
 
@@ -7,7 +7,7 @@
7
7
  * - an Explico `<Diagram>` with no `id` (can't be referenced with
8
8
  * `[[dia-…]]` even though it participates in diagram numbering);
9
9
  * - an Explico `<Diagram>` nested inside some other, non-transparent
10
- * component (anything but `<Memo>`/`<ShortNote>`, or `<Table>`/`<Figure>`
10
+ * component (anything but `<Memo>`/`<ShortNote>`/`<Fold>`, or `<Table>`/`<Figure>`
11
11
  * which legitimately gives it identity) — invisible to
12
12
  * `keyed-jsx-floats.mjs`'s top-level-only scan entirely, so it never even
13
13
  * reaches diagram numbering.
@@ -43,6 +43,21 @@
43
43
  * `data-default-open="true"|"false"` on the wrapper — `CollapsibleSections.astro`
44
44
  * only wraps headings carrying `data-collapsible` and never needs to know
45
45
  * about the config or the marker syntax itself.
46
+ *
47
+ * A second kind of marker cards the whole section — heading and body — in a
48
+ * tier from `surfaces.css` (docs/plans/titles-and-containers.md Part 2):
49
+ * `[card]` is the accent tier, `[card=meta]` the meta tier. It becomes
50
+ * `data-card="accent"|"meta"` on the wrapper, which `CollapsibleSections.astro`
51
+ * reads the same way. A heading may carry one marker of each kind, in either
52
+ * order (`### Explore the range [collapsed] [card]`).
53
+ *
54
+ * A lesson's top-level headings arrive here already carrying their `id` and,
55
+ * when the course shows section numbers, `data-sec-number="1.1.2"` — both put
56
+ * on the mdast node by `remark-notation.mjs` from the manifest (`sections.mjs`
57
+ * decides them), so a `[[sec-…]]` reference and the heading it points at read
58
+ * one table. `global.css` renders the number; this plugin leaves both alone.
59
+ * A heading that arrives without an id (one inside a component) still gets
60
+ * Astro's slug, renamed only if it would repeat an id given upstream.
46
61
  */
47
62
 
48
63
  import { rehypeHeadingIds } from '@astrojs/markdown-remark';
@@ -76,25 +91,72 @@ function headingText(node) {
76
91
  // `[collapsed]` with no link/reference definition of that name is just
77
92
  // literal text in both dialects. Deliberately not a general attribute syntax
78
93
  // (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;
94
+ // a handful of purpose-built tokens are enough for what an author needs to
95
+ // say about a heading here.
96
+ const HEADING_MARKER = /\s*\[(collapsible|collapsed|no-collapsible|card|card=meta)\]\s*$/i;
97
+
98
+ const CARD_TIER = { card: 'accent', 'card=meta': 'meta' };
82
99
 
83
- function stripCollapsibleMarker(headingNode) {
100
+ /**
101
+ * A heading's source text without its trailing markers: what the reader sees,
102
+ * and what the manifest slugs the heading's anchor from (`sections.mjs`) — so
103
+ * `### Explore the range [card]` is anchored `#explore-the-range`.
104
+ */
105
+ export function stripHeadingMarker(text) {
106
+ let rest = String(text);
107
+ for (let match = HEADING_MARKER.exec(rest); match; match = HEADING_MARKER.exec(rest)) {
108
+ rest = rest.slice(0, match.index);
109
+ }
110
+ return rest;
111
+ }
112
+
113
+ /**
114
+ * The card tier a heading's trailing markers ask for, read from its source
115
+ * text: `'accent'` for `[card]`, `'meta'` for `[card=meta]`, `null` for
116
+ * neither. The outline reads it (`sections.mjs`): a meta section is
117
+ * logistics, so it is not numbered. With both, the one written last wins, as
118
+ * in `stripMarkers`.
119
+ */
120
+ export function headingCardTier(text) {
121
+ let rest = String(text);
122
+ let tier = null;
123
+ for (let match = HEADING_MARKER.exec(rest); match; match = HEADING_MARKER.exec(rest)) {
124
+ const marker = match[1].toLowerCase();
125
+ if (marker in CARD_TIER) tier ??= CARD_TIER[marker];
126
+ rest = rest.slice(0, match.index);
127
+ }
128
+ return tier;
129
+ }
130
+
131
+ /**
132
+ * Strip every trailing marker off a hast heading and report them as
133
+ * `{ collapse, card }` — the collapse marker and the card tier, each `null`
134
+ * when absent. With two of one kind, the one written last wins.
135
+ */
136
+ function stripMarkers(headingNode) {
137
+ const found = { collapse: null, card: null };
84
138
  const children = headingNode.children;
85
139
  for (let index = children.length - 1; index >= 0; index--) {
86
140
  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);
141
+ if (node.type !== 'text') break; // trailing non-text node: no more markers
142
+ const match = HEADING_MARKER.exec(node.value);
89
143
  if (match) {
144
+ const marker = match[1].toLowerCase();
145
+ if (marker in CARD_TIER) found.card ??= CARD_TIER[marker];
146
+ else found.collapse ??= marker;
90
147
  node.value = node.value.slice(0, match.index);
91
148
  if (!node.value) children.splice(index, 1);
92
- return match[1].toLowerCase();
149
+ else index += 1; // look at the same node again: markers can stack
150
+ continue;
93
151
  }
94
- if (node.value.trim()) return null; // real trailing text, no marker present
152
+ if (node.value.trim()) break; // real trailing text, no marker present
95
153
  // trailing whitespace-only text node: keep looking further back
96
154
  }
97
- return null;
155
+ const last = children.at(-1);
156
+ if ((found.collapse || found.card) && last?.type === 'text') {
157
+ last.value = last.value.trimEnd();
158
+ }
159
+ return found;
98
160
  }
99
161
 
100
162
  /**
@@ -172,14 +234,20 @@ export default function rehypeHeadingAnchors(options = {}) {
172
234
  const assignHeadingIds = rehypeHeadingIds();
173
235
 
174
236
  return function transform(tree, file) {
175
- // A marker must be stripped *before* `rehypeHeadingIds` runs — otherwise
237
+ // Markers must be stripped *before* `rehypeHeadingIds` runs — otherwise
176
238
  // the generated slug is built from text that still contains it (e.g.
177
239
  // `two-ways-to-name-the-same-point-collapsed` instead of the clean
178
240
  // 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.
241
+ // decision and `cardHeadings` its card tier, since the marker text is
242
+ // gone by the time the main wrapping walk below runs.
181
243
  const collapsibleHeadings = new WeakMap();
182
- const stripMarkers = (node) => {
244
+ const cardHeadings = new WeakMap();
245
+ // Every heading in document order, and the ones whose id was given
246
+ // upstream (a lesson's top-level headings, from the manifest; the
247
+ // generated "References").
248
+ const headings = [];
249
+ const given = new Set();
250
+ const readMarkers = (node) => {
183
251
  if (!node || typeof node !== 'object' || !Array.isArray(node.children)) {
184
252
  return;
185
253
  }
@@ -189,19 +257,48 @@ export default function rehypeHeadingAnchors(options = {}) {
189
257
  typeof child.tagName === 'string' &&
190
258
  HEADING_TAG.test(child.tagName)
191
259
  ) {
192
- const marker = stripCollapsibleMarker(child);
260
+ const markers = stripMarkers(child);
193
261
  collapsibleHeadings.set(
194
262
  child,
195
- resolveCollapsible(marker, child.tagName, defaultCollapsible),
263
+ resolveCollapsible(markers.collapse, child.tagName, defaultCollapsible),
196
264
  );
265
+ if (markers.card) cardHeadings.set(child, markers.card);
266
+ headings.push(child);
267
+ const id = child.properties?.id;
268
+ if (typeof id === 'string' && id.length > 0) given.add(child);
197
269
  }
198
- stripMarkers(child);
270
+ readMarkers(child);
199
271
  }
200
272
  };
201
- stripMarkers(tree);
273
+ readMarkers(tree);
202
274
 
203
275
  assignHeadingIds(tree, file);
204
276
 
277
+ // Astro's slugger never saw the ids given upstream, so a heading inside a
278
+ // component that shares a top-level heading's text would get the same
279
+ // id. The upstream one keeps it (a `[[sec-…]]` points there); this one
280
+ // takes the next free `-n`, as the slugger itself would have. The second
281
+ // `assignHeadingIds` only rebuilds `file.data.astro.headings` from the
282
+ // ids as they now stand.
283
+ if (given.size > 0) {
284
+ const taken = new Set([...given].map((heading) => heading.properties.id));
285
+ let renamed = false;
286
+ for (const heading of headings) {
287
+ const id = heading.properties?.id;
288
+ if (given.has(heading) || typeof id !== 'string') continue;
289
+ let unique = id;
290
+ for (let suffix = 1; taken.has(unique); suffix += 1) {
291
+ unique = `${id}-${suffix}`;
292
+ }
293
+ if (unique !== id) {
294
+ heading.properties.id = unique;
295
+ renamed = true;
296
+ }
297
+ taken.add(unique);
298
+ }
299
+ if (renamed) assignHeadingIds(tree, file);
300
+ }
301
+
205
302
  const walk = (node) => {
206
303
  if (!node || typeof node !== 'object' || !Array.isArray(node.children)) {
207
304
  return;
@@ -223,6 +320,7 @@ export default function rehypeHeadingAnchors(options = {}) {
223
320
  ) {
224
321
  const id = child.properties.id;
225
322
  const decision = collapsibleHeadings.get(child) ?? { collapsible: false };
323
+ const card = cardHeadings.get(child);
226
324
  const label = `Section titled “${headingText(child)}”`;
227
325
  node.children[index] = {
228
326
  type: 'element',
@@ -235,6 +333,7 @@ export default function rehypeHeadingAnchors(options = {}) {
235
333
  'data-default-open': String(decision.defaultOpen),
236
334
  }
237
335
  : {}),
336
+ ...(card ? { 'data-card': card } : {}),
238
337
  },
239
338
  children: [child, anchorLink(id, label)],
240
339
  };
@@ -32,6 +32,8 @@
32
32
  * author typed them or `remark-notation.mjs` auto-inserted them).
33
33
  */
34
34
 
35
+ import { decorationOfEntry } from './math-glyphs.mjs';
36
+
35
37
  // Mirrors `gloss.ts`'s exported `formulaExplainKeys` regex. Not imported:
36
38
  // this file is a plain `.mjs` reference module, copied verbatim into `dist/`
37
39
  // by `scripts/copy-assets.mjs`, never touched by the tsup build that
@@ -108,12 +110,28 @@ export function explainKeysIn(sources) {
108
110
  * more than one (a ratio like `\mathrm{KE}/m`, explain-wrapped on both
109
111
  * sides). `undefined` when nothing resolved, so the caller leaves the
110
112
  * header exactly as it was rather than inserting an empty/placeholder label.
113
+ *
114
+ * An operator entry (`latex: \hat{}`, rule N22) wraps the symbol it
115
+ * decorates, so its key comes just before that symbol's: the two read as one
116
+ * phrase, `"Estimate of regression coefficient"`. The symbol's units are
117
+ * dropped there, since the operator may change them (a time derivative does).
111
118
  */
112
119
  export function composeHeaderLabel(keys, notationResolved) {
113
- const parts = keys
120
+ const entries = keys
114
121
  .map((key) => notationResolved[key])
115
- .filter((entry) => entry && typeof entry.label === 'string')
116
- .map((entry) => (entry.units ? `${entry.label} (${entry.units})` : entry.label));
122
+ .filter((entry) => entry && typeof entry.label === 'string');
123
+ const parts = [];
124
+ for (let index = 0; index < entries.length; index++) {
125
+ const entry = entries[index];
126
+ const operand = entries[index + 1];
127
+ if (operand && decorationOfEntry(entry.notation) !== undefined) {
128
+ const name = operand.label.charAt(0).toLowerCase() + operand.label.slice(1);
129
+ parts.push(`${entry.label} of ${name}`);
130
+ index += 1;
131
+ continue;
132
+ }
133
+ parts.push(entry.units ? `${entry.label} (${entry.units})` : entry.label);
134
+ }
117
135
  return parts.length > 0 ? parts.join(' / ') : undefined;
118
136
  }
119
137
 
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Starlight asides in an explico course (docs/plans/titles-and-containers.md
3
+ * Q10):
4
+ *
5
+ * :::note[Optional title]{icon="heart"}
6
+ * Body Markdown.
7
+ * :::
8
+ *
9
+ * **Why explico renders them.** Starlight turns these directives into asides
10
+ * only for files under its own `src/content/docs/` (or a
11
+ * `markdown.processedDirs` entry). An explico course keeps its lessons in
12
+ * `content/docs/`, and adding that directory to `processedDirs` would also
13
+ * run Starlight's heading-link pass over headings `rehype-heading-anchors.mjs`
14
+ * has already wrapped, giving every heading two anchors. So this pass turns
15
+ * each `:::note` / `:::tip` / `:::caution` / `:::danger` into Starlight's own
16
+ * `<Aside>` component (importing it into the page): Starlight still draws the
17
+ * aside, its translated default title, its icon per type, and its check that
18
+ * a named icon exists. `surfaces.css` gives it the aside tier's look.
19
+ *
20
+ * A `.md` lesson has no JSX, so there the aside is written out as the same
21
+ * markup with no icon — Starlight's icon set is not a public export.
22
+ *
23
+ * **An empty icon drops it.** Starlight reads `icon=""` as "the type's
24
+ * default", so an author has no way to say "none". Here `:::note{icon=""}`
25
+ * and `<Aside icon="">` lose the attribute and are wrapped in
26
+ * `<div class="ex-aside-no-icon">`, which `surfaces.css` keys the icon's
27
+ * removal on.
28
+ *
29
+ * Runs last in the course's `remarkPlugins`, after `remarkNotation` has
30
+ * resolved the `[[…]]` references inside an aside's body. Starlight appends its
31
+ * own plugins after the course's, so if a course's lessons do sit under
32
+ * Starlight's path, its aside pass finds nothing left to convert.
33
+ */
34
+
35
+ const ASIDE_TYPES = new Set(['note', 'tip', 'caution', 'danger']);
36
+ const DEFAULT_TITLES = { note: 'Note', tip: 'Tip', caution: 'Caution', danger: 'Danger' };
37
+ export const NO_ICON_CLASS = 'ex-aside-no-icon';
38
+ export const ASIDE_COMPONENT = 'ExplicoStarlightAside';
39
+
40
+ function textOf(node) {
41
+ if (typeof node?.value === 'string') return node.value;
42
+ return (node?.children ?? []).map(textOf).join('');
43
+ }
44
+
45
+ function isAsideDirective(node) {
46
+ return node?.type === 'containerDirective' && ASIDE_TYPES.has(node.name);
47
+ }
48
+
49
+ function isEmptyIconComponent(node) {
50
+ return (
51
+ node?.type === 'mdxJsxFlowElement' &&
52
+ node.name === 'Aside' &&
53
+ (node.attributes ?? []).some(
54
+ (attribute) =>
55
+ attribute?.type === 'mdxJsxAttribute' &&
56
+ attribute.name === 'icon' &&
57
+ attribute.value === '',
58
+ )
59
+ );
60
+ }
61
+
62
+ function noIconWrapper(node) {
63
+ return {
64
+ type: 'explicoAsideNoIcon',
65
+ data: { hName: 'div', hProperties: { className: [NO_ICON_CLASS] } },
66
+ children: [node],
67
+ };
68
+ }
69
+
70
+ /** The directive's `[label]` paragraph, removed from its body. */
71
+ function takeLabel(node) {
72
+ const first = node.children?.[0];
73
+ if (first?.type === 'paragraph' && first.data?.directiveLabel) {
74
+ node.children.splice(0, 1);
75
+ return first.children;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function asComponent(node) {
81
+ const type = node.name;
82
+ const icon = node.attributes?.icon;
83
+ const label = takeLabel(node);
84
+ const attributes = [{ type: 'mdxJsxAttribute', name: 'type', value: type }];
85
+ if (label) {
86
+ attributes.push({ type: 'mdxJsxAttribute', name: 'title', value: label.map(textOf).join('') });
87
+ }
88
+ if (icon) attributes.push({ type: 'mdxJsxAttribute', name: 'icon', value: icon });
89
+ const aside = {
90
+ type: 'mdxJsxFlowElement',
91
+ name: ASIDE_COMPONENT,
92
+ attributes,
93
+ children: node.children,
94
+ position: node.position,
95
+ };
96
+ return icon === '' ? noIconWrapper(aside) : aside;
97
+ }
98
+
99
+ function asMarkup(node) {
100
+ const type = node.name;
101
+ const label = takeLabel(node);
102
+ const title = label ?? [{ type: 'text', value: DEFAULT_TITLES[type] }];
103
+ return {
104
+ type: 'explicoAside',
105
+ data: {
106
+ hName: 'aside',
107
+ hProperties: {
108
+ ariaLabel: label ? label.map(textOf).join('') : DEFAULT_TITLES[type],
109
+ className: ['starlight-aside', `starlight-aside--${type}`],
110
+ },
111
+ },
112
+ position: node.position,
113
+ children: [
114
+ {
115
+ type: 'paragraph',
116
+ data: { hProperties: { className: ['starlight-aside__title'], ariaHidden: 'true' } },
117
+ children: title,
118
+ },
119
+ {
120
+ type: 'explicoAsideContent',
121
+ data: { hName: 'div', hProperties: { className: ['starlight-aside__content'] } },
122
+ children: node.children,
123
+ },
124
+ ],
125
+ };
126
+ }
127
+
128
+ /** `import { Aside as ExplicoStarlightAside } from '@astrojs/starlight/components';` */
129
+ function asideImport() {
130
+ const source = '@astrojs/starlight/components';
131
+ return {
132
+ type: 'mdxjsEsm',
133
+ value: `import { Aside as ${ASIDE_COMPONENT} } from '${source}';`,
134
+ data: {
135
+ estree: {
136
+ type: 'Program',
137
+ sourceType: 'module',
138
+ body: [
139
+ {
140
+ type: 'ImportDeclaration',
141
+ specifiers: [
142
+ {
143
+ type: 'ImportSpecifier',
144
+ imported: { type: 'Identifier', name: 'Aside' },
145
+ local: { type: 'Identifier', name: ASIDE_COMPONENT },
146
+ },
147
+ ],
148
+ source: { type: 'Literal', value: source, raw: `'${source}'` },
149
+ attributes: [],
150
+ },
151
+ ],
152
+ },
153
+ },
154
+ };
155
+ }
156
+
157
+ export default function remarkAsides() {
158
+ return function transform(tree, file) {
159
+ const mdx = !/\.md$/i.test(String(file?.path ?? ''));
160
+ let used = false;
161
+ const walk = (node) => {
162
+ if (!Array.isArray(node?.children)) return;
163
+ node.children.forEach((child, index) => {
164
+ walk(child); // an aside may hold another
165
+ if (isAsideDirective(child)) {
166
+ node.children[index] = mdx ? asComponent(child) : asMarkup(child);
167
+ used ||= mdx;
168
+ } else if (isEmptyIconComponent(child)) {
169
+ child.attributes = child.attributes.filter(
170
+ (attribute) =>
171
+ !(attribute?.type === 'mdxJsxAttribute' && attribute.name === 'icon'),
172
+ );
173
+ node.children[index] = noIconWrapper(child);
174
+ }
175
+ });
176
+ };
177
+ walk(tree);
178
+ if (used) tree.children.unshift(asideImport());
179
+ };
180
+ }
@@ -5,11 +5,16 @@
5
5
  *
6
6
  * The complete calculation is recorded in [[memo-derivation]].
7
7
  *
8
- * <Memo id="derivation" title="Derivation" description="The algebra.">
8
+ * <Memo id="derivation" caption="The algebra behind the result." short="Derivation">
9
9
  * Several paragraphs, equations, tables, or figures.
10
10
  *
11
11
  * </Memo>
12
12
  *
13
+ * A memo is named like every float (docs/plans/titles-and-containers.md Part
14
+ * 3): a required `caption`, shown on its bar and in its preview, and an
15
+ * optional `short` caption. Its marker is a superscript letter — `ᵃ`, `ᵇ`, …
16
+ * numbered per page — so it never reads as a citation's `[1]` (decision Q7).
17
+ *
13
18
  * MDX normally rejects this before remark transforms run: an inline JSX node
14
19
  * cannot contain block Markdown. The attacher therefore wraps remark-parse's
15
20
  * parser and tags each memo before parsing. Legacy inline placements are still
@@ -118,6 +123,25 @@ export function preprocessMemos(source) {
118
123
  return output;
119
124
  }
120
125
 
126
+ /** The per-page memo letter: 1 → `a`, 26 → `z`, 27 → `aa`, … */
127
+ export function memoLetter(number) {
128
+ let n = Number(number);
129
+ let letters = '';
130
+ while (n > 0) {
131
+ n -= 1;
132
+ letters = String.fromCharCode(97 + (n % 26)) + letters;
133
+ n = Math.floor(n / 26);
134
+ }
135
+ return letters;
136
+ }
137
+
138
+ // Name props a memo no longer takes, with the fix each error gives.
139
+ const REMOVED_PROPS = {
140
+ title:
141
+ 'a memo is named by its `caption` (shown on its bar and in its preview) and an optional `short` caption; move a short title to `short`.',
142
+ description: 'fold it into `caption`, which is shown on the bar and in the preview.',
143
+ };
144
+
121
145
  function fail(file, message, node) {
122
146
  if (typeof file?.fail === 'function') file.fail(message, node);
123
147
  const location = file?.path ? `${file.path}: ` : '';
@@ -152,7 +176,7 @@ function markerNode(memo) {
152
176
  children: [
153
177
  {
154
178
  type: 'text',
155
- value: memo.label ?? `[${memo.number}]`,
179
+ value: memo.label ?? memo.number,
156
180
  },
157
181
  ],
158
182
  data: {
@@ -163,9 +187,9 @@ function markerNode(memo) {
163
187
  'aria-controls': memo.anchorId,
164
188
  'aria-expanded': 'false',
165
189
  'aria-label': memo.label
166
- ? `${memo.label}: ${memo.title}`
167
- : `Memo ${memo.number}: ${memo.title}`,
168
- title: memo.title,
190
+ ? `${memo.label}: ${memo.name}`
191
+ : `Memo ${memo.number}: ${memo.name}`,
192
+ title: memo.name,
169
193
  },
170
194
  },
171
195
  };
@@ -227,7 +251,7 @@ function rewriteMemoReferences(node, memos, file) {
227
251
  }
228
252
 
229
253
  /**
230
- * Number memo blocks from one on every page and resolve same-page
254
+ * Letter memo blocks from `a` on every page and resolve same-page
231
255
  * `[[memo-id]]` references. Numbering is intentionally page-local.
232
256
  *
233
257
  * @this {any}
@@ -295,9 +319,14 @@ export default function remarkMemo() {
295
319
  fail(file, '<Memo> did not become a block-level MDX element.', node);
296
320
  }
297
321
 
322
+ for (const [prop, fix] of Object.entries(REMOVED_PROPS)) {
323
+ if (mdxAttributesNamed(node, prop).length > 0) {
324
+ fail(file, `<Memo> has no \`${prop}\` prop: ${fix}`, node);
325
+ }
326
+ }
298
327
  const id = literalAttribute(node, 'id', file);
299
- const title = literalAttribute(node, 'title', file);
300
- literalAttribute(node, 'description', file);
328
+ const caption = literalAttribute(node, 'caption', file);
329
+ const short = literalAttribute(node, 'short', file, { optional: true });
301
330
  const label = literalAttribute(node, 'label', file, { optional: true });
302
331
  if (!KEYED_FLOAT_KEY_PATTERN.test(id)) {
303
332
  fail(
@@ -312,18 +341,20 @@ export default function remarkMemo() {
312
341
  seenIds.add(id);
313
342
 
314
343
  number += 1;
344
+ const letter = memoLetter(number);
315
345
  const anchorId = `memo-${id}`;
316
346
  removeMdxAttribute(node, BLOCK_ATTRIBUTE);
317
347
  injectMdxStringAttributes(node, {
318
- number: String(number),
348
+ number: letter,
319
349
  anchorId,
320
350
  });
321
351
 
322
352
  const memo = {
323
353
  id,
324
- title,
354
+ // What the marker's tooltip and accessible name call it.
355
+ name: short ?? caption,
325
356
  label,
326
- number: String(number),
357
+ number: letter,
327
358
  anchorId,
328
359
  };
329
360
  memos.set(id, memo);