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.
Files changed (150) hide show
  1. package/CHANGELOG.md +382 -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 +88 -1
  10. package/dist/compiler/course-config.js +35 -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 +98 -6
  14. package/dist/compiler/manifest.js +154 -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-heading-length.d.ts +12 -0
  31. package/dist/reference/gate-heading-length.js +79 -0
  32. package/dist/reference/gate-math.d.ts +32 -5
  33. package/dist/reference/gate-math.js +6 -2
  34. package/dist/reference/gate-prose-notation.d.ts +60 -0
  35. package/dist/reference/gate-prose-notation.js +125 -0
  36. package/dist/reference/keyed-float-validate.d.ts +5 -5
  37. package/dist/reference/keyed-jsx-floats.d.mts +7 -12
  38. package/dist/reference/keyed-jsx-floats.mjs +50 -25
  39. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  40. package/dist/reference/knowledge-checks-validate.js +78 -0
  41. package/dist/reference/knowledge-checks.d.mts +25 -0
  42. package/dist/reference/knowledge-checks.mjs +45 -0
  43. package/dist/reference/lab-math-scope.d.ts +9 -1
  44. package/dist/reference/lab-math-scope.js +32 -4
  45. package/dist/reference/math-glyphs.d.mts +42 -0
  46. package/dist/reference/math-glyphs.mjs +548 -40
  47. package/dist/reference/mdx-attributes.d.mts +13 -0
  48. package/dist/reference/mdx-attributes.mjs +38 -0
  49. package/dist/reference/mixed-content.d.ts +17 -0
  50. package/dist/reference/mixed-content.js +29 -0
  51. package/dist/reference/page-notation.d.ts +52 -0
  52. package/dist/reference/page-notation.js +171 -0
  53. package/dist/reference/raw-floats-validate.d.ts +18 -0
  54. package/dist/reference/raw-floats-validate.js +53 -0
  55. package/dist/reference/raw-floats.d.mts +18 -0
  56. package/dist/reference/raw-floats.mjs +129 -0
  57. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  58. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  59. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  60. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  61. package/dist/reference/remark-abbreviations.d.mts +7 -0
  62. package/dist/reference/remark-abbreviations.mjs +174 -0
  63. package/dist/reference/remark-citation.d.mts +5 -1
  64. package/dist/reference/remark-citation.mjs +0 -0
  65. package/dist/reference/remark-memo.d.mts +16 -0
  66. package/dist/reference/remark-memo.mjs +339 -0
  67. package/dist/reference/remark-notation.d.mts +57 -9
  68. package/dist/reference/remark-notation.mjs +354 -42
  69. package/dist/reference/remark-short-note.d.mts +4 -0
  70. package/dist/reference/remark-short-note.mjs +237 -0
  71. package/dist/reference/types.d.ts +16 -1
  72. package/dist/reference/units-vocab.js +4 -0
  73. package/docs/VERSIONING.md +53 -4
  74. package/docs/authoring/README.md +10 -0
  75. package/docs/authoring/agents.md +17 -2
  76. package/docs/authoring/components.md +138 -0
  77. package/docs/authoring/migrating.md +275 -7
  78. package/docs/authoring/notation.md +615 -1
  79. package/docs/authoring/structure-rules.md +69 -0
  80. package/docs/authoring/structure.md +120 -8
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +208 -3
  84. package/src/compiler/cli.ts +29 -2
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +132 -0
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/manifest.ts +281 -22
  89. package/src/components/HoverPanel.astro +45 -4
  90. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  91. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  92. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  93. package/src/components/assessments/AssessmentSet.astro +98 -14
  94. package/src/components/assessments/RenderedContent.tsx +55 -0
  95. package/src/components/assessments/assessment-randomization.ts +16 -0
  96. package/src/components/diagram/Diagram.astro +409 -103
  97. package/src/components/disclosure/hover-preview.ts +202 -0
  98. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  99. package/src/components/examples/CompactExamples.astro +2 -0
  100. package/src/components/figure/Figure.astro +279 -10
  101. package/src/components/memo/Memo.astro +453 -0
  102. package/src/components/memo/ShortNote.astro +223 -0
  103. package/src/components/notation/NotationFormula.astro +18 -1
  104. package/src/components/notation/NotationLayer.astro +260 -83
  105. package/src/components/notation/render-notation-math.ts +39 -2
  106. package/src/components/notation/safe-math-to-react.ts +50 -0
  107. package/src/components/reference/FloatRefLayer.astro +233 -15
  108. package/src/components/starlight/CollapsibleSections.astro +461 -0
  109. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  110. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  111. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  112. package/src/components/starlight/LayoutHeader.astro +50 -0
  113. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  114. package/src/components/starlight/LessonExportActions.astro +436 -0
  115. package/src/components/starlight/LessonFooter.astro +30 -3
  116. package/src/components/starlight/section-parts.ts +31 -0
  117. package/src/components/table/Table.astro +293 -7
  118. package/src/components/table/TableOverflowExpander.astro +489 -0
  119. package/src/content-config.ts +36 -1
  120. package/src/reference/base-abbreviations.mjs +60 -0
  121. package/src/reference/base-operators.mjs +40 -0
  122. package/src/reference/base-roman-labels.mjs +43 -0
  123. package/src/reference/consistency.ts +73 -2
  124. package/src/reference/diagrams-validate.ts +40 -0
  125. package/src/reference/equations.mjs +67 -14
  126. package/src/reference/gate-abbreviations.ts +128 -0
  127. package/src/reference/gate-heading-length.ts +131 -0
  128. package/src/reference/gate-math.ts +32 -7
  129. package/src/reference/gate-prose-notation.ts +224 -0
  130. package/src/reference/keyed-float-validate.ts +5 -5
  131. package/src/reference/keyed-jsx-floats.mjs +50 -25
  132. package/src/reference/knowledge-checks-validate.ts +130 -0
  133. package/src/reference/knowledge-checks.mjs +45 -0
  134. package/src/reference/lab-math-scope.ts +45 -3
  135. package/src/reference/math-glyphs.mjs +548 -40
  136. package/src/reference/mdx-attributes.mjs +38 -0
  137. package/src/reference/mixed-content.ts +69 -0
  138. package/src/reference/page-notation.ts +252 -0
  139. package/src/reference/raw-floats-validate.ts +103 -0
  140. package/src/reference/raw-floats.mjs +129 -0
  141. package/src/reference/rehype-heading-anchors.mjs +107 -1
  142. package/src/reference/rehype-table-header-labels.mjs +152 -0
  143. package/src/reference/remark-abbreviations.mjs +174 -0
  144. package/src/reference/remark-citation.mjs +0 -0
  145. package/src/reference/remark-memo.mjs +339 -0
  146. package/src/reference/remark-notation.mjs +354 -42
  147. package/src/reference/remark-short-note.mjs +237 -0
  148. package/src/reference/types.ts +16 -0
  149. package/src/reference/units-vocab.ts +4 -0
  150. package/src/styles/global.css +996 -50
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Replace computed string attributes on an MDX JSX node without disturbing
3
+ * author-owned props. Remark adapters use this when the build, rather than the
4
+ * author, owns values such as a visible number or DOM anchor.
5
+ *
6
+ * @param {object} node
7
+ * @param {Record<string, string>} values
8
+ */
9
+ export function injectMdxStringAttributes(node, values) {
10
+ const names = new Set(Object.keys(values));
11
+ node.attributes = (node.attributes ?? []).filter(
12
+ (attribute) =>
13
+ !(
14
+ attribute?.type === 'mdxJsxAttribute' &&
15
+ typeof attribute.name === 'string' &&
16
+ names.has(attribute.name)
17
+ ),
18
+ );
19
+ for (const [name, value] of Object.entries(values)) {
20
+ node.attributes.push({ type: 'mdxJsxAttribute', name, value });
21
+ }
22
+ }
23
+
24
+ /** Return every ordinary MDX JSX attribute with `name`. */
25
+ export function mdxAttributesNamed(node, name) {
26
+ return (node?.attributes ?? []).filter(
27
+ (attribute) =>
28
+ attribute?.type === 'mdxJsxAttribute' && attribute.name === name,
29
+ );
30
+ }
31
+
32
+ /** Remove an internal adapter-owned attribute from an MDX JSX node. */
33
+ export function removeMdxAttribute(node, name) {
34
+ node.attributes = (node.attributes ?? []).filter(
35
+ (attribute) =>
36
+ !(attribute?.type === 'mdxJsxAttribute' && attribute.name === name),
37
+ );
38
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Split a plain string that mixes prose and `$…$` / `$$…$$` math into an
3
+ * ordered list of text and math segments, resolving each math span's glyphs
4
+ * against a page scope on the way — the shared step behind rendering an
5
+ * assessment `prompt` / `explanation` through the same KaTeX pipeline as
6
+ * lesson body math (`AssessmentSet.astro` calls `renderNotationMath` on each
7
+ * `math` segment's `latex`; nothing here touches KaTeX itself).
8
+ *
9
+ * Unlike `gate-math.ts`'s `mathSpans` (which only needs the math, for
10
+ * diagnostics), this keeps the surrounding text too, in order, since the
11
+ * caller has to reproduce the original sentence around the rendered math.
12
+ *
13
+ * Resolution failure is swallowed here, not thrown: a bad span falls back to
14
+ * its own raw source as a `math` segment KaTeX will very likely also reject,
15
+ * degrading the one span instead of crashing manifest compilation. The actual
16
+ * enforcement — failing the build — is `gate-math.ts`'s job (context
17
+ * `'assessment'`) and `gate-prose-notation.ts`'s (context `'prose'`); this
18
+ * module's contract is "never throw", so a manifest build never depends on
19
+ * content being clean to finish running.
20
+ */
21
+ import { resolveMathGlyphs } from './math-glyphs.mjs';
22
+ import type { ScopeEntry } from './gate-math';
23
+
24
+ export type ContentSegment =
25
+ | { readonly type: 'text'; readonly value: string }
26
+ | { readonly type: 'math'; readonly latex: string };
27
+
28
+ // Byte-identical to `gate-math.ts`'s `DISPLAY_MATH` / `INLINE_MATH` — both
29
+ // scan the same author-facing `$…$` syntax and must agree on what counts as
30
+ // a span. Assessment prompt/explanation strings have no code fences or MDX
31
+ // to strip first, unlike a lesson body, so there is no shared `stripCode`
32
+ // step to reuse here.
33
+ const MATH_SPAN =
34
+ /\$\$([\s\S]+?)\$\$|(?<![\\$])\$(?!\s)([^$\n]+?)(?<![\\\s])\$(?!\d)/g;
35
+
36
+ /**
37
+ * Split `text` into ordered segments, resolving each math span's glyphs
38
+ * against `scope`. Never throws.
39
+ */
40
+ export function splitMixedContent(
41
+ text: string,
42
+ scope: readonly ScopeEntry[],
43
+ ): ContentSegment[] {
44
+ const segments: ContentSegment[] = [];
45
+ let cursor = 0;
46
+ const pattern = new RegExp(MATH_SPAN);
47
+ let match: RegExpExecArray | null;
48
+
49
+ while ((match = pattern.exec(text))) {
50
+ if (match.index > cursor) {
51
+ segments.push({ type: 'text', value: text.slice(cursor, match.index) });
52
+ }
53
+ const raw = match[1] ?? match[2] ?? '';
54
+ let latex = raw;
55
+ try {
56
+ latex = resolveMathGlyphs(raw, [...scope]).latex;
57
+ } catch {
58
+ latex = raw;
59
+ }
60
+ segments.push({ type: 'math', latex });
61
+ cursor = match.index + match[0].length;
62
+ }
63
+
64
+ if (cursor < text.length) {
65
+ segments.push({ type: 'text', value: text.slice(cursor) });
66
+ }
67
+
68
+ return segments;
69
+ }
@@ -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
+ }