90 lines
7.3 KiB
Markdown
90 lines
7.3 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## What this is
|
||
|
||
Despite the repo name, this is **not** a Flutter project — there is no Dart toolchain here. It is a Vue 3 + Express web app that serves multiple-choice quizzes *about* Dart & Flutter, with an admin backend for managing question sets.
|
||
|
||
All UI copy, comments, and API error messages are Simplified Chinese. Keep new strings in Chinese to match.
|
||
|
||
## Commands
|
||
|
||
```bash
|
||
npm install
|
||
npm run dev # concurrently: Express on :3030 + Vite dev server on :5173 — develop at http://localhost:5173
|
||
npm run build # Vite builds web/ → dist/ at the repo root
|
||
npm start # Express only on :3030, serving dist/ + API (requires a prior build)
|
||
```
|
||
|
||
Server env vars (defaults in parens): `PORT` (3030), `ADMIN_USER` (admin), `ADMIN_PASS` (admin123).
|
||
|
||
### Data scripts
|
||
|
||
No test runner is configured. `verify-questions.js` is the closest thing to a test suite — run it after any bulk edit of `questions.json`.
|
||
|
||
```bash
|
||
node scripts/verify-questions.js # structural validation of every question + hardcoded answer-index spot checks
|
||
node scripts/fix-escaping.js # re-normalizes HTML escaping across questions.json in place; also asserts the transform is idempotent
|
||
node scripts/import-sets.js [file...] # POSTs scripts/data/<file> into an ALREADY-RUNNING server on :3030 (logs in as admin/admin123); with no args, re-imports the two original dev sets
|
||
node scripts/extract-questions.js # DESTRUCTIVE one-shot legacy migration; see "Legacy files" below
|
||
```
|
||
|
||
`scripts/` and `server.js` are CommonJS (no `"type": "module"` in package.json); everything under `web/` is ESM.
|
||
|
||
## Architecture
|
||
|
||
### Two processes, one origin
|
||
|
||
The frontend never hardcodes an API host — `web/src/api.js` always fetches `/api/...`. In dev, Vite's proxy (`vite.config.js`) forwards `/api` to `:3030`. In production, Express serves `dist/` and the API from the same port, with a catch-all (`/^\/(?!api\/).*/`) sending everything non-`/api` to `index.html`. Preserve that same-origin assumption when adding endpoints.
|
||
|
||
### Data store: one JSON file, held in memory
|
||
|
||
`questions.json` at the repo root is the entire database. `server.js` reads it **once at boot** into a module-level `data` object; every mutating route calls `saveData()`, which rewrites the whole file synchronously.
|
||
|
||
Consequences to keep in mind:
|
||
- Hand-editing `questions.json` while the server is running will be silently clobbered by the next write. Restart after external edits (including after running `fix-escaping.js`).
|
||
- `loadData()` auto-migrates a legacy top-level array into `{sets, nextSetId, nextQId}` and rewrites the file on first read.
|
||
|
||
### IDs are global, not per-set
|
||
|
||
`nextSetId` / `nextQId` are counters in the JSON. Question IDs are unique **across all sets**, which is why `findQuestion(qid)` scans every set and why the admin routes for questions are `/api/admin/questions/:qid` with no set in the path. Don't reintroduce per-set question numbering.
|
||
|
||
### Grading is client-side, and the quiz page works offline
|
||
|
||
`GET /api/questions` returns whole questions through `toPublic()`, **including `ans` and `exp`**. That is deliberate: `QuizView` caches the full set in `localStorage` and grades locally, so answering keeps working with the network down — and because grading is synchronous, a correct pick renders green immediately instead of flashing red while a round trip completes. The trade-off is that answers are visible in the network payload; don't reintroduce answer-stripping without also rethinking offline grading. `toPublic()` is still an explicit whitelist, so new internal fields (authoring notes, review flags) stay server-side unless you add them there.
|
||
|
||
Caching is **network-first, cache-fallback** in both `loadSets()` and `loadQuestions()`: an online client always gets fresh questions, so edits made in the admin UI are never masked by a stale cache and no invalidation mechanism is needed. Keys are versioned (`quiz-cache-v1-sets`, `quiz-cache-v1-set-<id>`) — bump `CACHE_VER` in `QuizView.vue` when the payload shape changes. Answer progress is intentionally *not* persisted: a reload restarts the round.
|
||
|
||
`loadQuestions()` bails out with a visible error if the first question has no `ans`, which catches the dev-mode trap of editing `server.js` without restarting it (`npm run dev` runs Express and Vite as separate processes) — otherwise local grading would silently mark every answer wrong.
|
||
|
||
`POST /api/check` still exists and still grades server-side, but the quiz page no longer calls it. `GET /api/admin/sets` returns full questions and stays behind auth.
|
||
|
||
### Auth
|
||
|
||
`POST /api/admin/login` compares against the env credentials and returns a random hex token stored in an in-memory `Set`. Tokens have no expiry but are lost on restart, so a server restart logs every admin out. The client keeps the token in `localStorage` under `quiz_token` and sends it as `Authorization: Bearer`; `AdminView.checkAuth()` validates it by probing `/admin/sets` on mount.
|
||
|
||
### HTML escaping contract
|
||
|
||
`web/src/htmlUtil.mjs` is shared by the browser bundle and the Node scripts — that is why it is `.mjs` (CommonJS scripts `await import()` it). Don't rename it to `.js`.
|
||
|
||
`escapeSmartHtml()` preserves an allowlist of formatting tags (`b`, `code`, `pre`, lists, headings, …) and escapes everything else, so question authors can paste raw `<` and `>` from code samples without hand-writing `<`. Stored content in `questions.json` is therefore already-escaped HTML.
|
||
|
||
The round trip in `AdminView` is: `decodeEntities()` when loading into the editor → edit as plain text → `escapeSmartHtml(decodeEntities(...))` on save. This is deliberately idempotent, and `fix-escaping.js` verifies that property. Keep any new field that accepts authored markup on the same path.
|
||
|
||
Rendering is asymmetric on purpose: question text (`q`) and explanations (`exp`) render through `v-html`; **options render as plain text** via `stripHtmlAndDecode()`. Options don't support markup.
|
||
|
||
### Difficulty levels
|
||
|
||
`lv` is an integer 1–6, validated server-side. Each set may carry `lvNames`, which must be exactly 6 non-empty strings (`validateLvNames`); sets without it fall back to `DEFAULT_LV_NAMES`. `QuizView` prepends an empty string to the array so `lvNames[lv]` indexes directly — the constant there is 7 elements, the one in `AdminView` and the API is 6. The `.seal-lv1`–`.seal-lv6` badge colors live in `web/src/styles/theme.css`.
|
||
|
||
### Routing and styling
|
||
|
||
`vue-router` uses **hash history**, so real URLs are `/#/` (quiz) and `/#/admin`. Set selection is a query param on the quiz route: `/#/quiz?set=9`. The startup banner in `server.js` still advertises `/admin.html`, which no longer exists.
|
||
|
||
`theme.css` is a global design system (CSS custom properties + `.card` / `.btn-*` / `.input` / `.seal` / `.table` classes). Component `<style>` blocks are `scoped` and only add what's specific to that view — reach for the existing tokens and classes before writing new ones.
|
||
|
||
### Legacy files
|
||
|
||
`index.html` at the repo root is the original standalone single-file version, with its 31 questions inlined as a JS array. Nothing serves it (Vite's root is `web/`, and the live entry is `web/index.html`). `scripts/extract-questions.js` exists only to parse that array out of it; running it overwrites `questions.json` with a single legacy set, discarding every set added since. Treat both as historical artifacts.
|