question-bank-framework 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +52 -0
  5. package/core/index.ts +35 -0
  6. package/core/types.ts +256 -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 +690 -0
  11. package/framework/components/quiz-list.tsx +118 -0
  12. package/framework/components/quiz-page.tsx +454 -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 +376 -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 +66 -4
  33. package/template/bank.config.ts +72 -0
  34. package/template/content/answers.ts +8 -0
  35. package/template/content/questions.json +76 -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
package/README.md CHANGED
@@ -1,3 +1,106 @@
1
- # Temporary Holding Version
1
+ # question-bank-framework
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-style question bank (generalised from the COMP20008 bank). Downstream
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.
6
+
7
+ Inspired by Vike's eject model: the stable core (`core/`) is never ejected;
8
+ everything under `framework/` can be ejected into your repo and owned.
9
+
10
+ ## Use as a template
11
+
12
+ ```sh
13
+ pnpm add question-bank-framework
14
+ pnpm exec qb init # scaffolds bank.config.ts + content/ + src/routes/
15
+ pnpm install
16
+ pnpm dev
17
+ ```
18
+
19
+ Your app owns:
20
+
21
+ - `bank.config.ts` — `defineBank({ site, namespace, fields, questions, answers })`.
22
+ `fields: BankFieldDef[]` **is** your question schema: each field declares
23
+ `id/label/type/getValue/options/searchable/column`. Built-ins (`id/title/tags/status`) are automatic.
24
+ - `content/questions.json`, `content/answers.ts`, `content/figures/`
25
+ - Thin TanStack Start routes in `src/routes/` (see `template/src/routes/`)
26
+ wrapping `<BankProvider bank={bank}>` + `<AppShell brand>` + framework pages.
27
+
28
+ ## Reveal (eject) files
29
+
30
+ ```sh
31
+ qb list # ejectable files under framework/
32
+ qb reveal framework/components/quiz-page.tsx
33
+ qb reveal framework/components/quiz-page.tsx --to src/overrides --force
34
+ ```
35
+
36
+ This copies the file into your repo (tracked in `.qb-manifest.json` with the
37
+ framework version). Add an alias so the override shadows the package:
38
+
39
+ ```ts
40
+ // vite.config.ts + tsconfig paths
41
+ resolve: { alias: { 'question-bank-framework/framework': './src/overrides' } }
42
+ ```
43
+
44
+ Eject is a last resort (same guidance as Vike): prefer `bank.config.ts`
45
+ fields/columns/searchable flags first; eject when you need custom UI.
46
+
47
+ ## How Next.js / Vike do it (and what we copied)
48
+
49
+ - **Next.js:** no eject command. Framework code stays hidden; you customize via
50
+ `next.config.js` + file conventions (`app/layout.tsx`, `error.tsx`,
51
+ `loading.tsx` override built-in fallbacks just by existing). Deep eject
52
+ (custom server/webpack fork) is possible but discouraged.
53
+ - **Vike:** tiny stable core + thin extensions (`vike-react`…) using the same
54
+ public hooks as user code. Eject is first-class: remove the extension, copy
55
+ `node_modules/vike-xxx/` → `ejected/vike-xxx/` (manually or via the `eject`
56
+ package), then customize.
57
+
58
+ We follow Vike: `core/` (types, `defineBank`, `BankProvider`, `useBank`) is the
59
+ stable core; `framework/` (components/lib/routes/convex) is the ejectable
60
+ extension layer; `qb reveal` is the copy command.
61
+
62
+ ## Layout
63
+
64
+ - `core/` — never ejected: `types.ts` (`BaseQuestion`, `BankFieldDef`,
65
+ `defineBank`), `config.tsx` (`BankProvider`, `useBank`).
66
+ - `framework/` — ejectable: `components/` (grid, quiz, filter builder,
67
+ shadcn `ui/`), `lib/` (generic filter DSL, namespaced quiz/status stores,
68
+ `convex-api.ts`), `convex/` (quizzes + questionStatuses tables).
69
+ - `template/` — starter: `bank.config.ts` (COMP20008-like schema),
70
+ `content/` example, `src/routes/` thin wrappers.
71
+ - `bin/qb.ts` (source; compiled to `bin/qb.js` with `pnpm build:bin`) — `list` / `reveal` / `init`.
72
+
73
+ ## Schema example
74
+
75
+ ```ts
76
+ defineBank({
77
+ site: { title: '…', brand: 'fieldnotes' },
78
+ namespace: 'comp20008', // localStorage keys + Convex lookups
79
+ fields: [
80
+ { id: 'year', label: 'Year', type: 'select', getValue: (q) => String(q['year'] ?? '') },
81
+ { id: 'marks', label: 'Marks', type: 'number', getValue: (q) => q['marks'] ?? null },
82
+ ],
83
+ questions, // BaseQuestion[]: { id, title, prompt, tags, ...custom }
84
+ answers, // Record<id, markdown>
85
+ });
86
+ ```
87
+
88
+ ## Dev
89
+
90
+ ```sh
91
+ pnpm install
92
+ pnpm typecheck
93
+ ```
94
+
95
+ ## Release (npm + changesets)
96
+
97
+ ```sh
98
+ pnpm changeset # describe your change as patch/minor/major, then push
99
+ ```
100
+
101
+ Pushing to `main` runs the release workflow: it opens a "Version Packages"
102
+ PR (bump + CHANGELOG). Merge that PR and the workflow publishes to npm with
103
+ OIDC trusted publishing and provenance attestations — no npm token needed.
104
+
105
+ One-time setup: on npmjs.com, add this repo as a trusted publisher for the
106
+ `question-bank-framework` package (no tokens or secrets to manage).
package/bin/qb.js ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ // `qb` — Vike-style eject CLI for question-bank-framework.
3
+ // qb list list ejectable framework files
4
+ // qb reveal <path> [--to <dir>] copy framework/<path> into your repo to own it
5
+ // qb init [--to <dir>] scaffold bank.config.ts + content/ + routes
6
+ //
7
+ // Eject model (like Vike's `eject` package): framework code ships in
8
+ // node_modules/question-bank-framework/framework/. By default you import it.
9
+ // `qb reveal` copies a file into your project (default: src/overrides/<path>)
10
+ // where your Vite/TS alias shadows the package. Edit freely afterwards.
11
+ //
12
+ // NOTE: the installed bin is plain JS compiled from this file
13
+ // (`pnpm build:bin`) — Node refuses to type-strip files under node_modules,
14
+ // so bin/qb.js must stay committed alongside this source.
15
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ const pkgRoot = resolve(here, '..');
20
+ const frameworkDir = join(pkgRoot, 'framework');
21
+ const templateDir = join(pkgRoot, 'template');
22
+ const args = process.argv.slice(2);
23
+ const cmd = args[0];
24
+ function walk(dir, base = '') {
25
+ const out = [];
26
+ for (const entry of readdirSync(dir)) {
27
+ const full = join(dir, entry);
28
+ const rel = base ? `${base}/${entry}` : entry;
29
+ if (statSync(full).isDirectory())
30
+ out.push(...walk(full, rel));
31
+ else
32
+ out.push(rel);
33
+ }
34
+ return out.sort();
35
+ }
36
+ function pkgVersion() {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
39
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
40
+ }
41
+ catch {
42
+ return '0.0.0';
43
+ }
44
+ }
45
+ function readManifest(cwd) {
46
+ const path = join(cwd, '.qb-manifest.json');
47
+ try {
48
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
49
+ if (parsed && typeof parsed.reveals === 'object' && parsed.reveals !== null) {
50
+ return { reveals: parsed.reveals };
51
+ }
52
+ return { reveals: {} };
53
+ }
54
+ catch {
55
+ return { reveals: {} };
56
+ }
57
+ }
58
+ function writeManifest(cwd, manifest) {
59
+ writeFileSync(join(cwd, '.qb-manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
60
+ }
61
+ if (cmd === 'list' || !cmd) {
62
+ if (!existsSync(frameworkDir)) {
63
+ console.error(`framework/ not found at ${frameworkDir}`);
64
+ process.exit(1);
65
+ }
66
+ for (const f of walk(frameworkDir))
67
+ console.log(`framework/${f}`);
68
+ if (!cmd) {
69
+ console.log('\nUsage: qb reveal <framework/<path>> [--to <dir>] [--force]');
70
+ }
71
+ }
72
+ else if (cmd === 'reveal') {
73
+ const src = args[1];
74
+ if (!src) {
75
+ console.error('Usage: qb reveal <framework/<path>> [--to <dir>] [--force]');
76
+ process.exit(1);
77
+ }
78
+ const toIdx = args.indexOf('--to');
79
+ const force = args.includes('--force');
80
+ const destRoot = toIdx !== -1 && args[toIdx + 1] ? args[toIdx + 1] : 'src/overrides';
81
+ const rel = src.replace(/^framework\//, '');
82
+ const absSrc = join(frameworkDir, rel);
83
+ if (!existsSync(absSrc) || !statSync(absSrc).isFile()) {
84
+ console.error(`Not ejectable: ${src}\nRun \`qb list\` to see ejectable files.`);
85
+ process.exit(1);
86
+ }
87
+ const cwd = process.cwd();
88
+ const dest = resolve(cwd, destRoot, rel);
89
+ if (existsSync(dest) && !force) {
90
+ console.error(`Refusing to overwrite ${dest} (pass --force).`);
91
+ process.exit(1);
92
+ }
93
+ mkdirSync(dirname(dest), { recursive: true });
94
+ cpSync(absSrc, dest);
95
+ const manifest = readManifest(cwd);
96
+ manifest.reveals[`${destRoot}/${rel}`] = { src: `framework/${rel}`, version: pkgVersion(), revealedAt: new Date().toISOString() };
97
+ writeManifest(cwd, manifest);
98
+ console.log(`Revealed ${`framework/${rel}`} -> ${dest}`);
99
+ console.log('Add an alias so the override shadows the package, e.g. Vite resolve.alias + tsconfig paths:');
100
+ console.log(` "${destRoot}/*" takes precedence over "question-bank-framework/framework/*"`);
101
+ }
102
+ else if (cmd === 'init') {
103
+ const toIdx = args.indexOf('--to');
104
+ const destRoot = toIdx !== -1 && args[toIdx + 1] ? args[toIdx + 1] : '.';
105
+ const cwd = resolve(process.cwd(), destRoot);
106
+ for (const f of walk(templateDir)) {
107
+ const from = join(templateDir, f);
108
+ const to = join(cwd, f);
109
+ mkdirSync(dirname(to), { recursive: true });
110
+ if (!existsSync(to))
111
+ cpSync(from, to);
112
+ }
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/).');
115
+ }
116
+ else {
117
+ console.error(`Unknown command: ${cmd}\nCommands: list, reveal, init`);
118
+ process.exit(1);
119
+ }
package/bin/qb.ts ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // `qb` — Vike-style eject CLI for question-bank-framework.
3
+ // qb list list ejectable framework files
4
+ // qb reveal <path> [--to <dir>] copy framework/<path> into your repo to own it
5
+ // qb init [--to <dir>] scaffold bank.config.ts + content/ + routes
6
+ //
7
+ // Eject model (like Vike's `eject` package): framework code ships in
8
+ // node_modules/question-bank-framework/framework/. By default you import it.
9
+ // `qb reveal` copies a file into your project (default: src/overrides/<path>)
10
+ // where your Vite/TS alias shadows the package. Edit freely afterwards.
11
+ //
12
+ // NOTE: the installed bin is plain JS compiled from this file
13
+ // (`pnpm build:bin`) — Node refuses to type-strip files under node_modules,
14
+ // so bin/qb.js must stay committed alongside this source.
15
+
16
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
17
+ import { dirname, join, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const here: string = dirname(fileURLToPath(import.meta.url));
21
+ const pkgRoot: string = resolve(here, '..');
22
+ const frameworkDir: string = join(pkgRoot, 'framework');
23
+ const templateDir: string = join(pkgRoot, 'template');
24
+
25
+ const args: string[] = process.argv.slice(2);
26
+ const cmd: string | undefined = args[0];
27
+
28
+ function walk(dir: string, base = ''): string[] {
29
+ const out: string[] = [];
30
+ for (const entry of readdirSync(dir)) {
31
+ const full: string = join(dir, entry);
32
+ const rel: string = base ? `${base}/${entry}` : entry;
33
+ if (statSync(full).isDirectory()) out.push(...walk(full, rel));
34
+ else out.push(rel);
35
+ }
36
+ return out.sort();
37
+ }
38
+
39
+ function pkgVersion(): string {
40
+ try {
41
+ const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) as { version?: unknown };
42
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
43
+ } catch {
44
+ return '0.0.0';
45
+ }
46
+ }
47
+
48
+ interface RevealEntry {
49
+ src: string;
50
+ version: string;
51
+ revealedAt: string;
52
+ }
53
+
54
+ interface QbManifest {
55
+ reveals: Record<string, RevealEntry>;
56
+ }
57
+
58
+ function readManifest(cwd: string): QbManifest {
59
+ const path = join(cwd, '.qb-manifest.json');
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<QbManifest>;
62
+ if (parsed && typeof parsed.reveals === 'object' && parsed.reveals !== null) {
63
+ return { reveals: parsed.reveals };
64
+ }
65
+ return { reveals: {} };
66
+ } catch {
67
+ return { reveals: {} };
68
+ }
69
+ }
70
+
71
+ function writeManifest(cwd: string, manifest: QbManifest): void {
72
+ writeFileSync(join(cwd, '.qb-manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
73
+ }
74
+
75
+ if (cmd === 'list' || !cmd) {
76
+ if (!existsSync(frameworkDir)) {
77
+ console.error(`framework/ not found at ${frameworkDir}`);
78
+ process.exit(1);
79
+ }
80
+ for (const f of walk(frameworkDir)) console.log(`framework/${f}`);
81
+ if (!cmd) {
82
+ console.log('\nUsage: qb reveal <framework/<path>> [--to <dir>] [--force]');
83
+ }
84
+ } else if (cmd === 'reveal') {
85
+ const src: string | undefined = args[1];
86
+ if (!src) {
87
+ console.error('Usage: qb reveal <framework/<path>> [--to <dir>] [--force]');
88
+ process.exit(1);
89
+ }
90
+ const toIdx: number = args.indexOf('--to');
91
+ const force: boolean = args.includes('--force');
92
+ const destRoot: string = toIdx !== -1 && args[toIdx + 1] ? args[toIdx + 1] : 'src/overrides';
93
+ const rel: string = src.replace(/^framework\//, '');
94
+ const absSrc: string = join(frameworkDir, rel);
95
+ if (!existsSync(absSrc) || !statSync(absSrc).isFile()) {
96
+ console.error(`Not ejectable: ${src}\nRun \`qb list\` to see ejectable files.`);
97
+ process.exit(1);
98
+ }
99
+ const cwd: string = process.cwd();
100
+ const dest: string = resolve(cwd, destRoot, rel);
101
+ if (existsSync(dest) && !force) {
102
+ console.error(`Refusing to overwrite ${dest} (pass --force).`);
103
+ process.exit(1);
104
+ }
105
+ mkdirSync(dirname(dest), { recursive: true });
106
+ cpSync(absSrc, dest);
107
+ const manifest: QbManifest = readManifest(cwd);
108
+ manifest.reveals[`${destRoot}/${rel}`] = { src: `framework/${rel}`, version: pkgVersion(), revealedAt: new Date().toISOString() };
109
+ writeManifest(cwd, manifest);
110
+ console.log(`Revealed ${`framework/${rel}`} -> ${dest}`);
111
+ console.log('Add an alias so the override shadows the package, e.g. Vite resolve.alias + tsconfig paths:');
112
+ console.log(` "${destRoot}/*" takes precedence over "question-bank-framework/framework/*"`);
113
+ } else if (cmd === 'init') {
114
+ const toIdx: number = args.indexOf('--to');
115
+ const destRoot: string = toIdx !== -1 && args[toIdx + 1] ? args[toIdx + 1] : '.';
116
+ const cwd: string = resolve(process.cwd(), destRoot);
117
+ for (const f of walk(templateDir)) {
118
+ const from: string = join(templateDir, f);
119
+ const to: string = join(cwd, f);
120
+ mkdirSync(dirname(to), { recursive: true });
121
+ if (!existsSync(to)) cpSync(from, to);
122
+ }
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/).');
125
+ } else {
126
+ console.error(`Unknown command: ${cmd}\nCommands: list, reveal, init`);
127
+ process.exit(1);
128
+ }
@@ -0,0 +1,52 @@
1
+ // Bank runtime context. Framework components consume the active bank via
2
+ // `useBank()` instead of importing `@/data/questions` directly, so a
3
+ // downstream subject only owns `bank.config.ts` + `content/`.
4
+
5
+ import { createContext, useContext, type ReactNode } from 'react';
6
+ import type { Bank } from './types.ts';
7
+
8
+ export const BankContext = createContext<Bank | null>(null);
9
+
10
+ export function BankProvider({ bank, children }: { bank: Bank; children: ReactNode }) {
11
+ return <BankContext.Provider value={bank}>{children}</BankContext.Provider>;
12
+ }
13
+
14
+ export function useBank(): Bank {
15
+ const bank = useContext(BankContext);
16
+ if (!bank) {
17
+ throw new Error(
18
+ 'useBank() called outside <BankProvider>. Wrap your routes with <BankProvider bank={bank}>.',
19
+ );
20
+ }
21
+ return bank;
22
+ }
23
+
24
+ export { defineBank } from './types.ts';
25
+ export type {
26
+ Bank,
27
+ BankFieldDef,
28
+ BankFieldOption,
29
+ BankFieldType,
30
+ BankFilterOperatorId,
31
+ BankSiteConfig,
32
+ BaseQuestion,
33
+ DefineBankInput,
34
+ QuestionKind,
35
+ QuestionOption,
36
+ QuestionStatus,
37
+ QuestionStatusMap,
38
+ QuizAnswer,
39
+ QuizSession,
40
+ } from './types.ts';
41
+ export {
42
+ getCorrectIds,
43
+ isAnswerCorrect,
44
+ isAnswerProvided,
45
+ isChoiceQuestion,
46
+ questionKind,
47
+ questionKindLabels,
48
+ questionKinds,
49
+ scoreAnswers,
50
+ toSelectedIds,
51
+ } from './types.ts';
52
+ export { questionStatuses } from './types.ts';
package/core/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ // Re-export the stable core: types + BankContext/useBank/defineBank.
2
+ // Framework code (ejectable) lives under framework/.
3
+
4
+ export {
5
+ BankContext,
6
+ BankProvider,
7
+ useBank,
8
+ defineBank,
9
+ getCorrectIds,
10
+ isAnswerCorrect,
11
+ isAnswerProvided,
12
+ isChoiceQuestion,
13
+ questionKind,
14
+ questionKindLabels,
15
+ questionKinds,
16
+ questionStatuses,
17
+ scoreAnswers,
18
+ toSelectedIds,
19
+ } from './config.tsx';
20
+ export type {
21
+ Bank,
22
+ BankFieldDef,
23
+ BankFieldOption,
24
+ BankFieldType,
25
+ BankFilterOperatorId,
26
+ BankSiteConfig,
27
+ BaseQuestion,
28
+ DefineBankInput,
29
+ QuestionKind,
30
+ QuestionOption,
31
+ QuestionStatus,
32
+ QuestionStatusMap,
33
+ QuizAnswer,
34
+ QuizSession,
35
+ } from './config.tsx';