explico 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +344 -1
- package/README.md +117 -3
- package/dist/compiler/astro.d.ts +38 -0
- package/dist/compiler/astro.js +258 -0
- package/dist/compiler/cli.d.ts +13 -0
- package/dist/compiler/cli.js +32 -12
- package/dist/compiler/collections.d.ts +3 -2
- package/dist/compiler/collections.js +28 -1
- package/dist/compiler/course-config.d.ts +86 -4
- package/dist/compiler/course-config.js +39 -0
- package/dist/compiler/exports/standalone-html.d.ts +34 -0
- package/dist/compiler/exports/standalone-html.js +803 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +112 -7
- package/dist/compiler/manifest.js +205 -40
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-abbreviations.d.mts +30 -0
- package/dist/reference/base-abbreviations.mjs +60 -0
- package/dist/reference/base-operators.d.mts +15 -0
- package/dist/reference/base-operators.mjs +40 -0
- package/dist/reference/base-roman-labels.d.mts +35 -0
- package/dist/reference/base-roman-labels.mjs +43 -0
- package/dist/reference/consistency.d.ts +8 -2
- package/dist/reference/consistency.js +50 -3
- package/dist/reference/diagrams-validate.d.ts +21 -0
- package/dist/reference/diagrams-validate.js +13 -0
- package/dist/reference/equations-validate.d.ts +8 -3
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +66 -35
- package/dist/reference/equations.mjs +142 -40
- package/dist/reference/gate-abbreviations.d.ts +17 -0
- package/dist/reference/gate-abbreviations.js +64 -0
- package/dist/reference/gate-math.d.ts +28 -5
- package/dist/reference/gate-math.js +5 -2
- package/dist/reference/gate-prose-notation.d.ts +60 -0
- package/dist/reference/gate-prose-notation.js +125 -0
- package/dist/reference/keyed-float-validate.d.ts +14 -8
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/keyed-jsx-floats.d.mts +24 -21
- package/dist/reference/keyed-jsx-floats.mjs +90 -41
- package/dist/reference/knowledge-checks-validate.d.ts +35 -0
- package/dist/reference/knowledge-checks-validate.js +78 -0
- package/dist/reference/knowledge-checks.d.mts +25 -0
- package/dist/reference/knowledge-checks.mjs +45 -0
- package/dist/reference/lab-math-scope.d.ts +9 -1
- package/dist/reference/lab-math-scope.js +32 -4
- package/dist/reference/math-glyphs.d.mts +30 -0
- package/dist/reference/math-glyphs.mjs +367 -0
- package/dist/reference/mdx-attributes.d.mts +13 -0
- package/dist/reference/mdx-attributes.mjs +38 -0
- package/dist/reference/mixed-content.d.ts +17 -0
- package/dist/reference/mixed-content.js +29 -0
- package/dist/reference/page-notation.d.ts +52 -0
- package/dist/reference/page-notation.js +171 -0
- package/dist/reference/raw-floats-validate.d.ts +18 -0
- package/dist/reference/raw-floats-validate.js +53 -0
- package/dist/reference/raw-floats.d.mts +18 -0
- package/dist/reference/raw-floats.mjs +129 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -1
- package/dist/reference/rehype-heading-anchors.mjs +107 -1
- package/dist/reference/rehype-table-header-labels.d.mts +26 -0
- package/dist/reference/rehype-table-header-labels.mjs +152 -0
- package/dist/reference/remark-abbreviations.d.mts +7 -0
- package/dist/reference/remark-abbreviations.mjs +174 -0
- package/dist/reference/remark-memo.d.mts +16 -0
- package/dist/reference/remark-memo.mjs +339 -0
- package/dist/reference/remark-notation.d.mts +102 -5
- package/dist/reference/remark-notation.mjs +506 -50
- package/dist/reference/remark-short-note.d.mts +4 -0
- package/dist/reference/remark-short-note.mjs +237 -0
- package/dist/reference/types.d.ts +16 -1
- package/dist/reference/units-vocab.js +4 -0
- package/docs/VERSIONING.md +33 -4
- package/docs/authoring/README.md +12 -0
- package/docs/authoring/agents.md +17 -2
- package/docs/authoring/components.md +135 -0
- package/docs/authoring/migrating.md +154 -7
- package/docs/authoring/notation.md +568 -8
- package/docs/authoring/structure.md +251 -0
- package/docs/authoring/visualizations.md +36 -3
- package/package.json +5 -2
- package/src/compiler/astro.ts +332 -0
- package/src/compiler/cli.ts +33 -10
- package/src/compiler/collections.ts +45 -4
- package/src/compiler/course-config.ts +131 -3
- package/src/compiler/exports/standalone-html.ts +963 -0
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +367 -49
- package/src/components/HoverPanel.astro +45 -4
- package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
- package/src/components/assessments/AssessmentRunner.tsx +52 -17
- package/src/components/assessments/AssessmentSet.astro +98 -14
- package/src/components/assessments/RenderedContent.tsx +55 -0
- package/src/components/assessments/assessment-randomization.ts +16 -0
- package/src/components/diagram/Diagram.astro +444 -83
- package/src/components/disclosure/hover-preview.ts +202 -0
- package/src/components/disclosure/persisted-disclosure.ts +66 -0
- package/src/components/examples/CompactExamples.astro +2 -0
- package/src/components/figure/Figure.astro +279 -10
- package/src/components/memo/Memo.astro +453 -0
- package/src/components/memo/ShortNote.astro +223 -0
- package/src/components/notation/NotationFormula.astro +18 -1
- package/src/components/notation/NotationLayer.astro +116 -8
- package/src/components/notation/render-notation-math.ts +39 -2
- package/src/components/notation/safe-math-to-react.ts +50 -0
- package/src/components/reference/FloatRefLayer.astro +233 -15
- package/src/components/starlight/CollapsibleSections.astro +461 -0
- package/src/components/starlight/DiagramEnhancer.astro +385 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
- package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
- package/src/components/starlight/LayoutHeader.astro +50 -0
- package/src/components/starlight/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LayoutSocialIcons.astro +41 -2
- package/src/components/starlight/LessonExportActions.astro +436 -0
- package/src/components/starlight/LessonFooter.astro +38 -2
- package/src/components/starlight/section-parts.ts +31 -0
- package/src/components/table/Table.astro +296 -9
- package/src/components/table/TableOverflowExpander.astro +489 -0
- package/src/content-config.ts +20 -0
- package/src/reference/base-abbreviations.mjs +60 -0
- package/src/reference/base-operators.mjs +40 -0
- package/src/reference/base-roman-labels.mjs +43 -0
- package/src/reference/consistency.ts +73 -2
- package/src/reference/diagrams-validate.ts +40 -0
- package/src/reference/equations-validate.ts +6 -4
- package/src/reference/equations.mjs +142 -40
- package/src/reference/gate-abbreviations.ts +128 -0
- package/src/reference/gate-math.ts +27 -7
- package/src/reference/gate-prose-notation.ts +224 -0
- package/src/reference/keyed-float-validate.ts +12 -9
- package/src/reference/keyed-jsx-floats.mjs +90 -41
- package/src/reference/knowledge-checks-validate.ts +130 -0
- package/src/reference/knowledge-checks.mjs +45 -0
- package/src/reference/lab-math-scope.ts +45 -3
- package/src/reference/math-glyphs.mjs +367 -0
- package/src/reference/mdx-attributes.mjs +38 -0
- package/src/reference/mixed-content.ts +69 -0
- package/src/reference/page-notation.ts +252 -0
- package/src/reference/raw-floats-validate.ts +103 -0
- package/src/reference/raw-floats.mjs +129 -0
- package/src/reference/rehype-heading-anchors.mjs +107 -1
- package/src/reference/rehype-table-header-labels.mjs +152 -0
- package/src/reference/remark-abbreviations.mjs +174 -0
- package/src/reference/remark-memo.mjs +339 -0
- package/src/reference/remark-notation.mjs +506 -50
- package/src/reference/remark-short-note.mjs +237 -0
- package/src/reference/types.ts +16 -0
- package/src/reference/units-vocab.ts +4 -0
- package/src/styles/global.css +1125 -50
|
@@ -50,7 +50,9 @@ const { id, label, class: className, ...rest } = Astro.props;
|
|
|
50
50
|
>
|
|
51
51
|
<span aria-hidden="true">×</span>
|
|
52
52
|
</button>
|
|
53
|
-
<
|
|
53
|
+
<div class="hover-panel-scroll">
|
|
54
|
+
<slot />
|
|
55
|
+
</div>
|
|
54
56
|
</aside>
|
|
55
57
|
|
|
56
58
|
<style>
|
|
@@ -63,9 +65,15 @@ const { id, label, class: className, ...rest } = Astro.props;
|
|
|
63
65
|
JS placement (which clears display equations) must start from zero. */
|
|
64
66
|
margin: 0;
|
|
65
67
|
width: min(23rem, calc(100vw - 1.5rem));
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-direction: column;
|
|
70
|
+
/* The scrolling lives on `.hover-panel-scroll` below, not here — the
|
|
71
|
+
close button is an absolutely-positioned sibling of that scroller,
|
|
72
|
+
so it stays put when the content scrolls instead of scrolling away
|
|
73
|
+
with it (which is what happens to an absolutely-positioned child of
|
|
74
|
+
the scroll container itself). `hidden` also keeps the rounded
|
|
75
|
+
corners clean around the inner scroller. */
|
|
76
|
+
overflow: hidden;
|
|
69
77
|
border: 1px solid var(--sl-color-gray-5);
|
|
70
78
|
border-top: 0.25rem solid var(--sl-color-accent);
|
|
71
79
|
border-radius: 0.65rem;
|
|
@@ -75,6 +83,39 @@ const { id, label, class: className, ...rest } = Astro.props;
|
|
|
75
83
|
font-size: var(--sl-text-sm);
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
/* `.hover-panel { display: flex }` above has the same specificity as the
|
|
87
|
+
UA stylesheet's `[hidden] { display: none }` and comes later in the
|
|
88
|
+
cascade, so it would otherwise win and defeat the `hidden` attribute
|
|
89
|
+
that every layer's JS relies on to actually hide the panel. */
|
|
90
|
+
.hover-panel[hidden] {
|
|
91
|
+
display: none;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* The equation/table/figure preview card (`FloatRefLayer.astro`) is the
|
|
95
|
+
one panel whose content size varies wildly — a short reference vs. a
|
|
96
|
+
wide equation — so it grows to fit instead of staying pinned at the
|
|
97
|
+
default width. `--float-ref-max-width` is computed in JS from the live
|
|
98
|
+
layout (the room between the left sidebar and the viewport edge); the
|
|
99
|
+
notation and citation panels never set it and keep the fixed default. */
|
|
100
|
+
.hover-panel[data-float-ref-panel] {
|
|
101
|
+
width: fit-content;
|
|
102
|
+
min-width: min(23rem, var(--float-ref-max-width, 100vw));
|
|
103
|
+
max-width: var(--float-ref-max-width, min(23rem, calc(100vw - 1.5rem)));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.hover-panel-scroll {
|
|
107
|
+
overflow: auto;
|
|
108
|
+
/* Without these, this flex item's default `auto` minimum size is its
|
|
109
|
+
*content's* size, not the panel's — content wider or taller than the
|
|
110
|
+
panel would inflate the panel itself outward instead of scrolling
|
|
111
|
+
inside it (the same flexbox trap `.float-ref-panel-content` guards
|
|
112
|
+
against with its own `min-width: 0`). */
|
|
113
|
+
min-width: 0;
|
|
114
|
+
min-height: 0;
|
|
115
|
+
padding: 1rem;
|
|
116
|
+
padding-inline-end: 2.4rem;
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
.hover-panel-close {
|
|
79
120
|
position: absolute;
|
|
80
121
|
top: 0.5rem;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A plain, alphabetical listing of the `abbreviations` collection (rule N14)
|
|
4
|
+
* — deliberately the simplest possible reader-facing view of this content.
|
|
5
|
+
* Unlike `NotationGlossary.astro`, there is no hover popup, no formula
|
|
6
|
+
* rendering, and no `NotationLayer`: an abbreviation card is a name
|
|
7
|
+
* resolution only, so a definition list is the whole job.
|
|
8
|
+
*/
|
|
9
|
+
import { getCollection } from 'astro:content';
|
|
10
|
+
|
|
11
|
+
const entries = (await getCollection('abbreviations')).sort((left, right) =>
|
|
12
|
+
left.data.abbr.localeCompare(right.data.abbr),
|
|
13
|
+
);
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
entries.length === 0 ? (
|
|
18
|
+
<p class="abbreviation-glossary-empty">
|
|
19
|
+
No abbreviations are catalogued yet.
|
|
20
|
+
</p>
|
|
21
|
+
) : (
|
|
22
|
+
<dl class="abbreviation-glossary">
|
|
23
|
+
{entries.map((entry) => (
|
|
24
|
+
<div class="abbreviation-entry" id={`abbr-${entry.data.key}`}>
|
|
25
|
+
<dt>
|
|
26
|
+
{entry.data.abbr}
|
|
27
|
+
{entry.data.aliases.length > 0 && (
|
|
28
|
+
<span class="abbreviation-aliases">
|
|
29
|
+
{' '}
|
|
30
|
+
(also {entry.data.aliases.join(', ')})
|
|
31
|
+
</span>
|
|
32
|
+
)}
|
|
33
|
+
</dt>
|
|
34
|
+
<dd>{entry.data.expansion}</dd>
|
|
35
|
+
</div>
|
|
36
|
+
))}
|
|
37
|
+
</dl>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
.abbreviation-glossary {
|
|
43
|
+
display: grid;
|
|
44
|
+
grid-template-columns: max-content 1fr;
|
|
45
|
+
column-gap: 1rem;
|
|
46
|
+
row-gap: 0.75rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.abbreviation-entry {
|
|
50
|
+
display: contents;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.abbreviation-entry dt {
|
|
54
|
+
font-weight: 600;
|
|
55
|
+
white-space: nowrap;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.abbreviation-entry dd {
|
|
59
|
+
margin: 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.abbreviation-aliases {
|
|
63
|
+
font-weight: 400;
|
|
64
|
+
color: var(--sl-color-gray-3);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.abbreviation-glossary-empty {
|
|
68
|
+
color: var(--sl-color-gray-3);
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Hover/pin panel for the inline abbreviation links `remarkAbbreviations`
|
|
4
|
+
* emits (N14, close sibling of `CitationLayer.astro`). The links are real
|
|
5
|
+
* anchors to the glossary's "Abbreviations" section and work with no
|
|
6
|
+
* JavaScript; this island only adds a quick look at the resolution without
|
|
7
|
+
* leaving the reader's place.
|
|
8
|
+
*
|
|
9
|
+
* The abbreviations table is small by nature (a name + one-line resolution
|
|
10
|
+
* each), so unlike the math-notation layer this renders every entry
|
|
11
|
+
* unconditionally on every lesson page — one hidden block per entry — rather
|
|
12
|
+
* than computing a per-page dependency closure. The panel reads its content
|
|
13
|
+
* from that static markup, never from a second serialized copy of the table.
|
|
14
|
+
* Positioning, pin, Escape, and outside-close come from the shared
|
|
15
|
+
* `HoverPanel` primitive.
|
|
16
|
+
*/
|
|
17
|
+
import { getCollection } from 'astro:content';
|
|
18
|
+
import HoverPanel from '../HoverPanel.astro';
|
|
19
|
+
|
|
20
|
+
const entries = await getCollection('abbreviations');
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<HoverPanel id="abbreviation-panel" label="Abbreviation" data-abbreviation-panel>
|
|
24
|
+
<div class="abbreviation-panel-body">
|
|
25
|
+
<p class="abbreviation-panel-term" data-panel-term></p>
|
|
26
|
+
<p class="abbreviation-panel-expansion" data-panel-expansion></p>
|
|
27
|
+
<a class="abbreviation-panel-jump" data-panel-jump
|
|
28
|
+
>See it in the glossary</a
|
|
29
|
+
>
|
|
30
|
+
</div>
|
|
31
|
+
</HoverPanel>
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
entries.map((entry) => (
|
|
35
|
+
<p hidden data-abbr-entry={entry.data.key}>
|
|
36
|
+
{entry.data.expansion}
|
|
37
|
+
</p>
|
|
38
|
+
))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
<script>
|
|
42
|
+
import { createHoverPanel, closestElement } from '../hover-panel';
|
|
43
|
+
|
|
44
|
+
function setup() {
|
|
45
|
+
const panel = document.querySelector<HTMLElement>(
|
|
46
|
+
'[data-abbreviation-panel]',
|
|
47
|
+
);
|
|
48
|
+
if (!panel || panel.dataset.hoverPanelReady === 'true') return;
|
|
49
|
+
|
|
50
|
+
const markers = Array.from(
|
|
51
|
+
document.querySelectorAll<HTMLAnchorElement>(
|
|
52
|
+
'.sl-markdown-content a.abbreviation-term[data-abbr-key]',
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
if (markers.length === 0) return;
|
|
56
|
+
const markerSet = new Set<HTMLElement>(markers);
|
|
57
|
+
|
|
58
|
+
const term = panel.querySelector<HTMLElement>('[data-panel-term]')!;
|
|
59
|
+
const expansion = panel.querySelector<HTMLElement>(
|
|
60
|
+
'[data-panel-expansion]',
|
|
61
|
+
)!;
|
|
62
|
+
const jump = panel.querySelector<HTMLAnchorElement>('[data-panel-jump]')!;
|
|
63
|
+
|
|
64
|
+
const markerFrom = (node: EventTarget | null) => {
|
|
65
|
+
const candidate = closestElement(
|
|
66
|
+
node,
|
|
67
|
+
'a.abbreviation-term[data-abbr-key]',
|
|
68
|
+
);
|
|
69
|
+
return candidate && markerSet.has(candidate) ? candidate : null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Fill the panel from the matching hidden `[data-abbr-entry]` block —
|
|
73
|
+
// never from a second serialized copy of the table.
|
|
74
|
+
const render = (marker: HTMLElement, key: string) => {
|
|
75
|
+
const entry = document.querySelector<HTMLElement>(
|
|
76
|
+
`[data-abbr-entry="${CSS.escape(key)}"]`,
|
|
77
|
+
);
|
|
78
|
+
if (!entry) return false;
|
|
79
|
+
|
|
80
|
+
term.textContent = marker.textContent ?? '';
|
|
81
|
+
expansion.textContent = entry.textContent ?? '';
|
|
82
|
+
jump.href = marker.getAttribute('href') ?? '#abbreviations';
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
createHoverPanel({
|
|
87
|
+
panel,
|
|
88
|
+
groupId: 'abbreviation',
|
|
89
|
+
triggerFrom: markerFrom,
|
|
90
|
+
keyFor: (element) => element.dataset.abbrKey ?? null,
|
|
91
|
+
render,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setup();
|
|
96
|
+
document.addEventListener('astro:page-load', setup);
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<style>
|
|
100
|
+
@layer starlight.components {
|
|
101
|
+
:global(a.abbreviation-term) {
|
|
102
|
+
text-decoration: underline dotted;
|
|
103
|
+
text-underline-offset: 0.2em;
|
|
104
|
+
color: inherit;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
:global(a.abbreviation-term:hover),
|
|
108
|
+
:global(a.abbreviation-term:focus-visible) {
|
|
109
|
+
background: var(--sl-color-accent-low);
|
|
110
|
+
color: var(--sl-color-text-accent);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.abbreviation-panel-body p {
|
|
114
|
+
margin: 0 0 0.5rem;
|
|
115
|
+
line-height: 1.5;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.abbreviation-panel-term {
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.abbreviation-panel-jump {
|
|
123
|
+
display: inline-block;
|
|
124
|
+
font-size: var(--sl-text-xs);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
type ProgressRepository,
|
|
11
11
|
} from '../../progress/ProgressRepository';
|
|
12
12
|
import { useSessionUser } from '../../session/user-context';
|
|
13
|
+
import type { MixedContentNode } from '../notation/render-notation-math';
|
|
14
|
+
import RenderedContent from './RenderedContent';
|
|
15
|
+
import { shuffled } from './assessment-randomization';
|
|
13
16
|
|
|
14
17
|
interface ChoiceOption {
|
|
15
18
|
id: string;
|
|
@@ -20,8 +23,14 @@ interface AssessmentItemBase {
|
|
|
20
23
|
id: string;
|
|
21
24
|
competencyId?: string;
|
|
22
25
|
evidenceKind: 'direct' | 'transfer';
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Pre-split and glyph-resolved by `AssessmentSet.astro` from
|
|
28
|
+
* `manifest.assessmentContent` (schema v8) — the same KaTeX pipeline
|
|
29
|
+
* lesson body math renders through, not the raw authored string. Render
|
|
30
|
+
* with {@link RenderedContent}, never as a plain string.
|
|
31
|
+
*/
|
|
32
|
+
promptContent: readonly MixedContentNode[];
|
|
33
|
+
explanationContent: readonly MixedContentNode[];
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
interface ChoiceItem extends AssessmentItemBase {
|
|
@@ -42,7 +51,10 @@ type Outcome = 'correct' | 'incorrect';
|
|
|
42
51
|
interface Props {
|
|
43
52
|
/** Assessment collection id — scopes recorded attempts and analytics events. */
|
|
44
53
|
assessmentId: string;
|
|
54
|
+
/** Assessment title; the set wrapper renders it, direct callers use it here. */
|
|
45
55
|
title: string;
|
|
56
|
+
/** Stable id of the heading emitted by `AssessmentSet.astro`. */
|
|
57
|
+
labelledBy?: string;
|
|
46
58
|
items: Item[];
|
|
47
59
|
/**
|
|
48
60
|
* Seams (Phase H1). Both default to the no-op implementations the static
|
|
@@ -70,18 +82,38 @@ function statusFor(outcome: Outcome | undefined): {
|
|
|
70
82
|
interface QuestionProps {
|
|
71
83
|
item: Item;
|
|
72
84
|
index: number;
|
|
85
|
+
headingTag: 'h3' | 'h4';
|
|
73
86
|
outcome: Outcome | undefined;
|
|
74
87
|
onResult: (id: string, outcome: Outcome) => void;
|
|
75
88
|
onReset: (id: string) => void;
|
|
76
89
|
}
|
|
77
90
|
|
|
78
|
-
function Question({
|
|
91
|
+
function Question({
|
|
92
|
+
item,
|
|
93
|
+
index,
|
|
94
|
+
headingTag: Heading,
|
|
95
|
+
outcome,
|
|
96
|
+
onResult,
|
|
97
|
+
onReset,
|
|
98
|
+
}: QuestionProps) {
|
|
79
99
|
const panelId = useId();
|
|
80
100
|
const groupName = useId();
|
|
81
101
|
const inputId = useId();
|
|
82
102
|
const [open, setOpen] = useState(false);
|
|
83
103
|
const [choice, setChoice] = useState<string | null>(null);
|
|
84
104
|
const [numeric, setNumeric] = useState('');
|
|
105
|
+
const [choiceOptions, setChoiceOptions] = useState<ChoiceOption[]>(() =>
|
|
106
|
+
item.type === 'single-choice' ? item.options : [],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Keep the server-rendered authored order hydration-safe, then generate a
|
|
110
|
+
// fresh display order in the browser. It remains stable for this view and
|
|
111
|
+
// is regenerated by a full reload; correctness continues to use option ids.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (item.type === 'single-choice') {
|
|
114
|
+
setChoiceOptions(shuffled(item.options));
|
|
115
|
+
}
|
|
116
|
+
}, [item]);
|
|
85
117
|
|
|
86
118
|
const checked = outcome !== undefined;
|
|
87
119
|
const parsed = Number(numeric);
|
|
@@ -108,7 +140,7 @@ function Question({ item, index, outcome, onResult, onReset }: QuestionProps) {
|
|
|
108
140
|
|
|
109
141
|
return (
|
|
110
142
|
<li className="assessment-item">
|
|
111
|
-
<
|
|
143
|
+
<Heading className="assessment-item-heading">
|
|
112
144
|
<button
|
|
113
145
|
type="button"
|
|
114
146
|
className="assessment-disclosure"
|
|
@@ -140,17 +172,19 @@ function Question({ item, index, outcome, onResult, onReset }: QuestionProps) {
|
|
|
140
172
|
/>
|
|
141
173
|
</svg>
|
|
142
174
|
</button>
|
|
143
|
-
</
|
|
175
|
+
</Heading>
|
|
144
176
|
|
|
145
177
|
<div id={panelId} className="assessment-panel" hidden={!open}>
|
|
146
|
-
<p className="assessment-prompt">
|
|
178
|
+
<p className="assessment-prompt">
|
|
179
|
+
<RenderedContent content={item.promptContent} />
|
|
180
|
+
</p>
|
|
147
181
|
|
|
148
182
|
{item.type === 'single-choice' ? (
|
|
149
183
|
<fieldset className="assessment-choices">
|
|
150
184
|
<legend className="sr-only">
|
|
151
185
|
Answer options for question {index}
|
|
152
186
|
</legend>
|
|
153
|
-
{
|
|
187
|
+
{choiceOptions.map((option) => (
|
|
154
188
|
<label key={option.id} className="assessment-option">
|
|
155
189
|
<input
|
|
156
190
|
type="radio"
|
|
@@ -203,7 +237,7 @@ function Question({ item, index, outcome, onResult, onReset }: QuestionProps) {
|
|
|
203
237
|
<strong>
|
|
204
238
|
{outcome === 'correct' ? 'Correct.' : 'Not quite.'}
|
|
205
239
|
</strong>{' '}
|
|
206
|
-
{item.
|
|
240
|
+
<RenderedContent content={item.explanationContent} />
|
|
207
241
|
</>
|
|
208
242
|
) : (
|
|
209
243
|
'Check your answer to reveal the explanation.'
|
|
@@ -217,11 +251,13 @@ function Question({ item, index, outcome, onResult, onReset }: QuestionProps) {
|
|
|
217
251
|
export default function AssessmentRunner({
|
|
218
252
|
assessmentId,
|
|
219
253
|
title,
|
|
254
|
+
labelledBy,
|
|
220
255
|
items,
|
|
221
256
|
progress = noopProgressRepository,
|
|
222
257
|
analytics = noopAnalyticsEmitter,
|
|
223
258
|
}: Props) {
|
|
224
|
-
const
|
|
259
|
+
const ownHeadingId = useId();
|
|
260
|
+
const headingId = labelledBy ?? ownHeadingId;
|
|
225
261
|
const user = useSessionUser();
|
|
226
262
|
const [results, setResults] = useState<Record<string, Outcome>>({});
|
|
227
263
|
|
|
@@ -295,20 +331,19 @@ export default function AssessmentRunner({
|
|
|
295
331
|
|
|
296
332
|
return (
|
|
297
333
|
<section className="assessment-shell" aria-labelledby={headingId}>
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
{
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
</p>
|
|
305
|
-
</div>
|
|
334
|
+
{!labelledBy && <h3 id={headingId}>{title}</h3>}
|
|
335
|
+
<p className="assessment-progress" aria-live="polite">
|
|
336
|
+
{answered === 0
|
|
337
|
+
? `${items.length} question${items.length === 1 ? '' : 's'}`
|
|
338
|
+
: `${correct} of ${items.length} correct`}
|
|
339
|
+
</p>
|
|
306
340
|
<ol className="assessment-list">
|
|
307
341
|
{items.map((item, position) => (
|
|
308
342
|
<Question
|
|
309
343
|
key={item.id}
|
|
310
344
|
item={item}
|
|
311
345
|
index={position + 1}
|
|
346
|
+
headingTag={labelledBy ? 'h3' : 'h4'}
|
|
312
347
|
outcome={results[item.id]}
|
|
313
348
|
onResult={report}
|
|
314
349
|
onReset={clear}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { getCollection } from 'astro:content';
|
|
3
3
|
import { loadManifest } from '../../compiler/manifest';
|
|
4
|
+
import type { ContentSegment } from '../../reference/mixed-content';
|
|
5
|
+
import { renderNotationMath } from '../notation/render-notation-math';
|
|
6
|
+
import type { MixedContentNode } from '../notation/render-notation-math';
|
|
7
|
+
import { knowledgeCheckAnchorId } from '../../reference/knowledge-checks.mjs';
|
|
4
8
|
import AssessmentRunner from './AssessmentRunner';
|
|
5
9
|
|
|
6
10
|
interface Props {
|
|
7
11
|
/**
|
|
8
12
|
* Explicit assessment id. Omitted in lesson bodies (Phase C2): the set is
|
|
9
13
|
* resolved from the route's colocated `<lesson>.checks.yml` via the manifest.
|
|
14
|
+
* Put an explicit id in that list as well to publish its part-wide number to
|
|
15
|
+
* `[[check-…]]` references; undeclared legacy embeds keep a local fallback.
|
|
10
16
|
*/
|
|
11
17
|
id?: string;
|
|
12
18
|
}
|
|
@@ -19,36 +25,114 @@ const lessonId = Astro.url.pathname
|
|
|
19
25
|
.replace(/\/$/, '')
|
|
20
26
|
.replace(/\//g, '.');
|
|
21
27
|
|
|
28
|
+
const manifest = await loadManifest();
|
|
29
|
+
const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
|
|
30
|
+
if (!lesson) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`AssessmentSet: route "${Astro.url.pathname}" does not resolve to a lesson in the manifest`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
let ids: string[];
|
|
23
37
|
if (explicitId) {
|
|
24
38
|
ids = [explicitId];
|
|
25
39
|
} else {
|
|
26
|
-
|
|
27
|
-
ids = [
|
|
28
|
-
...(manifest.lessons.find((lesson) => lesson.id === lessonId)
|
|
29
|
-
?.assessments ?? []),
|
|
30
|
-
];
|
|
40
|
+
ids = [...lesson.assessments];
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
const assessments = await getCollection('assessments');
|
|
34
|
-
const entries = ids.map((id) => {
|
|
44
|
+
const entries = ids.map((id, index) => {
|
|
35
45
|
const entry = assessments.find((candidate) => candidate.data.id === id);
|
|
36
46
|
if (!entry) {
|
|
37
47
|
throw new Error(
|
|
38
48
|
`AssessmentSet: no assessment collection entry with id "${id}"`,
|
|
39
49
|
);
|
|
40
50
|
}
|
|
41
|
-
|
|
51
|
+
const publishedNumber = lesson.knowledgeChecks.find(
|
|
52
|
+
(check) => check.key === id,
|
|
53
|
+
)?.number;
|
|
54
|
+
const localIndex =
|
|
55
|
+
lesson.numbering.checks + lesson.assessments.length + index + 1;
|
|
56
|
+
const number =
|
|
57
|
+
publishedNumber ??
|
|
58
|
+
(lesson.part > 0 ? `${lesson.part}.${localIndex}` : String(index + 1));
|
|
59
|
+
return { entry, number, anchorId: knowledgeCheckAnchorId(id) };
|
|
42
60
|
});
|
|
61
|
+
|
|
62
|
+
// `manifest.assessmentContent` (schema v8) already resolved every glyph in
|
|
63
|
+
// every item's `prompt` / `explanation` against this page's lesson scope —
|
|
64
|
+
// same `pageScope`, same relationship, as `gate-math.ts`'s assessment check.
|
|
65
|
+
// Rendering the resolved LaTeX to a `SafeMathNode` tree happens here, in an
|
|
66
|
+
// Astro component, not in `manifest.ts`: the compiler layer stays
|
|
67
|
+
// framework-agnostic, and `renderNotationMath` already lives one directory
|
|
68
|
+
// over for exactly this reason (`NotationMath.astro` calls it the same way).
|
|
69
|
+
const contentByKey = new Map(
|
|
70
|
+
manifest.assessmentContent
|
|
71
|
+
.filter((item) => item.lessonId === lessonId)
|
|
72
|
+
.map((item) => [`${item.assessmentId}:${item.itemId}`, item]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
function renderedContent(
|
|
76
|
+
raw: string,
|
|
77
|
+
segments: readonly ContentSegment[] | undefined,
|
|
78
|
+
): MixedContentNode[] {
|
|
79
|
+
if (!segments) return [{ type: 'text', value: raw }];
|
|
80
|
+
return segments.map((segment) =>
|
|
81
|
+
segment.type === 'text'
|
|
82
|
+
? segment
|
|
83
|
+
: { type: 'math', nodes: renderNotationMath(segment.latex) },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
43
86
|
---
|
|
44
87
|
|
|
45
88
|
{
|
|
46
|
-
entries.map((entry) => (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
89
|
+
entries.map(({ entry, number, anchorId }) => (
|
|
90
|
+
<>
|
|
91
|
+
<div
|
|
92
|
+
class="sl-heading-wrapper level-h2 assessment-heading-wrapper"
|
|
93
|
+
data-collapsible="true"
|
|
94
|
+
data-default-open="true"
|
|
95
|
+
>
|
|
96
|
+
<h2 id={anchorId}>
|
|
97
|
+
<span class="assessment-heading-number">Knowledge check {number}</span>
|
|
98
|
+
<span aria-hidden="true"> — </span>
|
|
99
|
+
{entry.data.title}
|
|
100
|
+
</h2>
|
|
101
|
+
<a
|
|
102
|
+
class="sl-anchor-link"
|
|
103
|
+
href={`#${anchorId}`}
|
|
104
|
+
aria-label={`Knowledge check ${number} titled “${entry.data.title}”`}
|
|
105
|
+
>
|
|
106
|
+
<span aria-hidden="true" class="sl-anchor-icon">
|
|
107
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
108
|
+
<path d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42" />
|
|
109
|
+
</svg>
|
|
110
|
+
</span>
|
|
111
|
+
<span class="sr-only" data-pagefind-ignore="true">
|
|
112
|
+
Link to knowledge check {number}: {entry.data.title}
|
|
113
|
+
</span>
|
|
114
|
+
</a>
|
|
115
|
+
</div>
|
|
116
|
+
<AssessmentRunner
|
|
117
|
+
client:visible
|
|
118
|
+
assessmentId={entry.data.id}
|
|
119
|
+
title={entry.data.title}
|
|
120
|
+
labelledBy={anchorId}
|
|
121
|
+
items={entry.data.items.map((item: (typeof entry.data.items)[number]) => {
|
|
122
|
+
const content = contentByKey.get(`${entry.data.id}:${item.id}`);
|
|
123
|
+
return {
|
|
124
|
+
...item,
|
|
125
|
+
promptContent: renderedContent(
|
|
126
|
+
item.prompt,
|
|
127
|
+
content?.promptSegments,
|
|
128
|
+
),
|
|
129
|
+
explanationContent: renderedContent(
|
|
130
|
+
item.explanation,
|
|
131
|
+
content?.explanationSegments,
|
|
132
|
+
),
|
|
133
|
+
};
|
|
134
|
+
})}
|
|
135
|
+
/>
|
|
136
|
+
</>
|
|
53
137
|
))
|
|
54
138
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Fragment } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
MixedContentNode,
|
|
4
|
+
SafeMathNode,
|
|
5
|
+
} from '../notation/render-notation-math';
|
|
6
|
+
import { toReactProps } from '../notation/safe-math-to-react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The React-side twin of `SafeMathNode.astro`: walk the same structured,
|
|
10
|
+
* pre-sanitized tree and emit real elements via `React.createElement`
|
|
11
|
+
* (`h`/JSX below), never `dangerouslySetInnerHTML` — no HTML string crosses
|
|
12
|
+
* into this island any more than it does into an Astro page.
|
|
13
|
+
*/
|
|
14
|
+
function SafeMath({ node }: { node: SafeMathNode }) {
|
|
15
|
+
if (node.type === 'text') return <>{node.value}</>;
|
|
16
|
+
const Tag = node.tagName as keyof JSX.IntrinsicElements;
|
|
17
|
+
return (
|
|
18
|
+
<Tag {...toReactProps(node.properties)}>
|
|
19
|
+
{node.children.map((child, index) => (
|
|
20
|
+
// eslint-disable-next-line react/no-array-index-key -- a static, sanitized render tree; index is stable across renders
|
|
21
|
+
<SafeMath key={index} node={child} />
|
|
22
|
+
))}
|
|
23
|
+
</Tag>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Render an assessment `prompt` / `explanation` after `AssessmentSet.astro`
|
|
29
|
+
* has split, resolved, and rendered it (`manifest.assessmentContent` ->
|
|
30
|
+
* `renderNotationMath`): plain text runs verbatim, each math run through
|
|
31
|
+
* {@link SafeMath}.
|
|
32
|
+
*/
|
|
33
|
+
export default function RenderedContent({
|
|
34
|
+
content,
|
|
35
|
+
}: {
|
|
36
|
+
content: readonly MixedContentNode[];
|
|
37
|
+
}) {
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
{content.map((segment, index) =>
|
|
41
|
+
segment.type === 'text' ? (
|
|
42
|
+
// eslint-disable-next-line react/no-array-index-key -- a static, ordered segment list
|
|
43
|
+
<Fragment key={index}>{segment.value}</Fragment>
|
|
44
|
+
) : (
|
|
45
|
+
<span key={index} className="notation-math">
|
|
46
|
+
{segment.nodes.map((node, nodeIndex) => (
|
|
47
|
+
// eslint-disable-next-line react/no-array-index-key -- a static, sanitized render tree
|
|
48
|
+
<SafeMath key={nodeIndex} node={node} />
|
|
49
|
+
))}
|
|
50
|
+
</span>
|
|
51
|
+
),
|
|
52
|
+
)}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Return a shuffled copy without changing the authored answer array. */
|
|
2
|
+
export function shuffled<T>(
|
|
3
|
+
values: readonly T[],
|
|
4
|
+
random: () => number = Math.random,
|
|
5
|
+
): T[] {
|
|
6
|
+
const result = [...values];
|
|
7
|
+
for (let index = result.length - 1; index > 0; index--) {
|
|
8
|
+
const draw = random();
|
|
9
|
+
const bounded = Number.isFinite(draw)
|
|
10
|
+
? Math.min(Math.max(draw, 0), 1 - Number.EPSILON)
|
|
11
|
+
: 0;
|
|
12
|
+
const swapIndex = Math.floor(bounded * (index + 1));
|
|
13
|
+
[result[index], result[swapIndex]] = [result[swapIndex], result[index]];
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|