question-bank-framework 0.6.0 → 0.7.1
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 +46 -5
- package/bin/qb.js +2 -1
- package/bin/qb.ts +2 -1
- package/core/types.ts +27 -7
- package/framework/cloudflare/index.ts +240 -0
- package/framework/components/markdown-content.tsx +60 -23
- package/framework/components/question-bank.tsx +27 -7
- package/framework/components/question-response.tsx +6 -4
- package/framework/components/quiz-list.tsx +4 -4
- package/framework/components/quiz-page.tsx +18 -17
- package/framework/index.ts +11 -2
- package/framework/lib/question-status-storage.ts +53 -79
- package/framework/lib/quiz-storage.ts +112 -102
- package/framework/lib/sync-client.ts +45 -0
- package/framework/styles/theme.css +4 -0
- package/package.json +2 -3
- package/template/bank.config.ts +2 -0
- package/template/src/routes/styles.css +0 -1
- package/template/src/server.ts +17 -0
- package/template/vite.config.ts +10 -0
- package/template/wrangler.jsonc +16 -0
- package/framework/convex/questionStatuses.ts +0 -30
- package/framework/convex/quizzes.ts +0 -62
- package/framework/convex/schema.ts +0 -18
- package/framework/lib/convex-api.ts +0 -43
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Framework-style question bank (generalised from the COMP20008 bank). Downstream
|
|
4
4
|
subjects bring their own `bank.config.ts` + `content/`; the framework ships the
|
|
5
|
-
grid, filter DSL, quizzes, and
|
|
5
|
+
grid, filter DSL, quizzes, and a Cloudflare Durable Object sync store as an npm
|
|
6
|
+
package.
|
|
6
7
|
|
|
7
8
|
Inspired by Vike's eject model: the stable core (`core/`) is never ejected;
|
|
8
9
|
everything under `framework/` can be ejected into your repo and owned.
|
|
@@ -35,10 +36,47 @@ Your app owns:
|
|
|
35
36
|
@source '../node_modules/question-bank-framework'; /* relative to this file */
|
|
36
37
|
```
|
|
37
38
|
|
|
39
|
+
`theme.css` also pulls in the KaTeX stylesheet from the framework's own `katex`,
|
|
40
|
+
matching the version that renders math. Don't import `katex/dist/katex.min.css`
|
|
41
|
+
yourself — a mismatched KaTeX version breaks math layout (0.19 renamed its CSS classes).
|
|
42
|
+
|
|
43
|
+
Text columns sort number-aware ("Q2" before "Q10"). New quizzes keep the
|
|
44
|
+
table's current sort order, so to get source order add a numeric field (e.g.
|
|
45
|
+
`order`) and sort by it, or set it in `defaultSorting`.
|
|
46
|
+
|
|
38
47
|
Pass `hideTagsColumn: true` to `defineBank` when tags merely duplicate
|
|
39
48
|
dedicated fields (every tag equals a topic/type value already in its own
|
|
40
49
|
column). Tags still feed free-text search.
|
|
41
50
|
|
|
51
|
+
## Sync (Cloudflare Durable Objects)
|
|
52
|
+
|
|
53
|
+
Without `sync`, quizzes and question statuses live only in the browser's
|
|
54
|
+
localStorage. With `sync: { url: '/api/qb' }` in `defineBank`, they are also
|
|
55
|
+
stored in a SQLite-backed Durable Object (one per bank namespace), and
|
|
56
|
+
localStorage stays as an offline cache: changes are saved locally first,
|
|
57
|
+
pushed to the server, and reconciled on load (newest `updatedAt` wins; deleted
|
|
58
|
+
quizzes are tombstoned so stale caches can't bring them back). Quizzes that
|
|
59
|
+
existed only in localStorage are uploaded on the first load with sync on.
|
|
60
|
+
|
|
61
|
+
`qb init` scaffolds the three pieces:
|
|
62
|
+
|
|
63
|
+
- `wrangler.jsonc` — Worker config with the `QB_STORE` Durable Object binding
|
|
64
|
+
and its `new_sqlite_classes` migration.
|
|
65
|
+
- `src/server.ts` — Worker entry: `handleQuestionBankRequest(request, env)`
|
|
66
|
+
answers `/api/qb/*` and returns `null` for everything else, which falls
|
|
67
|
+
through to TanStack Start. It also re-exports `QuestionBankStore`.
|
|
68
|
+
- `vite.config.ts` — adds `cloudflare({ viteEnvironment: { name: 'ssr' } })`.
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
pnpm add -D @cloudflare/vite-plugin wrangler
|
|
72
|
+
pnpm dev # workerd via Miniflare; data persists in .wrangler/state
|
|
73
|
+
pnpm build && wrangler deploy # same code on Cloudflare
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Add `.wrangler/` to `.gitignore`. The API has no auth of its own: before a
|
|
77
|
+
public deploy, pass `authorize` to `handleQuestionBankRequest` (e.g. verify a
|
|
78
|
+
Cloudflare Access JWT) or put the Worker behind Cloudflare Access.
|
|
79
|
+
|
|
42
80
|
## Reveal (eject) files
|
|
43
81
|
|
|
44
82
|
```sh
|
|
@@ -70,7 +108,7 @@ fields/columns/searchable flags first; eject when you need custom UI.
|
|
|
70
108
|
package), then customize.
|
|
71
109
|
|
|
72
110
|
We follow Vike: `core/` (types, `defineBank`, `BankProvider`, `useBank`) is the
|
|
73
|
-
stable core; `framework/` (components/lib/
|
|
111
|
+
stable core; `framework/` (components/lib/cloudflare) is the ejectable
|
|
74
112
|
extension layer; `qb reveal` is the copy command.
|
|
75
113
|
|
|
76
114
|
## Layout
|
|
@@ -79,7 +117,8 @@ extension layer; `qb reveal` is the copy command.
|
|
|
79
117
|
`defineBank`), `config.tsx` (`BankProvider`, `useBank`).
|
|
80
118
|
- `framework/` — ejectable: `components/` (grid, quiz, filter builder,
|
|
81
119
|
shadcn `ui/`), `lib/` (generic filter DSL, namespaced quiz/status stores,
|
|
82
|
-
`
|
|
120
|
+
`sync-client.ts`), `cloudflare/` (sync Durable Object + request handler,
|
|
121
|
+
exported as `question-bank-framework/cloudflare`).
|
|
83
122
|
- `template/` — starter: `bank.config.ts` (COMP20008-like schema),
|
|
84
123
|
`content/` example, `src/routes/` thin wrappers.
|
|
85
124
|
- `bin/qb.ts` (source; compiled to `bin/qb.js` with `pnpm build:bin`) — `list` / `reveal` / `init`.
|
|
@@ -89,7 +128,8 @@ extension layer; `qb reveal` is the copy command.
|
|
|
89
128
|
```ts
|
|
90
129
|
defineBank({
|
|
91
130
|
site: { title: '…', brand: 'fieldnotes' },
|
|
92
|
-
namespace: 'comp20008', // localStorage keys +
|
|
131
|
+
namespace: 'comp20008', // localStorage keys + sync store
|
|
132
|
+
sync: { url: '/api/qb' }, // optional: back quizzes/statuses up to the Worker
|
|
93
133
|
fields: [
|
|
94
134
|
{ id: 'year', label: 'Year', type: 'select', getValue: (q) => String(q['year'] ?? '') },
|
|
95
135
|
{ id: 'marks', label: 'Marks', type: 'number', getValue: (q) => q['marks'] ?? null },
|
|
@@ -132,7 +172,8 @@ across reloads but differs between quizzes. The bank grid keeps authored
|
|
|
132
172
|
order, so explanations should name options by content, not by letter. Opt out
|
|
133
173
|
with `shuffle: false` on the question, or on a single dropdown blank, when
|
|
134
174
|
order matters ("All of the above", ordered scales, options that reference
|
|
135
|
-
each other).
|
|
175
|
+
each other). To keep just one option in place ("None of the above" last),
|
|
176
|
+
mark it `pinned: true` instead; the other options still shuffle around it.
|
|
136
177
|
|
|
137
178
|
## Dev
|
|
138
179
|
|
package/bin/qb.js
CHANGED
|
@@ -111,7 +111,8 @@ else if (cmd === 'init') {
|
|
|
111
111
|
cpSync(from, to);
|
|
112
112
|
}
|
|
113
113
|
console.log(`Scaffolded template into ${cwd}`);
|
|
114
|
-
console.log('Next: pnpm install, then point your routes at bank.config.ts (see template/src/routes/).');
|
|
114
|
+
console.log('Next: pnpm add -D @cloudflare/vite-plugin wrangler, pnpm install, then point your routes at bank.config.ts (see template/src/routes/).');
|
|
115
|
+
console.log('wrangler.jsonc + src/server.ts serve the sync store; existing files were left untouched.');
|
|
115
116
|
}
|
|
116
117
|
else {
|
|
117
118
|
console.error(`Unknown command: ${cmd}\nCommands: list, reveal, init`);
|
package/bin/qb.ts
CHANGED
|
@@ -121,7 +121,8 @@ if (cmd === 'list' || !cmd) {
|
|
|
121
121
|
if (!existsSync(to)) cpSync(from, to);
|
|
122
122
|
}
|
|
123
123
|
console.log(`Scaffolded template into ${cwd}`);
|
|
124
|
-
console.log('Next: pnpm install, then point your routes at bank.config.ts (see template/src/routes/).');
|
|
124
|
+
console.log('Next: pnpm add -D @cloudflare/vite-plugin wrangler, pnpm install, then point your routes at bank.config.ts (see template/src/routes/).');
|
|
125
|
+
console.log('wrangler.jsonc + src/server.ts serve the sync store; existing files were left untouched.');
|
|
125
126
|
} else {
|
|
126
127
|
console.error(`Unknown command: ${cmd}\nCommands: list, reveal, init`);
|
|
127
128
|
process.exit(1);
|
package/core/types.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface QuestionOption {
|
|
|
35
35
|
id: string;
|
|
36
36
|
/** Option body. Markdown supported. */
|
|
37
37
|
text: string;
|
|
38
|
+
/**
|
|
39
|
+
* Keep this option at its authored position when options are shuffled,
|
|
40
|
+
* e.g. "None of the above" last. The other options still shuffle around it.
|
|
41
|
+
*/
|
|
42
|
+
pinned?: boolean;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
/** A left-hand row of a matching question, answered from the question's shared `options`. */
|
|
@@ -113,6 +118,8 @@ export interface QuizSession {
|
|
|
113
118
|
currentQuestion: number;
|
|
114
119
|
completed: boolean;
|
|
115
120
|
createdAt: number;
|
|
121
|
+
/** Last local change (ms). Sync keeps the newer copy. Missing on legacy sessions. */
|
|
122
|
+
updatedAt?: number;
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
// ---------------------------------------------------------------------------
|
|
@@ -179,7 +186,7 @@ export interface BankSiteConfig {
|
|
|
179
186
|
|
|
180
187
|
export interface DefineBankInput<Q extends BaseQuestion = BaseQuestion> {
|
|
181
188
|
site: BankSiteConfig;
|
|
182
|
-
/** Namespaces localStorage keys
|
|
189
|
+
/** Namespaces localStorage keys and the sync store. e.g. "comp20008". */
|
|
183
190
|
namespace: string;
|
|
184
191
|
/** Subject-specific schema. Built-ins (id/title/tags/status) are automatic. */
|
|
185
192
|
fields: BankFieldDef<Q>[];
|
|
@@ -197,6 +204,13 @@ export interface DefineBankInput<Q extends BaseQuestion = BaseQuestion> {
|
|
|
197
204
|
hideTagsColumn?: boolean;
|
|
198
205
|
/** Extra per-question figures, resolved by prompt `` etc. */
|
|
199
206
|
publicPath?: string;
|
|
207
|
+
/**
|
|
208
|
+
* Server sync for quizzes and question statuses. Point `url` at the
|
|
209
|
+
* framework's Cloudflare handler (see `question-bank-framework/cloudflare`),
|
|
210
|
+
* e.g. `{ url: '/api/qb' }`. Without it, data lives only in this browser's
|
|
211
|
+
* localStorage. localStorage is always kept as an offline cache.
|
|
212
|
+
*/
|
|
213
|
+
sync?: { url: string };
|
|
200
214
|
}
|
|
201
215
|
|
|
202
216
|
export interface Bank extends DefineBankInput<BaseQuestion> {
|
|
@@ -242,6 +256,9 @@ function validateOptionList(questionId: string, where: string, options: readonly
|
|
|
242
256
|
throw new Error(`Question "${questionId}" ${where} has a duplicate or empty option id "${option.id}".`);
|
|
243
257
|
}
|
|
244
258
|
seen.add(option.id);
|
|
259
|
+
if (option.pinned !== undefined && typeof option.pinned !== 'boolean') {
|
|
260
|
+
throw new Error(`Question "${questionId}" ${where} option "${option.id}" has a non-boolean pinned.`);
|
|
261
|
+
}
|
|
245
262
|
}
|
|
246
263
|
return seen;
|
|
247
264
|
}
|
|
@@ -412,14 +429,16 @@ function seededRandom(seed: number): () => number {
|
|
|
412
429
|
};
|
|
413
430
|
}
|
|
414
431
|
|
|
415
|
-
|
|
416
|
-
|
|
432
|
+
/** Shuffle the unpinned options; pinned options keep their authored index. */
|
|
433
|
+
function seededShuffle(list: readonly QuestionOption[], seed: string): QuestionOption[] {
|
|
434
|
+
const free = list.filter((option) => option.pinned !== true);
|
|
417
435
|
const random = seededRandom(hashSeed(seed));
|
|
418
|
-
for (let i =
|
|
436
|
+
for (let i = free.length - 1; i > 0; i -= 1) {
|
|
419
437
|
const j = Math.floor(random() * (i + 1));
|
|
420
|
-
[
|
|
438
|
+
[free[i], free[j]] = [free[j]!, free[i]!];
|
|
421
439
|
}
|
|
422
|
-
|
|
440
|
+
let next = 0;
|
|
441
|
+
return list.map((option) => (option.pinned === true ? option : free[next++]!));
|
|
423
442
|
}
|
|
424
443
|
|
|
425
444
|
/**
|
|
@@ -427,7 +446,8 @@ function seededShuffle<T>(list: readonly T[], seed: string): T[] {
|
|
|
427
446
|
* for `seed` (e.g. a quiz session id) so the order is stable across reloads
|
|
428
447
|
* but differs between quizzes. Answers are stored as option ids, so grading is
|
|
429
448
|
* unaffected. Questions with `shuffle: false` (and blanks with
|
|
430
|
-
* `shuffle: false`) keep their authored order
|
|
449
|
+
* `shuffle: false`) keep their authored order; `pinned` options keep their
|
|
450
|
+
* authored position while the rest shuffle.
|
|
431
451
|
*/
|
|
432
452
|
export function shuffleQuestionOptions<Q extends BaseQuestion>(question: Q, seed: string): Q {
|
|
433
453
|
const enabled = question.shuffle !== false;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Cloudflare sync backend: one SQLite-backed Durable Object per bank
|
|
2
|
+
// namespace stores quizzes and question statuses. Import from
|
|
3
|
+
// "question-bank-framework/cloudflare" in your Worker entry (src/server.ts):
|
|
4
|
+
//
|
|
5
|
+
// import handler from '@tanstack/react-start/server-entry';
|
|
6
|
+
// import { handleQuestionBankRequest } from 'question-bank-framework/cloudflare';
|
|
7
|
+
// export { QuestionBankStore } from 'question-bank-framework/cloudflare';
|
|
8
|
+
// export default {
|
|
9
|
+
// async fetch(request, env) {
|
|
10
|
+
// return (await handleQuestionBankRequest(request, env)) ?? handler.fetch(request);
|
|
11
|
+
// },
|
|
12
|
+
// };
|
|
13
|
+
//
|
|
14
|
+
// and bind it in wrangler.jsonc (see template/wrangler.jsonc). Local dev with
|
|
15
|
+
// @cloudflare/vite-plugin runs the same code in workerd and persists to
|
|
16
|
+
// .wrangler/state. Framework-owned: eject with `qb reveal framework/cloudflare/index.ts`.
|
|
17
|
+
//
|
|
18
|
+
// No Workers type packages needed: the few runtime types used are declared
|
|
19
|
+
// structurally below.
|
|
20
|
+
|
|
21
|
+
interface SqlStorage {
|
|
22
|
+
exec<T = Record<string, string | number | null>>(query: string, ...bindings: unknown[]): { toArray(): T[] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface DurableObjectState {
|
|
26
|
+
storage: { sql: SqlStorage };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface DurableObjectStub {
|
|
30
|
+
fetch(request: Request): Promise<Response>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DurableObjectNamespace {
|
|
34
|
+
idFromName(name: string): unknown;
|
|
35
|
+
get(id: unknown): DurableObjectStub;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
39
|
+
const NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
40
|
+
const statuses = new Set(['unanswered', 'revisit', 'complete']);
|
|
41
|
+
|
|
42
|
+
const json = (data: unknown, status = 200) =>
|
|
43
|
+
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
|
|
44
|
+
|
|
45
|
+
const error = (status: number, message: string) => json({ error: message }, status);
|
|
46
|
+
|
|
47
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
48
|
+
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
49
|
+
|
|
50
|
+
const isQuizAnswer = (value: unknown) =>
|
|
51
|
+
typeof value === 'string' ||
|
|
52
|
+
(Array.isArray(value) && value.every((item) => typeof item === 'string')) ||
|
|
53
|
+
(isObject(value) && Object.values(value).every((item) => typeof item === 'string'));
|
|
54
|
+
|
|
55
|
+
interface StoredQuiz {
|
|
56
|
+
id: string;
|
|
57
|
+
title: string;
|
|
58
|
+
questionIds: string[];
|
|
59
|
+
answers: Record<string, unknown>;
|
|
60
|
+
currentQuestion: number;
|
|
61
|
+
completed: boolean;
|
|
62
|
+
createdAt: number;
|
|
63
|
+
updatedAt: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseQuiz(id: string, body: unknown): StoredQuiz | null {
|
|
67
|
+
if (!isObject(body)) return null;
|
|
68
|
+
const { title, questionIds, answers, currentQuestion, completed, createdAt, updatedAt } = body;
|
|
69
|
+
if (body.id !== id || typeof title !== 'string' || title.length > 500) return null;
|
|
70
|
+
if (!Array.isArray(questionIds) || !questionIds.every((q) => typeof q === 'string')) return null;
|
|
71
|
+
if (!isObject(answers) || !Object.values(answers).every(isQuizAnswer)) return null;
|
|
72
|
+
if (!Number.isInteger(currentQuestion) || typeof completed !== 'boolean') return null;
|
|
73
|
+
if (!Number.isFinite(createdAt) || !Number.isFinite(updatedAt)) return null;
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
title,
|
|
77
|
+
questionIds: questionIds as string[],
|
|
78
|
+
answers,
|
|
79
|
+
currentQuestion: currentQuestion as number,
|
|
80
|
+
completed,
|
|
81
|
+
createdAt: createdAt as number,
|
|
82
|
+
updatedAt: updatedAt as number,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readBody(request: Request): Promise<unknown> {
|
|
87
|
+
const text = await request.text();
|
|
88
|
+
if (text.length > MAX_BODY_BYTES) throw new Error('too large');
|
|
89
|
+
return JSON.parse(text);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Durable Object holding one bank namespace. Export it from your Worker entry. */
|
|
93
|
+
export class QuestionBankStore {
|
|
94
|
+
private sql: SqlStorage;
|
|
95
|
+
|
|
96
|
+
constructor(state: DurableObjectState, _env: unknown) {
|
|
97
|
+
this.sql = state.storage.sql;
|
|
98
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS quizzes (
|
|
99
|
+
id TEXT PRIMARY KEY,
|
|
100
|
+
data TEXT NOT NULL,
|
|
101
|
+
created_at INTEGER NOT NULL,
|
|
102
|
+
updated_at INTEGER NOT NULL,
|
|
103
|
+
deleted INTEGER NOT NULL DEFAULT 0
|
|
104
|
+
)`);
|
|
105
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS statuses (
|
|
106
|
+
question_id TEXT PRIMARY KEY,
|
|
107
|
+
status TEXT NOT NULL,
|
|
108
|
+
updated_at INTEGER NOT NULL
|
|
109
|
+
)`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async fetch(request: Request): Promise<Response> {
|
|
113
|
+
const segments = new URL(request.url).pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
|
114
|
+
const [collection, key] = segments;
|
|
115
|
+
try {
|
|
116
|
+
if (collection === 'quizzes' && segments.length === 1 && request.method === 'GET') return this.listQuizzes();
|
|
117
|
+
if (collection === 'quizzes' && key && segments.length === 2) {
|
|
118
|
+
if (request.method === 'PUT') return this.putQuiz(key, await readBody(request));
|
|
119
|
+
if (request.method === 'DELETE') return this.deleteQuiz(key);
|
|
120
|
+
}
|
|
121
|
+
if (collection === 'statuses' && segments.length === 1 && request.method === 'GET') return this.listStatuses();
|
|
122
|
+
if (collection === 'statuses' && key && segments.length === 2 && request.method === 'PUT') {
|
|
123
|
+
return this.putStatus(key, await readBody(request));
|
|
124
|
+
}
|
|
125
|
+
return error(404, 'Not found');
|
|
126
|
+
} catch {
|
|
127
|
+
return error(400, 'Bad request');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private listQuizzes(): Response {
|
|
132
|
+
const rows = this.sql.exec<{ id: string; data: string; deleted: number }>(
|
|
133
|
+
'SELECT id, data, deleted FROM quizzes ORDER BY created_at DESC',
|
|
134
|
+
).toArray();
|
|
135
|
+
return json({
|
|
136
|
+
quizzes: rows.filter((row) => !row.deleted).map((row) => JSON.parse(row.data) as StoredQuiz),
|
|
137
|
+
deleted: rows.filter((row) => row.deleted).map((row) => row.id),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Upsert, keeping whichever copy has the newer updatedAt. Returns the stored copy; 410 if deleted. */
|
|
142
|
+
private putQuiz(id: string, body: unknown): Response {
|
|
143
|
+
const quiz = parseQuiz(id, body);
|
|
144
|
+
if (!quiz) return error(400, 'Invalid quiz');
|
|
145
|
+
const [existing] = this.sql.exec<{ data: string; updated_at: number; deleted: number }>(
|
|
146
|
+
'SELECT data, updated_at, deleted FROM quizzes WHERE id = ?',
|
|
147
|
+
id,
|
|
148
|
+
).toArray();
|
|
149
|
+
if (existing?.deleted) return json({ deleted: true }, 410);
|
|
150
|
+
if (existing && existing.updated_at >= quiz.updatedAt) return json(JSON.parse(existing.data));
|
|
151
|
+
this.sql.exec(
|
|
152
|
+
`INSERT INTO quizzes (id, data, created_at, updated_at, deleted) VALUES (?, ?, ?, ?, 0)
|
|
153
|
+
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
|
|
154
|
+
id,
|
|
155
|
+
JSON.stringify(quiz),
|
|
156
|
+
quiz.createdAt,
|
|
157
|
+
quiz.updatedAt,
|
|
158
|
+
);
|
|
159
|
+
return json(quiz);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Tombstone, so stale browser caches can't resurrect the quiz. */
|
|
163
|
+
private deleteQuiz(id: string): Response {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
this.sql.exec(
|
|
166
|
+
`INSERT INTO quizzes (id, data, created_at, updated_at, deleted) VALUES (?, '{}', ?, ?, 1)
|
|
167
|
+
ON CONFLICT(id) DO UPDATE SET data = '{}', updated_at = excluded.updated_at, deleted = 1`,
|
|
168
|
+
id,
|
|
169
|
+
now,
|
|
170
|
+
now,
|
|
171
|
+
);
|
|
172
|
+
return json({ deleted: true });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private listStatuses(): Response {
|
|
176
|
+
const rows = this.sql.exec<{ question_id: string; status: string; updated_at: number }>(
|
|
177
|
+
'SELECT question_id, status, updated_at FROM statuses',
|
|
178
|
+
).toArray();
|
|
179
|
+
return json(Object.fromEntries(rows.map((row) => [row.question_id, { status: row.status, updatedAt: row.updated_at }])));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private putStatus(questionId: string, body: unknown): Response {
|
|
183
|
+
if (!isObject(body) || typeof body.status !== 'string' || !statuses.has(body.status) || !Number.isFinite(body.updatedAt)) {
|
|
184
|
+
return error(400, 'Invalid status');
|
|
185
|
+
}
|
|
186
|
+
const updatedAt = body.updatedAt as number;
|
|
187
|
+
this.sql.exec(
|
|
188
|
+
`INSERT INTO statuses (question_id, status, updated_at) VALUES (?, ?, ?)
|
|
189
|
+
ON CONFLICT(question_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at
|
|
190
|
+
WHERE excluded.updated_at > statuses.updated_at`,
|
|
191
|
+
questionId,
|
|
192
|
+
body.status,
|
|
193
|
+
updatedAt,
|
|
194
|
+
);
|
|
195
|
+
const [row] = this.sql.exec<{ status: string; updated_at: number }>(
|
|
196
|
+
'SELECT status, updated_at FROM statuses WHERE question_id = ?',
|
|
197
|
+
questionId,
|
|
198
|
+
).toArray();
|
|
199
|
+
return json({ status: row!.status, updatedAt: row!.updated_at });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface QuestionBankHandlerOptions {
|
|
204
|
+
/** URL prefix the API is served under; must match the bank's `sync.url`. Default "/api/qb". */
|
|
205
|
+
prefix?: string;
|
|
206
|
+
/** Name of the Durable Object binding in wrangler.jsonc. Default "QB_STORE". */
|
|
207
|
+
binding?: string;
|
|
208
|
+
/**
|
|
209
|
+
* Gate every request, e.g. verify a Cloudflare Access JWT. Return false to
|
|
210
|
+
* answer 401. Without it the API is open to anyone who can reach the Worker.
|
|
211
|
+
*/
|
|
212
|
+
authorize?: (request: Request) => boolean | Promise<boolean>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Serve the sync API. Returns null for requests outside `prefix`, so the
|
|
217
|
+
* caller can fall through to the app handler.
|
|
218
|
+
*/
|
|
219
|
+
export async function handleQuestionBankRequest(
|
|
220
|
+
request: Request,
|
|
221
|
+
env: object,
|
|
222
|
+
options: QuestionBankHandlerOptions = {},
|
|
223
|
+
): Promise<Response | null> {
|
|
224
|
+
const prefix = (options.prefix ?? '/api/qb').replace(/\/+$/, '');
|
|
225
|
+
const url = new URL(request.url);
|
|
226
|
+
if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return null;
|
|
227
|
+
if (options.authorize && !(await options.authorize(request))) return error(401, 'Unauthorized');
|
|
228
|
+
|
|
229
|
+
const [namespace, ...rest] = url.pathname.slice(prefix.length).split('/').filter(Boolean);
|
|
230
|
+
const name = namespace ? decodeURIComponent(namespace) : '';
|
|
231
|
+
if (!NAMESPACE_PATTERN.test(name)) return error(404, 'Not found');
|
|
232
|
+
|
|
233
|
+
const binding = (env as Record<string, unknown>)[options.binding ?? 'QB_STORE'] as DurableObjectNamespace | undefined;
|
|
234
|
+
if (!binding || typeof binding.idFromName !== 'function') {
|
|
235
|
+
return error(500, `Durable Object binding "${options.binding ?? 'QB_STORE'}" is not configured.`);
|
|
236
|
+
}
|
|
237
|
+
const stub = binding.get(binding.idFromName(name));
|
|
238
|
+
const inner = new URL(`https://qb.internal/${rest.join('/')}`);
|
|
239
|
+
return stub.fetch(new Request(inner, request));
|
|
240
|
+
}
|
|
@@ -1,30 +1,67 @@
|
|
|
1
|
+
import katex from 'katex'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
1
3
|
import ReactMarkdown, { type Components } from 'react-markdown'
|
|
2
|
-
import rehypeKatex from 'rehype-katex'
|
|
3
4
|
import remarkGfm from 'remark-gfm'
|
|
4
5
|
import remarkMath from 'remark-math'
|
|
5
6
|
import { cn } from '../lib/utils.ts'
|
|
6
7
|
|
|
8
|
+
// Math is rendered with the framework's own `katex` dependency rather than
|
|
9
|
+
// rehype-katex, which pins katex@^0.16. KaTeX 0.19 renamed its CSS classes
|
|
10
|
+
// (`.base` → `.katex-base`, …), so 0.16 markup styled by 0.19 CSS loses its
|
|
11
|
+
// layout (subscripts wrap and overlap). theme.css imports the CSS from the
|
|
12
|
+
// same katex package, so markup and stylesheet always agree.
|
|
13
|
+
const textOf = (children: ReactNode): string =>
|
|
14
|
+
Array.isArray(children) ? children.map(textOf).join('') : typeof children === 'string' || typeof children === 'number' ? String(children) : ''
|
|
15
|
+
|
|
16
|
+
const hasClass = (className: unknown, name: string) =>
|
|
17
|
+
(Array.isArray(className) ? className.map(String) : String(className ?? '').split(/\s+/)).includes(name)
|
|
18
|
+
|
|
19
|
+
function MathFormula({ tex, display }: { tex: string; display: boolean }) {
|
|
20
|
+
const html = katex.renderToString(tex, { displayMode: display, throwOnError: false, strict: 'ignore' })
|
|
21
|
+
return display
|
|
22
|
+
? <div dangerouslySetInnerHTML={{ __html: html }} />
|
|
23
|
+
: <span dangerouslySetInnerHTML={{ __html: html }} />
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Every renderer drops react-markdown's `node` prop so it isn't forwarded to the DOM.
|
|
7
27
|
const components: Components = {
|
|
8
|
-
a: ({ ...props }) => <a className="font-medium text-green-800 underline decoration-green-300 underline-offset-2 hover:text-green-950" target="_blank" rel="noreferrer" {...props} />,
|
|
9
|
-
blockquote: ({ ...props }) => <blockquote className="my-4 border-l-2 border-gray-300 pl-4 text-gray-700" {...props} />,
|
|
10
|
-
code: ({ className, children, ...props }) => {
|
|
11
|
-
|
|
12
|
-
|
|
28
|
+
a: ({ node: _node, ...props }) => <a className="font-medium text-green-800 underline decoration-green-300 underline-offset-2 hover:text-green-950" target="_blank" rel="noreferrer" {...props} />,
|
|
29
|
+
blockquote: ({ node: _node, ...props }) => <blockquote className="my-4 border-l-2 border-gray-300 pl-4 text-gray-700" {...props} />,
|
|
30
|
+
code: ({ node: _node, className, children, ...props }) => {
|
|
31
|
+
if (hasClass(className, 'language-math')) {
|
|
32
|
+
return <MathFormula tex={textOf(children)} display={hasClass(className, 'math-display')} />
|
|
33
|
+
}
|
|
34
|
+
// Inline-code styling; fenced blocks reset it via the `pre` renderer below.
|
|
35
|
+
return <code className={cn('rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] text-gray-900 break-words', className)} {...props}>{children}</code>
|
|
13
36
|
},
|
|
14
|
-
h1: ({ ...props }) => <h1 className="mb-3 mt-6 text-2xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
|
|
15
|
-
h2: ({ ...props }) => <h2 className="mb-3 mt-6 text-xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
|
|
16
|
-
h3: ({ ...props }) => <h3 className="mb-2 mt-5 text-lg font-semibold text-gray-950 first:mt-0" {...props} />,
|
|
37
|
+
h1: ({ node: _node, ...props }) => <h1 className="mb-3 mt-6 text-2xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
|
|
38
|
+
h2: ({ node: _node, ...props }) => <h2 className="mb-3 mt-6 text-xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
|
|
39
|
+
h3: ({ node: _node, ...props }) => <h3 className="mb-2 mt-5 text-lg font-semibold text-gray-950 first:mt-0" {...props} />,
|
|
17
40
|
hr: () => <hr className="my-5 border-gray-200" />,
|
|
18
|
-
img: ({ ...props }) => <img className="my-4 h-auto max-w-full rounded-md border border-gray-200" loading="lazy" {...props} />,
|
|
19
|
-
li: ({ ...props }) => <li className="my-1 pl-1" {...props} />,
|
|
20
|
-
ol: ({ ...props }) => <ol className="my-3 list-decimal space-y-1 pl-6" {...props} />,
|
|
21
|
-
p: ({ ...props }) => <p className="my-3 whitespace-pre-wrap leading-7 first:mt-0 last:mb-0" {...props} />,
|
|
22
|
-
pre: ({ children, ...props }) =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
41
|
+
img: ({ node: _node, ...props }) => <img className="my-4 h-auto max-w-full rounded-md border border-gray-200" loading="lazy" {...props} />,
|
|
42
|
+
li: ({ node: _node, ...props }) => <li className="my-1 pl-1" {...props} />,
|
|
43
|
+
ol: ({ node: _node, ...props }) => <ol className="my-3 list-decimal space-y-1 pl-6" {...props} />,
|
|
44
|
+
p: ({ node: _node, ...props }) => <p className="my-3 whitespace-pre-wrap leading-7 first:mt-0 last:mb-0" {...props} />,
|
|
45
|
+
pre: ({ node, children, ...props }) => {
|
|
46
|
+
// remark-math emits display math as <pre><code class="language-math math-display">.
|
|
47
|
+
const first = node?.children.find((child) => child.type === 'element')
|
|
48
|
+
if (first?.type === 'element' && hasClass(first.properties.className, 'math-display')) {
|
|
49
|
+
return <div className="my-4 overflow-x-auto overflow-y-hidden py-1">{children}</div>
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
<pre
|
|
53
|
+
className="my-4 overflow-x-auto rounded-lg bg-gray-950 p-4 text-gray-50 [&_code]:rounded-none [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-sm [&_code]:text-inherit [&_code]:break-normal"
|
|
54
|
+
{...props}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
</pre>
|
|
58
|
+
)
|
|
59
|
+
},
|
|
60
|
+
table: ({ node: _node, children, ...props }) => <div className="my-4 overflow-x-auto"><table className="w-full border-collapse text-left text-sm" {...props}>{children}</table></div>,
|
|
61
|
+
tbody: ({ node: _node, ...props }) => <tbody className="divide-y divide-gray-200" {...props} />,
|
|
62
|
+
td: ({ node: _node, ...props }) => <td className="border border-gray-200 px-3 py-2 align-top" {...props} />,
|
|
63
|
+
th: ({ node: _node, ...props }) => <th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-semibold" {...props} />,
|
|
64
|
+
ul: ({ node: _node, ...props }) => <ul className="my-3 list-disc space-y-1 pl-6" {...props} />,
|
|
28
65
|
}
|
|
29
66
|
|
|
30
67
|
const markdownCode = /```[\s\S]*?```|~~~[\s\S]*?~~~|(`+)(?!`)[\s\S]*?\1(?!`)/g
|
|
@@ -102,16 +139,16 @@ const normalizeGfmTables = (content: string) => {
|
|
|
102
139
|
// blank labels) can sit inside buttons, select triggers and flex rows.
|
|
103
140
|
const inlineComponents: Components = {
|
|
104
141
|
...components,
|
|
105
|
-
p: ({ ...props }) => <span className="whitespace-pre-wrap" {...props} />,
|
|
142
|
+
p: ({ node: _node, ...props }) => <span className="whitespace-pre-wrap" {...props} />,
|
|
106
143
|
}
|
|
107
144
|
|
|
108
145
|
// min-w-0 + break-words keep long tokens inside the column; display math
|
|
109
|
-
// and wide tables/code scroll horizontally
|
|
110
|
-
const blockClassName = 'min-w-0 max-w-full break-words
|
|
146
|
+
// and wide tables/code scroll horizontally (see the `pre`/`table` renderers).
|
|
147
|
+
const blockClassName = 'min-w-0 max-w-full break-words'
|
|
111
148
|
|
|
112
149
|
export function MarkdownContent({ content, className, inline = false }: { content: string; className?: string; inline?: boolean }) {
|
|
113
150
|
const markdown = (
|
|
114
|
-
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]}
|
|
151
|
+
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} components={inline ? inlineComponents : components}>
|
|
115
152
|
{normalizeGfmTables(normalizeMathDelimiters(content))}
|
|
116
153
|
</ReactMarkdown>
|
|
117
154
|
)
|