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 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 Convex bindings as an npm package.
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/routes/convex) is the ejectable
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
- `convex-api.ts`), `convex/` (quizzes + questionStatuses tables).
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 + Convex lookups
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 + Convex lookups. e.g. "comp20008". */
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 `![](/figures/…)` 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
- function seededShuffle<T>(list: readonly T[], seed: string): T[] {
416
- const out = [...list];
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 = out.length - 1; i > 0; i -= 1) {
436
+ for (let i = free.length - 1; i > 0; i -= 1) {
419
437
  const j = Math.floor(random() * (i + 1));
420
- [out[i], out[j]] = [out[j]!, out[i]!];
438
+ [free[i], free[j]] = [free[j]!, free[i]!];
421
439
  }
422
- return out;
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
- const isBlock = className?.startsWith('language-') ?? false
12
- return <code className={cn(isBlock ? 'font-mono text-sm' : 'rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] text-gray-900 break-words', className)} {...props}>{children}</code>
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 }) => <pre className="my-4 overflow-x-auto rounded-lg bg-gray-950 p-4 text-gray-50" {...props}>{children}</pre>,
23
- table: ({ children, ...props }) => <div className="my-4 overflow-x-auto"><table className="w-full border-collapse text-left text-sm" {...props}>{children}</table></div>,
24
- tbody: ({ ...props }) => <tbody className="divide-y divide-gray-200" {...props} />,
25
- td: ({ ...props }) => <td className="border border-gray-200 px-3 py-2 align-top" {...props} />,
26
- th: ({ ...props }) => <th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-semibold" {...props} />,
27
- ul: ({ ...props }) => <ul className="my-3 list-disc space-y-1 pl-6" {...props} />,
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 instead of widening the page.
110
- const blockClassName = 'min-w-0 max-w-full break-words [&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden [&_.katex-display]:py-1'
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]} rehypePlugins={[rehypeKatex]} components={inline ? inlineComponents : components}>
151
+ <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} components={inline ? inlineComponents : components}>
115
152
  {normalizeGfmTables(normalizeMathDelimiters(content))}
116
153
  </ReactMarkdown>
117
154
  )