# 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 :3031 + 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 :3031, serving dist/ + API (requires a prior build) ``` Server env vars (defaults in parens): `PORT` (3031), `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/ into an ALREADY-RUNNING server on :3031; BASE_URL/ADMIN_USER/ADMIN_PASS can override connection settings 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 `:3031`. 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 and validates it **once at boot** into a module-level `data` object; every mutating route calls `saveData()`, which writes a temporary file and atomically replaces the database. 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. Keys are versioned (`quiz-cache-v2-sets`, `quiz-cache-v2-set-`) — bump `CACHE_VER` in `QuizView.vue` when the payload shape changes. Answer progress is persisted per set and restored only when the server-provided content `version` still matches. `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 lightweight summaries; fetch one set and its questions from `GET /api/admin/sets/:id`. New and duplicated sets are drafts (`published: false`). Public endpoints only expose sets that are published and contain at least one question. Use `PATCH /api/admin/sets/:id/publish` to publish or unpublish; deleting the last question automatically returns a set to draft state. ### 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` loads `/admin/sets` once on mount to validate the token and bootstrap the list. ### 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, …), strips all tag attributes, and escapes everything else, so question authors can paste raw `<` and `>` from code samples without hand-writing `<`. The server applies the same attribute-free allowlist when questions are created, imported, or updated. 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 `