question-bank-framework 0.0.0-stage → 0.1.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.
Files changed (39) hide show
  1. package/README.md +105 -2
  2. package/bin/qb.js +119 -0
  3. package/bin/qb.ts +128 -0
  4. package/core/config.tsx +38 -0
  5. package/core/index.ts +17 -0
  6. package/core/types.ts +111 -0
  7. package/framework/components/app-shell.tsx +54 -0
  8. package/framework/components/filter-builder.tsx +471 -0
  9. package/framework/components/markdown-content.tsx +109 -0
  10. package/framework/components/question-bank.tsx +654 -0
  11. package/framework/components/quiz-list.tsx +110 -0
  12. package/framework/components/quiz-page.tsx +240 -0
  13. package/framework/components/ui/badge.tsx +47 -0
  14. package/framework/components/ui/button.tsx +63 -0
  15. package/framework/components/ui/checkbox.tsx +29 -0
  16. package/framework/components/ui/dropdown-menu.tsx +256 -0
  17. package/framework/components/ui/input.tsx +20 -0
  18. package/framework/components/ui/popover.tsx +88 -0
  19. package/framework/components/ui/select.tsx +187 -0
  20. package/framework/components/ui/sheet.tsx +140 -0
  21. package/framework/components/ui/table.tsx +121 -0
  22. package/framework/convex/questionStatuses.ts +30 -0
  23. package/framework/convex/quizzes.ts +50 -0
  24. package/framework/convex/schema.ts +18 -0
  25. package/framework/index.ts +26 -0
  26. package/framework/lib/convex-api.ts +36 -0
  27. package/framework/lib/question-filter-store.ts +116 -0
  28. package/framework/lib/question-filter.ts +362 -0
  29. package/framework/lib/question-status-storage.ts +117 -0
  30. package/framework/lib/quiz-storage.ts +118 -0
  31. package/framework/lib/utils.ts +6 -0
  32. package/package.json +65 -3
  33. package/template/bank.config.ts +72 -0
  34. package/template/content/answers.ts +6 -0
  35. package/template/content/questions.json +35 -0
  36. package/template/src/routes/__root.tsx +44 -0
  37. package/template/src/routes/index.tsx +4 -0
  38. package/template/src/routes/quiz.$quizId.tsx +4 -0
  39. package/template/src/routes/quizzes.tsx +4 -0
@@ -0,0 +1,471 @@
1
+ import { useState } from 'react'
2
+ import { ChevronDown, Circle, Filter, Hash, Plus, Tags, Type, X } from 'lucide-react'
3
+ import { Badge } from './ui/badge'
4
+ import { Button } from './ui/button'
5
+ import { Checkbox } from './ui/checkbox'
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuRadioGroup,
10
+ DropdownMenuRadioItem,
11
+ DropdownMenuTrigger,
12
+ } from './ui/dropdown-menu'
13
+ import { Input } from './ui/input'
14
+ import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from './ui/select'
22
+ import {
23
+ defaultOperatorByType,
24
+ fieldTypeLabels,
25
+ operatorLabels,
26
+ type FilterField,
27
+ type FilterFieldType,
28
+ type FilterMatch,
29
+ type FilterOperatorId,
30
+ type FilterRule,
31
+ } from '../lib/question-filter.ts'
32
+ import { cn } from '../lib/utils.ts'
33
+
34
+ const fieldIcons: Record<FilterFieldType, typeof Filter> = {
35
+ text: Type,
36
+ number: Hash,
37
+ select: Circle,
38
+ multiselect: Tags,
39
+ }
40
+
41
+ type FilterBuilderProps = {
42
+ fields: readonly FilterField[]
43
+ filters: readonly FilterRule[]
44
+ match: FilterMatch
45
+ onAdd: (field: string) => void
46
+ onChange: (id: string, patch: Partial<Omit<FilterRule, 'id'>>) => void
47
+ onRemove: (id: string) => void
48
+ onClear: () => void
49
+ onMatchChange: (match: FilterMatch) => void
50
+ }
51
+
52
+ export function FilterBuilder({
53
+ fields,
54
+ filters,
55
+ match,
56
+ onAdd,
57
+ onChange,
58
+ onRemove,
59
+ onClear,
60
+ onMatchChange,
61
+ }: FilterBuilderProps) {
62
+ const [open, setOpen] = useState(false)
63
+ const [adding, setAdding] = useState(false)
64
+
65
+ return (
66
+ <Popover
67
+ open={open}
68
+ onOpenChange={(nextOpen) => {
69
+ setOpen(nextOpen)
70
+ if (!nextOpen) setAdding(false)
71
+ }}
72
+ >
73
+ <PopoverTrigger asChild>
74
+ <Button variant="outline" className="h-11 gap-2 text-base">
75
+ <Filter className="size-4" />
76
+ Filter
77
+ {filters.length > 0 && (
78
+ <Badge variant="secondary" className="h-5 min-w-5 rounded-full px-1.5 text-xs tabular-nums">
79
+ {filters.length}
80
+ </Badge>
81
+ )}
82
+ </Button>
83
+ </PopoverTrigger>
84
+ <PopoverContent align="start" className="w-[min(780px,calc(100vw-2rem))] p-0">
85
+ <div className="flex items-center justify-between gap-2 border-b px-3 py-2">
86
+ <p className="text-sm font-semibold">Filters</p>
87
+ {filters.length > 0 && (
88
+ <Button variant="ghost" size="sm" onClick={onClear}>Clear all</Button>
89
+ )}
90
+ </div>
91
+
92
+ <div className="space-y-1 px-3 py-3">
93
+ {filters.length === 0 ? (
94
+ <PropertyPicker fields={fields} onPick={onAdd} />
95
+ ) : (
96
+ filters.map((rule, index) => (
97
+ <div key={rule.id}>
98
+ {index > 0 && <MatchToggle match={match} onChange={onMatchChange} />}
99
+ <FilterRuleRow
100
+ rule={rule}
101
+ fields={fields}
102
+ onChange={onChange}
103
+ onRemove={onRemove}
104
+ />
105
+ </div>
106
+ ))
107
+ )}
108
+ </div>
109
+
110
+ {filters.length > 0 && (
111
+ <div className="border-t p-2">
112
+ {adding ? (
113
+ <div className="space-y-1">
114
+ <PropertyPicker
115
+ fields={fields}
116
+ onPick={(field) => {
117
+ onAdd(field)
118
+ setAdding(false)
119
+ }}
120
+ />
121
+ <Button
122
+ variant="ghost"
123
+ size="sm"
124
+ className="w-full justify-start"
125
+ onClick={() => setAdding(false)}
126
+ >
127
+ Cancel
128
+ </Button>
129
+ </div>
130
+ ) : (
131
+ <Button
132
+ variant="ghost"
133
+ size="sm"
134
+ className="w-full justify-start gap-2"
135
+ onClick={() => setAdding(true)}
136
+ >
137
+ <Plus className="size-4" />
138
+ Add filter
139
+ </Button>
140
+ )}
141
+ </div>
142
+ )}
143
+ </PopoverContent>
144
+ </Popover>
145
+ )
146
+ }
147
+
148
+ function PropertyPicker({
149
+ fields,
150
+ onPick,
151
+ }: {
152
+ fields: readonly FilterField[]
153
+ onPick: (field: string) => void
154
+ }) {
155
+ return (
156
+ <div>
157
+ <p className="px-2 pb-1 pt-1 text-xs font-medium text-muted-foreground">Filter by</p>
158
+ <div className="grid gap-0.5">
159
+ {fields.map((field) => {
160
+ const Icon = fieldIcons[field.type]
161
+ return (
162
+ <button
163
+ key={field.id}
164
+ type="button"
165
+ onClick={() => onPick(field.id)}
166
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent"
167
+ >
168
+ <Icon className="size-4 shrink-0 text-muted-foreground" />
169
+ <span className="flex-1 truncate">{field.label}</span>
170
+ <span className="text-xs text-muted-foreground">{fieldTypeLabels[field.type]}</span>
171
+ </button>
172
+ )
173
+ })}
174
+ </div>
175
+ </div>
176
+ )
177
+ }
178
+
179
+ function MatchToggle({
180
+ match,
181
+ onChange,
182
+ }: {
183
+ match: FilterMatch
184
+ onChange: (match: FilterMatch) => void
185
+ }) {
186
+ return (
187
+ <DropdownMenu>
188
+ <DropdownMenuTrigger asChild>
189
+ <button
190
+ type="button"
191
+ aria-label={`Match ${match === 'and' ? 'all' : 'any'} filters`}
192
+ className="my-1 ml-2 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
193
+ >
194
+ {match === 'and' ? 'And' : 'Or'}
195
+ <ChevronDown className="size-3" />
196
+ </button>
197
+ </DropdownMenuTrigger>
198
+ <DropdownMenuContent align="start" className="w-72">
199
+ <DropdownMenuRadioGroup
200
+ value={match}
201
+ onValueChange={(value) => onChange(value as FilterMatch)}
202
+ >
203
+ <DropdownMenuRadioItem value="and">
204
+ And
205
+ <span className="text-muted-foreground">— all filters must match</span>
206
+ </DropdownMenuRadioItem>
207
+ <DropdownMenuRadioItem value="or">
208
+ Or
209
+ <span className="text-muted-foreground">— at least one must match</span>
210
+ </DropdownMenuRadioItem>
211
+ </DropdownMenuRadioGroup>
212
+ </DropdownMenuContent>
213
+ </DropdownMenu>
214
+ )
215
+ }
216
+
217
+ function FilterRuleRow({
218
+ rule,
219
+ fields,
220
+ onChange,
221
+ onRemove,
222
+ }: {
223
+ rule: FilterRule
224
+ fields: readonly FilterField[]
225
+ onChange: (id: string, patch: Partial<Omit<FilterRule, 'id'>>) => void
226
+ onRemove: (id: string) => void
227
+ }) {
228
+ const field = fields.find((candidate) => candidate.id === rule.field) ?? fields[0]
229
+ if (!field) return null
230
+
231
+ const needsValue = rule.operator !== 'isEmpty' && rule.operator !== 'isNotEmpty'
232
+
233
+ return (
234
+ <div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-2">
235
+ <Select
236
+ value={rule.field}
237
+ onValueChange={(value) => {
238
+ const nextField = fields.find((candidate) => candidate.id === value)
239
+ if (!nextField) return
240
+ onChange(rule.id, {
241
+ field: nextField.id,
242
+ operator: defaultOperatorByType[nextField.type],
243
+ value: [],
244
+ })
245
+ }}
246
+ >
247
+ <SelectTrigger size="sm" aria-label="Filter field" className="w-[132px]">
248
+ <SelectValue />
249
+ </SelectTrigger>
250
+ <SelectContent>
251
+ {fields.map((candidate) => (
252
+ <SelectItem key={candidate.id} value={candidate.id}>{candidate.label}</SelectItem>
253
+ ))}
254
+ </SelectContent>
255
+ </Select>
256
+
257
+ <Select
258
+ value={rule.operator}
259
+ onValueChange={(value) =>
260
+ onChange(rule.id, {
261
+ operator: value as FilterOperatorId,
262
+ value: value === 'between' ? [] : rule.value,
263
+ })
264
+ }
265
+ >
266
+ <SelectTrigger size="sm" aria-label="Filter operator" className="w-[172px]">
267
+ <SelectValue />
268
+ </SelectTrigger>
269
+ <SelectContent>
270
+ {field.operators.map((operator) => (
271
+ <SelectItem key={operator} value={operator}>{operatorLabels[operator]}</SelectItem>
272
+ ))}
273
+ </SelectContent>
274
+ </Select>
275
+
276
+ <div className="min-w-[140px] flex-1">
277
+ {needsValue ? (
278
+ <FilterValueEditor
279
+ field={field}
280
+ rule={rule}
281
+ onChange={(value) => onChange(rule.id, { value })}
282
+ />
283
+ ) : (
284
+ <span className="px-2 text-sm text-muted-foreground">—</span>
285
+ )}
286
+ </div>
287
+
288
+ <Button
289
+ variant="ghost"
290
+ size="icon-sm"
291
+ aria-label="Remove filter"
292
+ onClick={() => onRemove(rule.id)}
293
+ >
294
+ <X className="size-4" />
295
+ </Button>
296
+ </div>
297
+ )
298
+ }
299
+
300
+ function FilterValueEditor({
301
+ field,
302
+ rule,
303
+ onChange,
304
+ }: {
305
+ field: FilterField
306
+ rule: FilterRule
307
+ onChange: (value: string[]) => void
308
+ }) {
309
+ if (field.type === 'number') {
310
+ if (rule.operator === 'between') {
311
+ return (
312
+ <div className="flex items-center gap-2">
313
+ <Input
314
+ type="number"
315
+ inputMode="decimal"
316
+ aria-label="Minimum value"
317
+ placeholder="Min"
318
+ className="h-8"
319
+ value={rule.value[0] ?? ''}
320
+ onChange={(event) => onChange([event.target.value, rule.value[1] ?? ''])}
321
+ />
322
+ <span className="text-xs text-muted-foreground">and</span>
323
+ <Input
324
+ type="number"
325
+ inputMode="decimal"
326
+ aria-label="Maximum value"
327
+ placeholder="Max"
328
+ className="h-8"
329
+ value={rule.value[1] ?? ''}
330
+ onChange={(event) => onChange([rule.value[0] ?? '', event.target.value])}
331
+ />
332
+ </div>
333
+ )
334
+ }
335
+
336
+ return (
337
+ <Input
338
+ type="number"
339
+ inputMode="decimal"
340
+ aria-label={`${field.label} value`}
341
+ placeholder={field.placeholder ?? 'Value'}
342
+ className="h-8"
343
+ value={rule.value[0] ?? ''}
344
+ onChange={(event) => onChange([event.target.value])}
345
+ />
346
+ )
347
+ }
348
+
349
+ if (field.type === 'text') {
350
+ return (
351
+ <Input
352
+ aria-label={`${field.label} value`}
353
+ placeholder={field.placeholder ?? 'Type a value…'}
354
+ className="h-8"
355
+ value={rule.value[0] ?? ''}
356
+ onChange={(event) => onChange([event.target.value])}
357
+ />
358
+ )
359
+ }
360
+
361
+ return <ValuePicker field={field} selected={rule.value} onChange={onChange} />
362
+ }
363
+
364
+ function ValuePicker({
365
+ field,
366
+ selected,
367
+ onChange,
368
+ }: {
369
+ field: FilterField
370
+ selected: readonly string[]
371
+ onChange: (value: string[]) => void
372
+ }) {
373
+ const [open, setOpen] = useState(false)
374
+ const [query, setQuery] = useState('')
375
+
376
+ const options = field.options ?? []
377
+ const showSearch = options.length > 8
378
+ const normalizedQuery = query.trim().toLowerCase()
379
+ const visibleOptions = normalizedQuery
380
+ ? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery))
381
+ : options
382
+ const selectedSet = new Set(selected)
383
+
384
+ const label = selected.length === 0
385
+ ? 'Select values…'
386
+ : selected.length === 1
387
+ ? options.find((option) => option.value === selected[0])?.label ?? selected[0]!
388
+ : `${selected.length} selected`
389
+
390
+ const toggle = (value: string) => {
391
+ onChange(
392
+ selectedSet.has(value)
393
+ ? selected.filter((item) => item !== value)
394
+ : [...selected, value],
395
+ )
396
+ }
397
+
398
+ return (
399
+ <Popover
400
+ open={open}
401
+ onOpenChange={(nextOpen) => {
402
+ setOpen(nextOpen)
403
+ if (!nextOpen) setQuery('')
404
+ }}
405
+ >
406
+ <PopoverTrigger asChild>
407
+ <Button
408
+ variant="outline"
409
+ size="sm"
410
+ className={cn(
411
+ 'h-8 w-full justify-between font-normal',
412
+ selected.length === 0 && 'text-muted-foreground',
413
+ )}
414
+ >
415
+ <span className="truncate">{label}</span>
416
+ <ChevronDown className="size-4 shrink-0 opacity-60" />
417
+ </Button>
418
+ </PopoverTrigger>
419
+ <PopoverContent align="start" className="w-64 p-0">
420
+ {showSearch && (
421
+ <div className="border-b p-2">
422
+ <Input
423
+ autoFocus
424
+ value={query}
425
+ onChange={(event) => setQuery(event.target.value)}
426
+ placeholder="Search values…"
427
+ aria-label={`Search ${field.label} values`}
428
+ className="h-8"
429
+ />
430
+ </div>
431
+ )}
432
+ <div className="max-h-64 overflow-y-auto p-1">
433
+ {visibleOptions.length === 0 ? (
434
+ <p className="px-2 py-6 text-center text-sm text-muted-foreground">No matching values</p>
435
+ ) : (
436
+ visibleOptions.map((option) => {
437
+ const checked = selectedSet.has(option.value)
438
+ return (
439
+ <div
440
+ key={option.value}
441
+ className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
442
+ onClick={() => toggle(option.value)}
443
+ >
444
+ <Checkbox
445
+ checked={checked}
446
+ aria-label={option.label}
447
+ onClick={(event) => event.stopPropagation()}
448
+ onCheckedChange={() => toggle(option.value)}
449
+ />
450
+ <span className="truncate">{option.label}</span>
451
+ </div>
452
+ )
453
+ })
454
+ )}
455
+ </div>
456
+ {selected.length > 0 && (
457
+ <div className="border-t p-1">
458
+ <Button
459
+ variant="ghost"
460
+ size="sm"
461
+ className="w-full justify-start"
462
+ onClick={() => onChange([])}
463
+ >
464
+ Clear selection
465
+ </Button>
466
+ </div>
467
+ )}
468
+ </PopoverContent>
469
+ </Popover>
470
+ )
471
+ }
@@ -0,0 +1,109 @@
1
+ import ReactMarkdown, { type Components } from 'react-markdown'
2
+ import rehypeKatex from 'rehype-katex'
3
+ import remarkGfm from 'remark-gfm'
4
+ import remarkMath from 'remark-math'
5
+ import { cn } from '../lib/utils.ts'
6
+
7
+ 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', className)} {...props}>{children}</code>
13
+ },
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} />,
17
+ 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} />,
28
+ }
29
+
30
+ const markdownCode = /```[\s\S]*?```|~~~[\s\S]*?~~~|(`+)(?!`)[\s\S]*?\1(?!`)/g
31
+
32
+ const mapOutsideCode = (content: string, transform: (text: string) => string) => {
33
+ let result = ''
34
+ let lastIndex = 0
35
+ for (const match of content.matchAll(markdownCode)) {
36
+ const index = match.index ?? 0
37
+ result += transform(content.slice(lastIndex, index)) + match[0]
38
+ lastIndex = index + match[0].length
39
+ }
40
+ return result + transform(content.slice(lastIndex))
41
+ }
42
+
43
+ // remark-math uses $...$ inline and $$...$$ on separate lines for display math.
44
+ // Convert legacy LaTeX delimiters and same-line display expressions to those forms.
45
+ const normalizeMathDelimiters = (content: string) => mapOutsideCode(content, (text) => text
46
+ .replace(/\\\[([\s\S]+?)\\\]/g, (_match, formula: string) => `\n$$\n${formula.trim()}\n$$\n`)
47
+ .replace(/\$\$([\s\S]+?)\$\$/g, (_match, formula: string) => `\n$$\n${formula.trim()}\n$$\n`)
48
+ .replace(/\\\(([\s\S]+?)\\\)/g, (_match, formula: string) => `$${formula}$`))
49
+
50
+ const isPipeRow = (line: string) => line.trimStart().startsWith('|')
51
+
52
+ const isDelimiterRow = (line: string) => {
53
+ const trimmed = line.trim()
54
+ return trimmed.startsWith('|') && trimmed.includes('---')
55
+ }
56
+
57
+ const normalizeGfmTables = (content: string) => {
58
+ const parts = content.split(/(```[\s\S]*?```)/g)
59
+ return parts.map((part, index) => {
60
+ if (index % 2 === 1) return part
61
+ const lines = part.split('\n')
62
+ const result: string[] = []
63
+ let tableHasDelimiter = false
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const line = lines[i]
66
+ if (line.trim() === '') {
67
+ const prev = [...result].reverse().find((item) => item.trim() !== '') ?? ''
68
+ let next = ''
69
+ let afterNext = ''
70
+ for (let j = i + 1; j < lines.length; j++) {
71
+ if (lines[j].trim() !== '') {
72
+ next = lines[j]
73
+ for (let k = j + 1; k < lines.length; k++) {
74
+ if (lines[k].trim() !== '') {
75
+ afterNext = lines[k]
76
+ break
77
+ }
78
+ }
79
+ break
80
+ }
81
+ }
82
+ if (isPipeRow(prev) && isPipeRow(next)) {
83
+ const startsNewTable = tableHasDelimiter && !isDelimiterRow(prev) && !isDelimiterRow(next) && isDelimiterRow(afterNext)
84
+ if (!startsNewTable) continue
85
+ if (result.length > 0 && result[result.length - 1].trim() === '') continue
86
+ }
87
+ result.push(line)
88
+ continue
89
+ }
90
+ if (isPipeRow(line)) {
91
+ if (isDelimiterRow(line)) tableHasDelimiter = true
92
+ } else {
93
+ tableHasDelimiter = false
94
+ }
95
+ result.push(line)
96
+ }
97
+ return result.join('\n')
98
+ }).join('')
99
+ }
100
+
101
+ export function MarkdownContent({ content, className }: { content: string; className?: string }) {
102
+ return (
103
+ <div className={cn('min-w-0 text-base leading-7 text-gray-800', className)}>
104
+ <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={components}>
105
+ {normalizeGfmTables(normalizeMathDelimiters(content))}
106
+ </ReactMarkdown>
107
+ </div>
108
+ )
109
+ }