@panaversity/ksor 0.0.29 → 0.0.30
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 +41 -0
- package/dist/cli.mjs +7 -3
- package/dist/{gateway-api-8lNruq9e-CuohjtoK.mjs → gateway-api-CbFkHZiU-HvlJRjRB.mjs} +1 -1
- package/dist/gateway.mjs +1 -1
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +3 -1
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +3 -1
- package/templates/scaffold/AGENTS.md +51 -5
- package/templates/scaffold/knowledge/what-is-a-ksor.quiz.yaml +90 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +9 -2
- package/templates/scaffold/system/site/app/global.css +68 -0
- package/templates/scaffold/system/site/components/quiz.tsx +321 -0
- package/templates/scaffold/system/site/components/study-aids.tsx +1 -1
- package/templates/scaffold/system/site/lib/attachment-rule.ts +7 -0
- package/templates/scaffold/system/site/lib/attachments.ts +41 -3
- package/templates/scaffold/system/site/lib/deck.ts +3 -12
- package/templates/scaffold/system/site/lib/identity.ts +55 -0
- package/templates/scaffold/system/site/lib/quiz-audit.ts +306 -0
- package/templates/scaffold/system/site/lib/quiz-round.ts +57 -0
- package/templates/scaffold/system/site/lib/quiz.ts +84 -0
- package/templates/scaffold/system/site/source.config.ts +16 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, RotateCcw, X } from "lucide-react";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
|
5
|
+
|
|
6
|
+
import { StudyAidHeader } from "@/components/study-aids";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
9
|
+
import { Progress } from "@/components/ui/progress";
|
|
10
|
+
import type { QuizEntry, QuizQuestion } from "@/lib/attachments";
|
|
11
|
+
import { hasMoreRounds, roundOf } from "@/lib/quiz-round";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Taking a quiz on the document you just read.
|
|
15
|
+
*
|
|
16
|
+
* The interaction model is the predecessor's and is the good part of it:
|
|
17
|
+
* answer, see IMMEDIATELY whether you were right, and read the explanation
|
|
18
|
+
* before moving on. Its own usage guide is explicit that this teaches through
|
|
19
|
+
* the mistake, which has more effect than a score revealed at the end — so the
|
|
20
|
+
* explanation is the point of the component and the score is a footnote.
|
|
21
|
+
*
|
|
22
|
+
* What is deliberately absent is everything the predecessor's `GatedQuiz`
|
|
23
|
+
* wrapper added: a sign-in gate, an XP modal, and a POST of the score to a
|
|
24
|
+
* progress API. The site is a static export with no backend, and decision 7
|
|
25
|
+
* fixes it as preview and review rather than an editor. A score here is the
|
|
26
|
+
* reader's, stays in their browser, and is sent nowhere.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const STORAGE_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
interface Persisted {
|
|
32
|
+
readonly version: number;
|
|
33
|
+
/** questionHash -> the option index this reader chose. */
|
|
34
|
+
readonly answers: Record<string, number>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function storageKey(quizPath: string): string {
|
|
38
|
+
return `ksor:quiz:${quizPath}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readPersisted(quizPath: string): Record<string, number> {
|
|
42
|
+
try {
|
|
43
|
+
const raw = window.localStorage.getItem(storageKey(quizPath));
|
|
44
|
+
if (raw === null) return {};
|
|
45
|
+
const record = JSON.parse(raw) as Persisted;
|
|
46
|
+
// A version bump discards rather than migrates: the only thing lost is
|
|
47
|
+
// which options a reader clicked, and guessing at an old shape is how a
|
|
48
|
+
// reader ends up with someone else's answers against their questions.
|
|
49
|
+
if (record.version !== STORAGE_VERSION) return {};
|
|
50
|
+
return record.answers ?? {};
|
|
51
|
+
} catch {
|
|
52
|
+
// A private window, cleared site data, or storage disabled entirely. An
|
|
53
|
+
// unanswered quiz is a correct starting state, so this is not an error.
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writePersisted(quizPath: string, answers: Record<string, number>): void {
|
|
59
|
+
try {
|
|
60
|
+
window.localStorage.setItem(
|
|
61
|
+
storageKey(quizPath),
|
|
62
|
+
JSON.stringify({ version: STORAGE_VERSION, answers } satisfies Persisted),
|
|
63
|
+
);
|
|
64
|
+
} catch {
|
|
65
|
+
// Storage is a convenience here, never the record. Losing it costs the
|
|
66
|
+
// reader their place and nothing else.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The letter an author and a reader both use for an option index. */
|
|
71
|
+
function letterOf(index: number): string {
|
|
72
|
+
return String.fromCharCode(65 + index);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function Quiz({ quiz }: { quiz: QuizEntry }): ReactElement {
|
|
76
|
+
const size = quiz.questionsPerRound;
|
|
77
|
+
const banked = quiz.questions.length;
|
|
78
|
+
const canRedraw = hasMoreRounds(banked, size);
|
|
79
|
+
|
|
80
|
+
// Round zero is the authored order, chosen on the server AND the client so
|
|
81
|
+
// the first paint matches: `roundOf` returns the bank untouched when it fits
|
|
82
|
+
// in one round, and for a larger bank the shuffle happens only after mount
|
|
83
|
+
// (see the effect below). Sampling during render would differ between the
|
|
84
|
+
// server HTML and the first client render and would hydrate mismatched.
|
|
85
|
+
const [round, setRound] = useState<readonly QuizQuestion[]>(() =>
|
|
86
|
+
roundOf(quiz.questions, size, () => 0),
|
|
87
|
+
);
|
|
88
|
+
const [answers, setAnswers] = useState<Record<string, number>>({});
|
|
89
|
+
const [hydrated, setHydrated] = useState(false);
|
|
90
|
+
const [index, setIndex] = useState(0);
|
|
91
|
+
const [done, setDone] = useState(false);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
setAnswers(readPersisted(quiz.path));
|
|
95
|
+
if (canRedraw) setRound(roundOf(quiz.questions, size, Math.random));
|
|
96
|
+
setHydrated(true);
|
|
97
|
+
}, [quiz.path, quiz.questions, size, canRedraw]);
|
|
98
|
+
|
|
99
|
+
const current = round[index];
|
|
100
|
+
const chosen = current === undefined ? undefined : answers[current.hash];
|
|
101
|
+
const answered = chosen !== undefined;
|
|
102
|
+
|
|
103
|
+
const answeredCount = useMemo(
|
|
104
|
+
() => round.filter((q) => answers[q.hash] !== undefined).length,
|
|
105
|
+
[round, answers],
|
|
106
|
+
);
|
|
107
|
+
const correctCount = useMemo(
|
|
108
|
+
() => round.filter((q) => answers[q.hash] === q.answer).length,
|
|
109
|
+
[round, answers],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const choose = useCallback(
|
|
113
|
+
(option: number) => {
|
|
114
|
+
if (current === undefined || answers[current.hash] !== undefined) return;
|
|
115
|
+
const next = { ...answers, [current.hash]: option };
|
|
116
|
+
setAnswers(next);
|
|
117
|
+
writePersisted(quiz.path, next);
|
|
118
|
+
},
|
|
119
|
+
[answers, current, quiz.path],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const newRound = useCallback(() => {
|
|
123
|
+
setRound(roundOf(quiz.questions, size, Math.random));
|
|
124
|
+
setAnswers({});
|
|
125
|
+
writePersisted(quiz.path, {});
|
|
126
|
+
setIndex(0);
|
|
127
|
+
setDone(false);
|
|
128
|
+
}, [quiz.questions, size, quiz.path]);
|
|
129
|
+
|
|
130
|
+
const retry = useCallback(() => {
|
|
131
|
+
setAnswers({});
|
|
132
|
+
writePersisted(quiz.path, {});
|
|
133
|
+
setIndex(0);
|
|
134
|
+
setDone(false);
|
|
135
|
+
}, [quiz.path]);
|
|
136
|
+
|
|
137
|
+
if (current === undefined) return <></>;
|
|
138
|
+
|
|
139
|
+
if (done) {
|
|
140
|
+
return (
|
|
141
|
+
<section aria-label="Quiz results">
|
|
142
|
+
<StudyAidHeader title={quiz.title} description={quiz.description} />
|
|
143
|
+
<Card className="mx-auto max-w-2xl">
|
|
144
|
+
<CardContent className="flex flex-col items-center gap-6 py-12 text-center">
|
|
145
|
+
<p className="font-(family-name:--font-display) text-5xl font-semibold tabular-nums text-fd-foreground">
|
|
146
|
+
{correctCount}
|
|
147
|
+
<span className="text-fd-muted-foreground">/{round.length}</span>
|
|
148
|
+
</p>
|
|
149
|
+
<p className="max-w-sm text-sm text-fd-muted-foreground">
|
|
150
|
+
{/* No pass mark, deliberately: this checks understanding of a
|
|
151
|
+
document, it does not certify anybody. */}
|
|
152
|
+
Answers are kept in this browser only.
|
|
153
|
+
</p>
|
|
154
|
+
<div className="flex flex-wrap justify-center gap-3">
|
|
155
|
+
<Button variant="outline" onClick={() => setIndex(0)}>
|
|
156
|
+
Review answers
|
|
157
|
+
</Button>
|
|
158
|
+
{canRedraw ? (
|
|
159
|
+
<Button onClick={newRound}>
|
|
160
|
+
<RotateCcw aria-hidden className="size-4" />
|
|
161
|
+
Another round
|
|
162
|
+
</Button>
|
|
163
|
+
) : (
|
|
164
|
+
<Button onClick={retry}>
|
|
165
|
+
<RotateCcw aria-hidden className="size-4" />
|
|
166
|
+
Start again
|
|
167
|
+
</Button>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</CardContent>
|
|
171
|
+
</Card>
|
|
172
|
+
</section>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<section aria-label="Quiz">
|
|
178
|
+
<StudyAidHeader title={quiz.title} description={quiz.description} />
|
|
179
|
+
|
|
180
|
+
<div className="mx-auto max-w-2xl">
|
|
181
|
+
<div className="mb-4 flex items-baseline justify-between gap-4">
|
|
182
|
+
<p className="font-mono text-xs tracking-wide text-fd-muted-foreground uppercase">
|
|
183
|
+
Question {index + 1} / {round.length}
|
|
184
|
+
{canRedraw ? <span> · drawn from {banked}</span> : null}
|
|
185
|
+
</p>
|
|
186
|
+
<p className="font-mono text-xs tabular-nums text-fd-muted-foreground">
|
|
187
|
+
{answeredCount} answered
|
|
188
|
+
</p>
|
|
189
|
+
</div>
|
|
190
|
+
<Progress value={(answeredCount / round.length) * 100} className="mb-8 h-1" />
|
|
191
|
+
|
|
192
|
+
<Card>
|
|
193
|
+
<CardContent className="flex flex-col gap-6 py-8">
|
|
194
|
+
<h3 className="text-lg leading-snug font-medium text-balance text-fd-foreground">
|
|
195
|
+
{current.question}
|
|
196
|
+
</h3>
|
|
197
|
+
|
|
198
|
+
<ul className="flex flex-col gap-2">
|
|
199
|
+
{current.options.map((option, i) => {
|
|
200
|
+
const isAnswer = i === current.answer;
|
|
201
|
+
const isChoice = i === chosen;
|
|
202
|
+
// Three things have to be legible at once after a wrong
|
|
203
|
+
// answer, and they are three different facts: which option is
|
|
204
|
+
// RIGHT (green), which one is WRONG (red), and which one YOU
|
|
205
|
+
// picked (the accent ring). The accent alone cannot carry two
|
|
206
|
+
// of those, which is what it was doing — the correct option
|
|
207
|
+
// wore the same colour as a selection, so choosing wrongly
|
|
208
|
+
// looked like the page had answered for you.
|
|
209
|
+
//
|
|
210
|
+
// Colour is never the only channel: the check and cross icons
|
|
211
|
+
// and the verdict line below say the same thing in shape and
|
|
212
|
+
// in words.
|
|
213
|
+
//
|
|
214
|
+
// Plain CSS classes, not Tailwind arbitrary values: a
|
|
215
|
+
// `border-[color:var(--x)]` utility did not paint in a real
|
|
216
|
+
// build even with the rule in the stylesheet and the token
|
|
217
|
+
// resolving on the element. `app/global.css` is where this
|
|
218
|
+
// record's semantic colour already lives.
|
|
219
|
+
const tone = !answered
|
|
220
|
+
? "border-fd-border hover:border-fd-primary/60 hover:bg-fd-accent"
|
|
221
|
+
: isAnswer
|
|
222
|
+
? "ksor-answer-correct"
|
|
223
|
+
: isChoice
|
|
224
|
+
? "ksor-answer-wrong"
|
|
225
|
+
: "border-fd-border opacity-60";
|
|
226
|
+
// Your own pick keeps the accent, whether it was right or
|
|
227
|
+
// wrong, so "what I chose" is never in doubt.
|
|
228
|
+
const mine = answered && isChoice ? " ksor-answer-mine" : "";
|
|
229
|
+
return (
|
|
230
|
+
<li key={option}>
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
disabled={answered}
|
|
234
|
+
onClick={() => choose(i)}
|
|
235
|
+
aria-pressed={isChoice}
|
|
236
|
+
className={`flex w-full items-start gap-3 rounded-lg border px-4 py-3 text-left text-sm transition-colors motion-safe:duration-150 ${tone}${mine} ${answered ? "cursor-default" : "cursor-pointer"}`}
|
|
237
|
+
>
|
|
238
|
+
<span className="mt-px font-mono text-xs text-fd-muted-foreground">
|
|
239
|
+
{letterOf(i)}
|
|
240
|
+
</span>
|
|
241
|
+
<span className="flex-1 text-fd-foreground">{option}</span>
|
|
242
|
+
{answered && isAnswer ? (
|
|
243
|
+
<Check
|
|
244
|
+
aria-label="correct answer"
|
|
245
|
+
className="ksor-answer-correct-text size-4 shrink-0"
|
|
246
|
+
/>
|
|
247
|
+
) : null}
|
|
248
|
+
{answered && isChoice && !isAnswer ? (
|
|
249
|
+
<X
|
|
250
|
+
aria-label="your answer, which is wrong"
|
|
251
|
+
className="ksor-answer-wrong-text size-4 shrink-0"
|
|
252
|
+
/>
|
|
253
|
+
) : null}
|
|
254
|
+
</button>
|
|
255
|
+
</li>
|
|
256
|
+
);
|
|
257
|
+
})}
|
|
258
|
+
</ul>
|
|
259
|
+
|
|
260
|
+
{answered ? (
|
|
261
|
+
<div
|
|
262
|
+
// Announced, because the whole value of the immediate-feedback
|
|
263
|
+
// model is this text, and a reader using a screen reader gets
|
|
264
|
+
// it only if the region says it changed.
|
|
265
|
+
role="status"
|
|
266
|
+
className="motion-safe:animate-in motion-safe:fade-in flex flex-col gap-3 border-t border-fd-border pt-5 text-sm"
|
|
267
|
+
>
|
|
268
|
+
<p className="font-mono text-xs tracking-wide uppercase">
|
|
269
|
+
{chosen === current.answer ? (
|
|
270
|
+
<span className="ksor-answer-correct-text">Correct</span>
|
|
271
|
+
) : (
|
|
272
|
+
<span className="ksor-answer-wrong-text">
|
|
273
|
+
Not quite — the answer is {letterOf(current.answer)}
|
|
274
|
+
</span>
|
|
275
|
+
)}
|
|
276
|
+
</p>
|
|
277
|
+
<p className="leading-relaxed text-fd-muted-foreground">{current.explanation}</p>
|
|
278
|
+
{current.source === undefined ? null : (
|
|
279
|
+
// "In the document", never "Source": a citation in this
|
|
280
|
+
// product carries a generation, and an attachment has no id
|
|
281
|
+
// to pin — calling this a source would sell provenance that
|
|
282
|
+
// is not here (spec §3).
|
|
283
|
+
<p className="font-mono text-xs text-fd-muted-foreground">
|
|
284
|
+
In the document: {current.source}
|
|
285
|
+
</p>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
) : null}
|
|
289
|
+
</CardContent>
|
|
290
|
+
</Card>
|
|
291
|
+
|
|
292
|
+
<div className="mt-6 flex items-center justify-between gap-4">
|
|
293
|
+
<Button
|
|
294
|
+
variant="ghost"
|
|
295
|
+
disabled={index === 0}
|
|
296
|
+
onClick={() => setIndex((i) => Math.max(0, i - 1))}
|
|
297
|
+
>
|
|
298
|
+
Back
|
|
299
|
+
</Button>
|
|
300
|
+
{index === round.length - 1 ? (
|
|
301
|
+
<Button disabled={!answered} onClick={() => setDone(true)}>
|
|
302
|
+
Finish
|
|
303
|
+
</Button>
|
|
304
|
+
) : (
|
|
305
|
+
<Button disabled={!answered} onClick={() => setIndex((i) => i + 1)}>
|
|
306
|
+
Next
|
|
307
|
+
</Button>
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
{/* Rendered only once the reader's own answers are in, so the server
|
|
312
|
+
HTML never claims a state this reader is not in. */}
|
|
313
|
+
{hydrated && answeredCount > 0 && !done ? (
|
|
314
|
+
<p className="mt-4 text-center font-mono text-xs text-fd-muted-foreground">
|
|
315
|
+
{correctCount} of {answeredCount} correct so far
|
|
316
|
+
</p>
|
|
317
|
+
) : null}
|
|
318
|
+
</div>
|
|
319
|
+
</section>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { ReactElement, ReactNode } from "react";
|
|
|
4
4
|
* The end-of-document region: what a reader DOES with a document once they
|
|
5
5
|
* have read it.
|
|
6
6
|
*
|
|
7
|
-
* The deck
|
|
7
|
+
* The deck and the quiz live here, in that order. Deliberately not a tab:
|
|
8
8
|
* a study aid is used AFTER the document, and a tab would hide the document
|
|
9
9
|
* while you used it. Deliberately one region rather than each aid finding its
|
|
10
10
|
* own spot on the page, so a second aid is a child here and not a new layout
|
|
@@ -21,6 +21,7 @@ export const ATTACHMENT_SUFFIXES = [
|
|
|
21
21
|
{ suffix: ".summary.md", kind: "summary" },
|
|
22
22
|
{ suffix: ".summary.mdx", kind: "summary" },
|
|
23
23
|
{ suffix: ".flashcards.yaml", kind: "deck" },
|
|
24
|
+
{ suffix: ".quiz.yaml", kind: "quiz" },
|
|
24
25
|
] as const;
|
|
25
26
|
|
|
26
27
|
export type AttachmentKind = (typeof ATTACHMENT_SUFFIXES)[number]["kind"];
|
|
@@ -37,6 +38,8 @@ export const ATTACHMENT_NEAR_MISSES = [
|
|
|
37
38
|
{ suffix: ".flashcards.yml", want: ".flashcards.yaml" },
|
|
38
39
|
{ suffix: ".flashcards.json", want: ".flashcards.yaml" },
|
|
39
40
|
{ suffix: ".summary.markdown", want: ".summary.md" },
|
|
41
|
+
{ suffix: ".quiz.yml", want: ".quiz.yaml" },
|
|
42
|
+
{ suffix: ".quiz.json", want: ".quiz.yaml" },
|
|
40
43
|
] as const;
|
|
41
44
|
|
|
42
45
|
/**
|
|
@@ -102,6 +105,7 @@ export function nearMissOf(
|
|
|
102
105
|
export const ATTACHMENT_CASES = [
|
|
103
106
|
{ name: "returns.summary.md", kind: "summary", parent: "returns.md" },
|
|
104
107
|
{ name: "returns.flashcards.yaml", kind: "deck", parent: "returns.md" },
|
|
108
|
+
{ name: "returns.quiz.yaml", kind: "quiz", parent: "returns.md" },
|
|
105
109
|
{ name: "index.summary.md", kind: "summary", parent: "index.md" },
|
|
106
110
|
// A stem containing dots keeps every one of them: the parent is the same
|
|
107
111
|
// name with the attachment suffix removed, never "up to the first dot".
|
|
@@ -110,15 +114,18 @@ export const ATTACHMENT_CASES = [
|
|
|
110
114
|
{ name: "returns.md", kind: null, parent: null },
|
|
111
115
|
{ name: "summary.md", kind: null, parent: null },
|
|
112
116
|
{ name: "flashcards.yaml", kind: null, parent: null },
|
|
117
|
+
{ name: "quiz.yaml", kind: null, parent: null },
|
|
113
118
|
{ name: "my-summary.md", kind: null, parent: null },
|
|
114
119
|
// A dotfile with no stem attaches to nothing — refused as an attachment so
|
|
115
120
|
// it is refused as an unexpected file instead, which is the honest error.
|
|
116
121
|
{ name: ".summary.md", kind: null, parent: null },
|
|
117
122
|
{ name: ".flashcards.yaml", kind: null, parent: null },
|
|
123
|
+
{ name: ".quiz.yaml", kind: null, parent: null },
|
|
118
124
|
// Case matters: the record already refuses two names differing only in case,
|
|
119
125
|
// so an uppercase suffix is a different file, not the same rule.
|
|
120
126
|
{ name: "returns.SUMMARY.md", kind: null, parent: null },
|
|
121
127
|
// Not attachments — near misses, which get their own refusal.
|
|
122
128
|
{ name: "returns.flashcards.yml", kind: null, parent: null },
|
|
123
129
|
{ name: "returns.flashcards.json", kind: null, parent: null },
|
|
130
|
+
{ name: "returns.quiz.yml", kind: null, parent: null },
|
|
124
131
|
] as const;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { decks, summaries } from "collections/server";
|
|
1
|
+
import { decks, quizzes, summaries } from "collections/server";
|
|
2
2
|
|
|
3
3
|
import { ATTACHMENT_SUFFIXES } from "./attachment-rule";
|
|
4
4
|
import { cardHash, type Card, type Deck } from "./deck";
|
|
5
5
|
import { newCard, type CardSchedule } from "./srs";
|
|
6
|
+
import { type Question, type Quiz } from "./quiz";
|
|
7
|
+
import { DEFAULT_QUESTIONS_PER_ROUND } from "./quiz-round";
|
|
8
|
+
import { questionHash } from "./identity";
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
11
|
* Finding a document's study attachments.
|
|
@@ -83,9 +86,44 @@ export function deckFor(documentPath: string): DeckEntry | null {
|
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
/**
|
|
89
|
+
/** One question as the quiz UI consumes it: authored text plus its identity. */
|
|
90
|
+
export interface QuizQuestion extends Question {
|
|
91
|
+
/** Identity: a hash of the question's own text, so an edit resets only this one. */
|
|
92
|
+
readonly hash: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface QuizEntry {
|
|
96
|
+
readonly title: string;
|
|
97
|
+
readonly description?: string;
|
|
98
|
+
readonly questionsPerRound: number;
|
|
99
|
+
readonly questions: readonly QuizQuestion[];
|
|
100
|
+
/** The record-relative path — the quiz's identity, used to key saved answers. */
|
|
101
|
+
readonly path: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The quiz for a document, or null when it has none. */
|
|
105
|
+
export function quizFor(documentPath: string): QuizEntry | null {
|
|
106
|
+
const wanted = attachmentPath(documentPath, ".quiz.yaml");
|
|
107
|
+
const hit = quizzes.find((entry) => entry.info.path === wanted);
|
|
108
|
+
if (hit === undefined) return null;
|
|
109
|
+
|
|
110
|
+
const parsed = hit as unknown as Quiz & { readonly info: { readonly path: string } };
|
|
111
|
+
return {
|
|
112
|
+
title: parsed.quiz.title,
|
|
113
|
+
description: parsed.quiz.description,
|
|
114
|
+
questionsPerRound: parsed.quiz.questionsPerRound ?? DEFAULT_QUESTIONS_PER_ROUND,
|
|
115
|
+
path: parsed.info.path,
|
|
116
|
+
questions: parsed.questions.map((q) => ({ ...q, hash: questionHash(q) })),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when a document has ANY attachment — the presence gate for the UI. */
|
|
87
121
|
export function hasAttachments(documentPath: string): boolean {
|
|
88
|
-
return
|
|
122
|
+
return (
|
|
123
|
+
summaryFor(documentPath) !== null ||
|
|
124
|
+
deckFor(documentPath) !== null ||
|
|
125
|
+
quizFor(documentPath) !== null
|
|
126
|
+
);
|
|
89
127
|
}
|
|
90
128
|
|
|
91
129
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
+
import { textHash } from "./identity";
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* The shape of a `<doc>.flashcards.yaml` deck.
|
|
5
7
|
*
|
|
@@ -44,16 +46,5 @@ export type Card = z.infer<typeof CardSchema>;
|
|
|
44
46
|
* and rewording it should not throw away a learner's history with the card.
|
|
45
47
|
*/
|
|
46
48
|
export function cardHash(card: Card): string {
|
|
47
|
-
|
|
48
|
-
// NUL in the source makes git treat this file as binary. It has to be a
|
|
49
|
-
// separator of some kind — without one, front "ab"/back "c" hashes the same
|
|
50
|
-
// as front "a"/back "bc" — and NUL is the one character authored card text
|
|
51
|
-
// cannot contain.
|
|
52
|
-
const text = `${card.front}\u0000${card.back}`;
|
|
53
|
-
let hash = 0x811c9dc5;
|
|
54
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
55
|
-
hash ^= text.charCodeAt(i);
|
|
56
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
57
|
-
}
|
|
58
|
-
return hash.toString(16).padStart(8, "0");
|
|
49
|
+
return textHash([card.front, card.back]);
|
|
59
50
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity for authored text: the hash, and what a question hashes.
|
|
3
|
+
*
|
|
4
|
+
* FNV-1a, 32-bit, hand-rolled — the site has no crypto import at build time and
|
|
5
|
+
* this has to produce the same value in the browser, where the saved progress
|
|
6
|
+
* it keys actually lives. A collision costs one card's or one question's saved
|
|
7
|
+
* state, never correctness, so 32 bits is the right size of hammer.
|
|
8
|
+
*
|
|
9
|
+
* Extracted so the deck and the quiz share ONE implementation. Two hand-rolled
|
|
10
|
+
* copies of a hash is the kind of duplication that stays identical right up
|
|
11
|
+
* until someone fixes a separator in one of them.
|
|
12
|
+
*
|
|
13
|
+
* A LEAF, and it has to stay one. This repo can only unit-test a scaffold
|
|
14
|
+
* module with no relative imports: `tsc` under node16 resolution demands a
|
|
15
|
+
* `.js` specifier, and Next's bundler in the scaffold cannot resolve that back
|
|
16
|
+
* to a `.ts` file — so a scaffold module that imports a sibling either fails
|
|
17
|
+
* the typecheck or fails the site build. `questionHash` therefore lives here
|
|
18
|
+
* beside the hash it calls rather than in a file of its own.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The separator between parts, written as an escape rather than embedded: a raw
|
|
23
|
+
* NUL in the source makes git treat the file as binary.
|
|
24
|
+
*
|
|
25
|
+
* A separator is load-bearing. Without one, `["ab", "c"]` and `["a", "bc"]`
|
|
26
|
+
* hash identically, and NUL is the one character authored text cannot contain.
|
|
27
|
+
*/
|
|
28
|
+
const SEPARATOR = "\u0000";
|
|
29
|
+
|
|
30
|
+
/** Hash these parts as one identity. */
|
|
31
|
+
export function textHash(parts: readonly string[]): string {
|
|
32
|
+
const text = parts.join(SEPARATOR);
|
|
33
|
+
let hash = 0x811c9dc5;
|
|
34
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
35
|
+
hash ^= text.charCodeAt(i);
|
|
36
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
37
|
+
}
|
|
38
|
+
return hash.toString(16).padStart(8, "0");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A question's identity: a hash of the text the reader actually sees.
|
|
43
|
+
*
|
|
44
|
+
* The stem AND the options, deliberately including their ORDER — reordering
|
|
45
|
+
* changes which index is correct, so a saved answer would otherwise be
|
|
46
|
+
* re-scored against a different question and silently become right or wrong.
|
|
47
|
+
* `explanation` and `source` are excluded: they teach ABOUT the question
|
|
48
|
+
* rather than being it, so improving an explanation costs the reader nothing.
|
|
49
|
+
*/
|
|
50
|
+
export function questionHash(question: {
|
|
51
|
+
readonly question: string;
|
|
52
|
+
readonly options: readonly string[];
|
|
53
|
+
}): string {
|
|
54
|
+
return textHash([question.question, ...question.options]);
|
|
55
|
+
}
|