explico 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -0
- package/dist/analytics/AnalyticsEmitter.d.ts +22 -0
- package/dist/analytics/AnalyticsEmitter.js +5 -0
- package/dist/compiler/cli.d.ts +189 -0
- package/dist/compiler/cli.js +552 -0
- package/dist/compiler/collections.d.ts +70 -0
- package/dist/compiler/collections.js +383 -0
- package/dist/compiler/course-config.d.ts +31 -0
- package/dist/compiler/course-config.js +0 -0
- package/dist/compiler/lesson-derivation.d.ts +89 -0
- package/dist/compiler/lesson-derivation.js +91 -0
- package/dist/compiler/manifest.d.ts +178 -0
- package/dist/compiler/manifest.js +345 -0
- package/dist/compiler/resolution-report.d.ts +45 -0
- package/dist/compiler/resolution-report.js +97 -0
- package/dist/curriculum/validation.d.ts +76 -0
- package/dist/curriculum/validation.js +320 -0
- package/dist/progress/ProgressRepository.d.ts +53 -0
- package/dist/progress/ProgressRepository.js +12 -0
- package/dist/reference/base-library.mjs +27 -0
- package/dist/reference/citation-format.mjs +133 -0
- package/dist/reference/consistency.d.ts +64 -0
- package/dist/reference/consistency.js +234 -0
- package/dist/reference/curriculum-alignment.d.ts +7 -0
- package/dist/reference/curriculum-alignment.js +166 -0
- package/dist/reference/equations-validate.d.ts +50 -0
- package/dist/reference/equations-validate.js +90 -0
- package/dist/reference/equations.mjs +144 -0
- package/dist/reference/gate-math.d.ts +41 -0
- package/dist/reference/gate-math.js +151 -0
- package/dist/reference/katex-options.mjs +91 -0
- package/dist/reference/lab-math-scope.d.ts +21 -0
- package/dist/reference/lab-math-scope.js +28 -0
- package/dist/reference/label.d.ts +11 -0
- package/dist/reference/label.js +38 -0
- package/dist/reference/lint-ignore.d.ts +32 -0
- package/dist/reference/lint-ignore.js +39 -0
- package/dist/reference/math-glyphs.mjs +830 -0
- package/dist/reference/prose.d.ts +15 -0
- package/dist/reference/prose.js +12 -0
- package/dist/reference/references.d.ts +17 -0
- package/dist/reference/references.js +42 -0
- package/dist/reference/registry.d.ts +6 -0
- package/dist/reference/registry.js +548 -0
- package/dist/reference/rehype-fail-katex-errors.mjs +55 -0
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-notation.mjs +753 -0
- package/dist/reference/render-lab-math.d.ts +21 -0
- package/dist/reference/render-lab-math.js +58 -0
- package/dist/reference/types.d.ts +151 -0
- package/dist/reference/types.js +0 -0
- package/dist/reference/units-vocab.d.ts +26 -0
- package/dist/reference/units-vocab.js +119 -0
- package/dist/session/PreferenceStore.d.ts +28 -0
- package/dist/session/PreferenceStore.js +30 -0
- package/dist/session/user-context.d.ts +7 -0
- package/dist/session/user-context.js +10 -0
- package/dist/session/user.d.ts +21 -0
- package/dist/session/user.js +8 -0
- package/package.json +53 -0
- package/src/analytics/AnalyticsEmitter.ts +23 -0
- package/src/compiler/cli.ts +866 -0
- package/src/compiler/collections.ts +554 -0
- package/src/compiler/course-config.ts +32 -0
- package/src/compiler/lesson-derivation.ts +186 -0
- package/src/compiler/manifest.ts +601 -0
- package/src/compiler/resolution-report.ts +150 -0
- package/src/components/CurriculumMap.astro +208 -0
- package/src/components/HoverPanel.astro +67 -0
- package/src/components/assessments/AssessmentRunner.tsx +320 -0
- package/src/components/assessments/AssessmentSet.astro +54 -0
- package/src/components/citation/CitationLayer.astro +173 -0
- package/src/components/examples/CompactExample.astro +34 -0
- package/src/components/examples/CompactExamples.astro +253 -0
- package/src/components/hover-panel.ts +273 -0
- package/src/components/notation/NotationGlossary.astro +412 -0
- package/src/components/notation/NotationLayer.astro +594 -0
- package/src/components/notation/NotationMath.astro +24 -0
- package/src/components/notation/SafeMathNode.astro +23 -0
- package/src/components/notation/render-notation-math.ts +223 -0
- package/src/components/starlight/EquationEnhancer.astro +72 -0
- package/src/components/starlight/LayoutHeader.astro +102 -0
- package/src/components/starlight/LayoutPageSidebar.astro +33 -0
- package/src/components/starlight/LayoutSidebar.astro +29 -0
- package/src/components/starlight/LessonFooter.astro +58 -0
- package/src/content-config.ts +259 -0
- package/src/curriculum/validation.ts +481 -0
- package/src/progress/ProgressRepository.ts +64 -0
- package/src/reference/base-library.mjs +27 -0
- package/src/reference/citation-format.mjs +133 -0
- package/src/reference/consistency.ts +375 -0
- package/src/reference/curriculum-alignment.ts +228 -0
- package/src/reference/equations-validate.ts +167 -0
- package/src/reference/equations.mjs +144 -0
- package/src/reference/gate-math.ts +250 -0
- package/src/reference/katex-options.mjs +91 -0
- package/src/reference/lab-math-scope.ts +54 -0
- package/src/reference/label.ts +47 -0
- package/src/reference/lint-ignore.ts +75 -0
- package/src/reference/math-glyphs.mjs +830 -0
- package/src/reference/prose.ts +21 -0
- package/src/reference/references.ts +82 -0
- package/src/reference/registry.ts +723 -0
- package/src/reference/rehype-fail-katex-errors.mjs +55 -0
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-notation.mjs +753 -0
- package/src/reference/render-lab-math.ts +99 -0
- package/src/reference/types.ts +182 -0
- package/src/reference/units-vocab.ts +143 -0
- package/src/session/PreferenceStore.ts +54 -0
- package/src/session/user-context.ts +18 -0
- package/src/session/user.ts +24 -0
- package/src/styles/global.css +763 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { useEffect, useId, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
noopAnalyticsEmitter,
|
|
5
|
+
type AnalyticsEmitter,
|
|
6
|
+
} from '../../analytics/AnalyticsEmitter';
|
|
7
|
+
import {
|
|
8
|
+
noopProgressRepository,
|
|
9
|
+
type AssessmentAttempt,
|
|
10
|
+
type ProgressRepository,
|
|
11
|
+
} from '../../progress/ProgressRepository';
|
|
12
|
+
import { useSessionUser } from '../../session/user-context';
|
|
13
|
+
|
|
14
|
+
interface ChoiceOption {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface AssessmentItemBase {
|
|
20
|
+
id: string;
|
|
21
|
+
competencyId?: string;
|
|
22
|
+
evidenceKind: 'direct' | 'transfer';
|
|
23
|
+
prompt: string;
|
|
24
|
+
explanation: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ChoiceItem extends AssessmentItemBase {
|
|
28
|
+
type: 'single-choice';
|
|
29
|
+
options: ChoiceOption[];
|
|
30
|
+
correctOptionId: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface NumericItem extends AssessmentItemBase {
|
|
34
|
+
type: 'numeric';
|
|
35
|
+
answer: { value: number; tolerance: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Item = ChoiceItem | NumericItem;
|
|
39
|
+
|
|
40
|
+
type Outcome = 'correct' | 'incorrect';
|
|
41
|
+
|
|
42
|
+
interface Props {
|
|
43
|
+
/** Assessment collection id — scopes recorded attempts and analytics events. */
|
|
44
|
+
assessmentId: string;
|
|
45
|
+
title: string;
|
|
46
|
+
items: Item[];
|
|
47
|
+
/**
|
|
48
|
+
* Seams (Phase H1). Both default to the no-op implementations the static
|
|
49
|
+
* site ships; a future SSR/LMS host injects real ones without changing the
|
|
50
|
+
* component. Props exist so tests can observe the calls.
|
|
51
|
+
*/
|
|
52
|
+
progress?: ProgressRepository;
|
|
53
|
+
analytics?: AnalyticsEmitter;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const evidenceLabel: Record<AssessmentItemBase['evidenceKind'], string> = {
|
|
57
|
+
direct: 'Direct',
|
|
58
|
+
transfer: 'Transfer',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function statusFor(outcome: Outcome | undefined): {
|
|
62
|
+
tone: 'idle' | 'correct' | 'incorrect';
|
|
63
|
+
label: string;
|
|
64
|
+
} {
|
|
65
|
+
if (outcome === 'correct') return { tone: 'correct', label: 'Correct' };
|
|
66
|
+
if (outcome === 'incorrect') return { tone: 'incorrect', label: 'Revisit' };
|
|
67
|
+
return { tone: 'idle', label: 'Not answered' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface QuestionProps {
|
|
71
|
+
item: Item;
|
|
72
|
+
index: number;
|
|
73
|
+
outcome: Outcome | undefined;
|
|
74
|
+
onResult: (id: string, outcome: Outcome) => void;
|
|
75
|
+
onReset: (id: string) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function Question({ item, index, outcome, onResult, onReset }: QuestionProps) {
|
|
79
|
+
const panelId = useId();
|
|
80
|
+
const groupName = useId();
|
|
81
|
+
const inputId = useId();
|
|
82
|
+
const [open, setOpen] = useState(false);
|
|
83
|
+
const [choice, setChoice] = useState<string | null>(null);
|
|
84
|
+
const [numeric, setNumeric] = useState('');
|
|
85
|
+
|
|
86
|
+
const checked = outcome !== undefined;
|
|
87
|
+
const parsed = Number(numeric);
|
|
88
|
+
const numericFilled = numeric.trim() !== '' && Number.isFinite(parsed);
|
|
89
|
+
const canCheck =
|
|
90
|
+
item.type === 'single-choice' ? choice !== null : numericFilled;
|
|
91
|
+
|
|
92
|
+
const evaluate = () => {
|
|
93
|
+
const isCorrect =
|
|
94
|
+
item.type === 'single-choice'
|
|
95
|
+
? choice === item.correctOptionId
|
|
96
|
+
: numericFilled &&
|
|
97
|
+
Math.abs(parsed - item.answer.value) <= item.answer.tolerance;
|
|
98
|
+
onResult(item.id, isCorrect ? 'correct' : 'incorrect');
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const retry = () => {
|
|
102
|
+
onReset(item.id);
|
|
103
|
+
setChoice(null);
|
|
104
|
+
setNumeric('');
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const status = statusFor(outcome);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<li className="assessment-item">
|
|
111
|
+
<h4 className="assessment-item-heading">
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className="assessment-disclosure"
|
|
115
|
+
aria-expanded={open}
|
|
116
|
+
aria-controls={panelId}
|
|
117
|
+
onClick={() => setOpen((value) => !value)}
|
|
118
|
+
>
|
|
119
|
+
<span className="assessment-kind">
|
|
120
|
+
{evidenceLabel[item.evidenceKind]}
|
|
121
|
+
</span>
|
|
122
|
+
<span className="assessment-item-title">Question {index}</span>
|
|
123
|
+
<span className={`assessment-status is-${status.tone}`}>
|
|
124
|
+
{status.label}
|
|
125
|
+
</span>
|
|
126
|
+
<svg
|
|
127
|
+
className="assessment-chevron"
|
|
128
|
+
viewBox="0 0 16 16"
|
|
129
|
+
width="16"
|
|
130
|
+
height="16"
|
|
131
|
+
aria-hidden="true"
|
|
132
|
+
>
|
|
133
|
+
<path
|
|
134
|
+
d="M4 6l4 4 4-4"
|
|
135
|
+
fill="none"
|
|
136
|
+
stroke="currentColor"
|
|
137
|
+
strokeWidth="2"
|
|
138
|
+
strokeLinecap="round"
|
|
139
|
+
strokeLinejoin="round"
|
|
140
|
+
/>
|
|
141
|
+
</svg>
|
|
142
|
+
</button>
|
|
143
|
+
</h4>
|
|
144
|
+
|
|
145
|
+
<div id={panelId} className="assessment-panel" hidden={!open}>
|
|
146
|
+
<p className="assessment-prompt">{item.prompt}</p>
|
|
147
|
+
|
|
148
|
+
{item.type === 'single-choice' ? (
|
|
149
|
+
<fieldset className="assessment-choices">
|
|
150
|
+
<legend className="sr-only">
|
|
151
|
+
Answer options for question {index}
|
|
152
|
+
</legend>
|
|
153
|
+
{item.options.map((option) => (
|
|
154
|
+
<label key={option.id} className="assessment-option">
|
|
155
|
+
<input
|
|
156
|
+
type="radio"
|
|
157
|
+
name={groupName}
|
|
158
|
+
value={option.id}
|
|
159
|
+
checked={choice === option.id}
|
|
160
|
+
disabled={checked}
|
|
161
|
+
onChange={() => setChoice(option.id)}
|
|
162
|
+
/>
|
|
163
|
+
{option.label}
|
|
164
|
+
</label>
|
|
165
|
+
))}
|
|
166
|
+
</fieldset>
|
|
167
|
+
) : (
|
|
168
|
+
<label className="assessment-numeric" htmlFor={inputId}>
|
|
169
|
+
<span className="sr-only">Numeric answer for question {index}</span>
|
|
170
|
+
<input
|
|
171
|
+
id={inputId}
|
|
172
|
+
type="number"
|
|
173
|
+
inputMode="decimal"
|
|
174
|
+
value={numeric}
|
|
175
|
+
disabled={checked}
|
|
176
|
+
onChange={(event) => setNumeric(event.currentTarget.value)}
|
|
177
|
+
/>
|
|
178
|
+
</label>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
<div className="assessment-actions">
|
|
182
|
+
{checked ? (
|
|
183
|
+
<button type="button" onClick={retry}>
|
|
184
|
+
Try again
|
|
185
|
+
</button>
|
|
186
|
+
) : (
|
|
187
|
+
<button type="button" disabled={!canCheck} onClick={evaluate}>
|
|
188
|
+
Check
|
|
189
|
+
</button>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<p
|
|
194
|
+
className={
|
|
195
|
+
checked
|
|
196
|
+
? `assessment-feedback is-${outcome}`
|
|
197
|
+
: 'assessment-feedback is-idle'
|
|
198
|
+
}
|
|
199
|
+
aria-live="polite"
|
|
200
|
+
>
|
|
201
|
+
{checked ? (
|
|
202
|
+
<>
|
|
203
|
+
<strong>
|
|
204
|
+
{outcome === 'correct' ? 'Correct.' : 'Not quite.'}
|
|
205
|
+
</strong>{' '}
|
|
206
|
+
{item.explanation}
|
|
207
|
+
</>
|
|
208
|
+
) : (
|
|
209
|
+
'Check your answer to reveal the explanation.'
|
|
210
|
+
)}
|
|
211
|
+
</p>
|
|
212
|
+
</div>
|
|
213
|
+
</li>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export default function AssessmentRunner({
|
|
218
|
+
assessmentId,
|
|
219
|
+
title,
|
|
220
|
+
items,
|
|
221
|
+
progress = noopProgressRepository,
|
|
222
|
+
analytics = noopAnalyticsEmitter,
|
|
223
|
+
}: Props) {
|
|
224
|
+
const headingId = useId();
|
|
225
|
+
const user = useSessionUser();
|
|
226
|
+
const [results, setResults] = useState<Record<string, Outcome>>({});
|
|
227
|
+
|
|
228
|
+
const itemById = new Map(items.map((item) => [item.id, item]));
|
|
229
|
+
|
|
230
|
+
// Seed from any previously recorded attempts. The no-op repository returns an
|
|
231
|
+
// empty snapshot, so this is inert in the static build.
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
let live = true;
|
|
234
|
+
void Promise.resolve(progress.load(user)).then((snapshot) => {
|
|
235
|
+
if (!live) return;
|
|
236
|
+
const seeded: Record<string, Outcome> = {};
|
|
237
|
+
for (const attempt of snapshot.attempts) {
|
|
238
|
+
if (
|
|
239
|
+
attempt.assessmentId === assessmentId &&
|
|
240
|
+
itemById.has(attempt.itemId)
|
|
241
|
+
) {
|
|
242
|
+
seeded[attempt.itemId] = attempt.outcome;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (Object.keys(seeded).length > 0) setResults(seeded);
|
|
246
|
+
});
|
|
247
|
+
return () => {
|
|
248
|
+
live = false;
|
|
249
|
+
};
|
|
250
|
+
// `assessmentId` and the repository identity are the only real inputs.
|
|
251
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
252
|
+
}, [assessmentId, progress, user]);
|
|
253
|
+
|
|
254
|
+
const answered = Object.keys(results).length;
|
|
255
|
+
const correct = Object.values(results).filter(
|
|
256
|
+
(value) => value === 'correct',
|
|
257
|
+
).length;
|
|
258
|
+
|
|
259
|
+
const report = (id: string, outcome: Outcome) => {
|
|
260
|
+
setResults((prev) => ({ ...prev, [id]: outcome }));
|
|
261
|
+
const item = itemById.get(id);
|
|
262
|
+
const attempt: AssessmentAttempt = {
|
|
263
|
+
assessmentId,
|
|
264
|
+
itemId: id,
|
|
265
|
+
...(item?.competencyId ? { competencyId: item.competencyId } : {}),
|
|
266
|
+
evidenceKind: item?.evidenceKind ?? 'direct',
|
|
267
|
+
outcome,
|
|
268
|
+
at: Date.now(),
|
|
269
|
+
};
|
|
270
|
+
void progress.recordAttempt(user, attempt);
|
|
271
|
+
analytics.emit({
|
|
272
|
+
name: 'assessment.attempt',
|
|
273
|
+
at: attempt.at,
|
|
274
|
+
props: {
|
|
275
|
+
assessmentId,
|
|
276
|
+
itemId: id,
|
|
277
|
+
outcome,
|
|
278
|
+
evidenceKind: attempt.evidenceKind,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
const clear = (id: string) => {
|
|
283
|
+
setResults((prev) => {
|
|
284
|
+
const next = { ...prev };
|
|
285
|
+
delete next[id];
|
|
286
|
+
return next;
|
|
287
|
+
});
|
|
288
|
+
void progress.reset(user, { assessmentId, itemId: id });
|
|
289
|
+
analytics.emit({
|
|
290
|
+
name: 'assessment.retry',
|
|
291
|
+
at: Date.now(),
|
|
292
|
+
props: { assessmentId, itemId: id },
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<section className="assessment-shell" aria-labelledby={headingId}>
|
|
298
|
+
<div className="assessment-header">
|
|
299
|
+
<h3 id={headingId}>{title}</h3>
|
|
300
|
+
<p className="assessment-progress" aria-live="polite">
|
|
301
|
+
{answered === 0
|
|
302
|
+
? `${items.length} question${items.length === 1 ? '' : 's'}`
|
|
303
|
+
: `${correct} of ${items.length} correct`}
|
|
304
|
+
</p>
|
|
305
|
+
</div>
|
|
306
|
+
<ol className="assessment-list">
|
|
307
|
+
{items.map((item, position) => (
|
|
308
|
+
<Question
|
|
309
|
+
key={item.id}
|
|
310
|
+
item={item}
|
|
311
|
+
index={position + 1}
|
|
312
|
+
outcome={results[item.id]}
|
|
313
|
+
onResult={report}
|
|
314
|
+
onReset={clear}
|
|
315
|
+
/>
|
|
316
|
+
))}
|
|
317
|
+
</ol>
|
|
318
|
+
</section>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from 'astro:content';
|
|
3
|
+
import { loadManifest } from '../../compiler/manifest';
|
|
4
|
+
import AssessmentRunner from './AssessmentRunner';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/**
|
|
8
|
+
* Explicit assessment id. Omitted in lesson bodies (Phase C2): the set is
|
|
9
|
+
* resolved from the route's colocated `<lesson>.checks.yml` via the manifest.
|
|
10
|
+
*/
|
|
11
|
+
id?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const explicitId = Astro.props.id;
|
|
15
|
+
|
|
16
|
+
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
|
|
17
|
+
const lessonId = Astro.url.pathname
|
|
18
|
+
.replace(new RegExp(`^${base}/`), '')
|
|
19
|
+
.replace(/\/$/, '')
|
|
20
|
+
.replace(/\//g, '.');
|
|
21
|
+
|
|
22
|
+
let ids: string[];
|
|
23
|
+
if (explicitId) {
|
|
24
|
+
ids = [explicitId];
|
|
25
|
+
} else {
|
|
26
|
+
const manifest = await loadManifest();
|
|
27
|
+
ids = [
|
|
28
|
+
...(manifest.lessons.find((lesson) => lesson.id === lessonId)
|
|
29
|
+
?.assessments ?? []),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const assessments = await getCollection('assessments');
|
|
34
|
+
const entries = ids.map((id) => {
|
|
35
|
+
const entry = assessments.find((candidate) => candidate.data.id === id);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`AssessmentSet: no assessment collection entry with id "${id}"`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return entry;
|
|
42
|
+
});
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
entries.map((entry) => (
|
|
47
|
+
<AssessmentRunner
|
|
48
|
+
client:visible
|
|
49
|
+
assessmentId={entry.data.id}
|
|
50
|
+
title={entry.data.title}
|
|
51
|
+
items={entry.data.items}
|
|
52
|
+
/>
|
|
53
|
+
))
|
|
54
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Optional hover/pin panel for the inline `[n]` citation markers that the
|
|
4
|
+
* `remarkCitation` adapter emits. The markers are real anchor links to the
|
|
5
|
+
* on-page "References" list and work with no JavaScript; this island only
|
|
6
|
+
* adds a quick look at the source without leaving the reader's place.
|
|
7
|
+
*
|
|
8
|
+
* It reads everything it shows from the page's own static markup — the
|
|
9
|
+
* marker's `data-*` and the matching `#cite-n` reference-list item — so no
|
|
10
|
+
* page serializes a second copy of the source database. Positioning, pin,
|
|
11
|
+
* Escape, and outside-close come from the shared `HoverPanel` primitive.
|
|
12
|
+
*/
|
|
13
|
+
import HoverPanel from '../HoverPanel.astro';
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<HoverPanel id="citation-panel" label="Source citation" data-citation-panel>
|
|
17
|
+
<div class="citation-panel-body">
|
|
18
|
+
<p class="citation-panel-label" data-panel-label></p>
|
|
19
|
+
<p class="citation-panel-locator" data-panel-locator hidden></p>
|
|
20
|
+
<p class="citation-panel-status" data-panel-status hidden></p>
|
|
21
|
+
<a class="citation-panel-link" data-panel-link hidden>Open source link</a>
|
|
22
|
+
<a class="citation-panel-jump" data-panel-jump
|
|
23
|
+
>See it in the reference list</a
|
|
24
|
+
>
|
|
25
|
+
<button
|
|
26
|
+
class="citation-panel-pin"
|
|
27
|
+
type="button"
|
|
28
|
+
data-panel-pin
|
|
29
|
+
aria-pressed="false"
|
|
30
|
+
>
|
|
31
|
+
Pin
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
</HoverPanel>
|
|
35
|
+
|
|
36
|
+
<script>
|
|
37
|
+
import { createHoverPanel, closestElement } from '../hover-panel';
|
|
38
|
+
import { referenceLabelFromText } from '../../reference/citation-format.mjs';
|
|
39
|
+
|
|
40
|
+
function setText(element: HTMLElement, value: string) {
|
|
41
|
+
element.textContent = value;
|
|
42
|
+
element.hidden = value.length === 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setup() {
|
|
46
|
+
const panel = document.querySelector<HTMLElement>('[data-citation-panel]');
|
|
47
|
+
if (!panel || panel.dataset.hoverPanelReady === 'true') return;
|
|
48
|
+
|
|
49
|
+
const markers = Array.from(
|
|
50
|
+
document.querySelectorAll<HTMLAnchorElement>(
|
|
51
|
+
'.sl-markdown-content a.citation-ref[data-citation-id]',
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
if (markers.length === 0) return;
|
|
55
|
+
const markerSet = new Set<HTMLElement>(markers);
|
|
56
|
+
|
|
57
|
+
const label = panel.querySelector<HTMLElement>('[data-panel-label]')!;
|
|
58
|
+
const locator = panel.querySelector<HTMLElement>('[data-panel-locator]')!;
|
|
59
|
+
const status = panel.querySelector<HTMLElement>('[data-panel-status]')!;
|
|
60
|
+
const link = panel.querySelector<HTMLAnchorElement>('[data-panel-link]')!;
|
|
61
|
+
const jump = panel.querySelector<HTMLAnchorElement>('[data-panel-jump]')!;
|
|
62
|
+
const pin = panel.querySelector<HTMLButtonElement>('[data-panel-pin]')!;
|
|
63
|
+
|
|
64
|
+
const markerFrom = (node: EventTarget | null) => {
|
|
65
|
+
const candidate = closestElement(
|
|
66
|
+
node,
|
|
67
|
+
'a.citation-ref[data-citation-id]',
|
|
68
|
+
);
|
|
69
|
+
return candidate && markerSet.has(candidate) ? candidate : null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Fill the panel from the marker plus its `#cite-n` reference-list item —
|
|
73
|
+
// never from a serialized source table.
|
|
74
|
+
const render = (marker: HTMLElement, number: string) => {
|
|
75
|
+
const item = document.getElementById(`cite-${number}`);
|
|
76
|
+
if (!item) return false;
|
|
77
|
+
|
|
78
|
+
// The reference list is a tight list, so the `<li>` has no `<p>`
|
|
79
|
+
// wrapper: clone it and drop the trailing link/status chrome.
|
|
80
|
+
const line = item.cloneNode(true) as HTMLElement;
|
|
81
|
+
for (const extra of line.querySelectorAll(
|
|
82
|
+
'.citation-url, .citation-status, .citation-backref',
|
|
83
|
+
)) {
|
|
84
|
+
extra.remove();
|
|
85
|
+
}
|
|
86
|
+
const referenceText = line.textContent ?? '';
|
|
87
|
+
const loc = marker.dataset.citationLocator ?? '';
|
|
88
|
+
|
|
89
|
+
label.textContent = referenceLabelFromText(referenceText, loc);
|
|
90
|
+
setText(locator, loc);
|
|
91
|
+
|
|
92
|
+
const sourceStatus = item
|
|
93
|
+
.querySelector('.citation-status')
|
|
94
|
+
?.textContent?.trim();
|
|
95
|
+
setText(status, sourceStatus ? `Source record: ${sourceStatus}` : '');
|
|
96
|
+
|
|
97
|
+
const sourceUrl = item.querySelector<HTMLAnchorElement>('a.citation-url');
|
|
98
|
+
if (sourceUrl) {
|
|
99
|
+
link.href = sourceUrl.href;
|
|
100
|
+
link.hidden = false;
|
|
101
|
+
} else {
|
|
102
|
+
link.hidden = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
jump.href = marker.getAttribute('href') ?? '#references';
|
|
106
|
+
return true;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
createHoverPanel({
|
|
110
|
+
panel,
|
|
111
|
+
triggerFrom: markerFrom,
|
|
112
|
+
keyFor: (element) => element.dataset.citationNumber ?? null,
|
|
113
|
+
render,
|
|
114
|
+
pinControlFrom: (node) => closestElement(node, '[data-panel-pin]'),
|
|
115
|
+
onSync: ({ pinnedKey }) => {
|
|
116
|
+
const isPinned = pinnedKey !== null;
|
|
117
|
+
pin.setAttribute('aria-pressed', String(isPinned));
|
|
118
|
+
pin.textContent = isPinned ? 'Unpin' : 'Pin';
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
setup();
|
|
124
|
+
document.addEventListener('astro:page-load', setup);
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<style>
|
|
128
|
+
@layer starlight.components {
|
|
129
|
+
.citation-panel-body p {
|
|
130
|
+
margin: 0 0 0.5rem;
|
|
131
|
+
line-height: 1.5;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.citation-panel-label {
|
|
135
|
+
font-weight: 600;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.citation-panel-locator {
|
|
139
|
+
color: var(--sl-color-gray-2);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.citation-panel-status {
|
|
143
|
+
color: var(--sl-color-gray-3);
|
|
144
|
+
font-size: var(--sl-text-xs);
|
|
145
|
+
text-transform: lowercase;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.citation-panel-link,
|
|
149
|
+
.citation-panel-jump {
|
|
150
|
+
display: inline-block;
|
|
151
|
+
margin-inline-end: 0.9rem;
|
|
152
|
+
font-size: var(--sl-text-xs);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.citation-panel-pin {
|
|
156
|
+
display: block;
|
|
157
|
+
margin-top: 0.6rem;
|
|
158
|
+
padding: 0.2rem 0.6rem;
|
|
159
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
160
|
+
border-radius: 0.35rem;
|
|
161
|
+
background: var(--sl-color-bg);
|
|
162
|
+
color: var(--sl-color-text-accent);
|
|
163
|
+
font: inherit;
|
|
164
|
+
font-size: var(--sl-text-xs);
|
|
165
|
+
cursor: pointer;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.citation-panel-pin[aria-pressed='true'] {
|
|
169
|
+
border-color: var(--sl-color-accent);
|
|
170
|
+
background: var(--sl-color-accent-low);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Short, descriptive tab label, for example “Mixed signed cash flows”. */
|
|
4
|
+
label: string;
|
|
5
|
+
/** Optional stable anchor for linking directly to this example. */
|
|
6
|
+
id?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { label, id } = Astro.props;
|
|
10
|
+
|
|
11
|
+
if (typeof label !== 'string' || !label.trim()) {
|
|
12
|
+
throw new Error('CompactExample requires a non-empty `label` prop.');
|
|
13
|
+
}
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<section
|
|
17
|
+
class="compact-example"
|
|
18
|
+
data-compact-example
|
|
19
|
+
data-example-label={label}
|
|
20
|
+
id={id}
|
|
21
|
+
>
|
|
22
|
+
<h3 class="compact-example__heading">{label}</h3>
|
|
23
|
+
<slot />
|
|
24
|
+
</section>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
.compact-example__heading {
|
|
28
|
+
margin-block-start: 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.compact-example > :last-child {
|
|
32
|
+
margin-block-end: 0;
|
|
33
|
+
}
|
|
34
|
+
</style>
|