refactor(web): rebuild the frontend on SvelteKit, TypeScript and Tailwind

The app was a single-entry React SPA: one index.html that the assembler
copied to every dataset path, with a hand-rolled router resolving the
dataset from window.location and the title patched in at runtime because
one file had to serve every route.

SvelteKit prerenders a real page per route instead. The entry generator
in routes/[dataset]/+page.ts reads the same datasets.json the assembler
does, so the set of pages and the set of databases cannot drift apart,
and each dataset page ships its own <title> and description.

Everything framework-free moved across unchanged in behaviour and became
typed: the admission blocks, subject list, query classifier and SQL
presets. `Student` now mirrors the 22-column table, so a mistyped column
name fails the build rather than rendering blank.

Tailwind replaces the stylesheet. Theme tokens are CSS variables, which
keeps dark mode a single block of overrides rather than a `dark:` variant
on every class. The score tiers stay hand-written CSS: the class is
chosen at runtime from a score, and no utility generator can see that.

Adds the tests the frontend never had, over the three modules where a
silent wrong answer is possible — most importantly that toAscii here
folds exactly as ToAscii does in the parser, which is what makes
accent-insensitive search find anything.

The assembler stops copying index.html per dataset and checks that the
build prerendered each one instead. The database presence, raw-artifact
and idempotence guards are untouched.

Verified: 25 tests, ESLint and svelte-check clean, and a full
`assemble site` producing /thptqg/, /thptqg/2016/, /thptqg/2017/ and
404.html with absolute asset URLs. Not verified in a browser — this
machine has none.
This commit is contained in:
tiennm99 committed 2026-08-14 11:38:29 +07:00
1 parent e2112fdb38
commit 3fd137a233
49 files changed
+4432 -3112

No files matched your search

+6 -6
View File
@@ -11,7 +11,7 @@ One-time setup: **Settings → Pages → Source: GitHub Actions**.
2. Parser, crawler and assembler test suites, web lint, `govulncheck` over all three modules
3. `go -C assembler run ./cmd/assemble` — the whole pipeline: compile the
parser, build and verify each database, compress it into `.build/public/db/`,
run the Vite build, assemble `_site/`
run the web build, assemble `_site/`
4. `actions/upload-pages-artifact` + `actions/deploy-pages`
The database build dominates the runtime: roughly 348 MB of Excel is parsed on
@@ -46,9 +46,9 @@ go -C assembler run ./cmd/assemble site
## Base path
`vite.config.js` sets `base: "/thptqg/"`. If you fork under a different repo
name, update it to match — assets are referenced absolutely, so a mismatch shows
up as a blank page with 404s on `/assets/...`.
`svelte.config.js` sets `paths.base: "/thptqg"`. If you fork under a different
repo name, update it to match — assets are referenced absolutely, so a mismatch
shows up as a blank page with 404s on `/_app/...`.
## Adding a dataset
@@ -96,9 +96,9 @@ run rebuilds the older state. There is no data to migrate.
| Symptom | Typical cause |
| --- | --- |
| Blank page, 404 on assets | `base` in `vite.config.js` does not match the repo name |
| Blank page, 404 on assets | `paths.base` in `svelte.config.js` does not match the repo name |
| `Failed to fetch database: 404` | Dataset id in `datasets.json` does not match the file in `db/` |
| A route 404s | The site step did not run, or the id is missing from `datasets.json` |
| WASM fails to load | `sql.js.org` unreachable — self-host `sql-wasm.wasm` and update `SQL_WASM_URL` in `use-sqlite.js` |
| WASM fails to load | `sql.js.org` unreachable — self-host `sql-wasm.wasm` and update `SQL_WASM_URL` in `lib/sqlite.svelte.ts` |
| Deploy fails on assembly | An uncompressed database artefact reached the output; the error names the files |
| Missing rows after a data update | Unknown Excel header — check the per-file row counts the parser prints |
+20 -20
View File
@@ -20,7 +20,7 @@ data/<id>/*.xls(x)
▼ assembler/ — row count must match datasets.json, then gzip
.build/public/db/<id>.db.gz (the raw .db does not survive)
│
▼ assembler/ → vite build (root = web/, publicDir = .build/public)
▼ assembler/ → npm run build (SvelteKit static, assets = .build/public)
web/dist/
│
▼ assembler/ — one index.html per dataset; every database must be present
@@ -40,7 +40,7 @@ data/2017/ → parser/configs/2017.yml → db/2017.db.gz → /thptqg/2017/
`datasets.json` at the repository root declares the ids once, with the row count
and artifact size the assembler enforces. It is JSON rather than a module
because the assembler is a Go program and the Vite app is not, and JSON is the
because the assembler is a Go program and the web app is not, and JSON is the
only format both parse without a dependency.
Presentation — titles, labels, search examples, SQL presets — stays in
@@ -100,23 +100,21 @@ URLs are flat, one segment per dataset, and the segment is the id:
/thptqg/2017/
```
`web/src/router.js` is an exact match on that segment. The nested form used
before (`/thptqg/2017/old/`) would have needed longest-prefix matching, since it
also starts with `/thptqg/2017/`. Both of those URLs addressed the two removed
2017 archives, so the rewrite that kept them working has gone with them; they
fall through to the hub like any other unknown path.
The route is `web/src/routes/[dataset]/`, and its entry generator reads the same
`datasets.json` the assembler does, so the set of pages and the set of databases
cannot drift apart. Unknown paths fall through to the hub.
A single Vite build emits one `index.html`. Because `base` is absolute
(`/thptqg/`), that file references `/thptqg/assets/...` regardless of the
directory it is served from, so the assemble step copies it to every route and
each URL is a real static file. **No SPA 404-fallback redirect is used** — the
usual hack rewrites URLs and would interfere with the deep links.
SvelteKit prerenders one HTML file per route, each with its own `<title>`.
Asset URLs stay absolute (`paths.relative: false`), so the copy of the hub that
serves as `404.html` resolves its assets from any depth. **No SPA 404-fallback
redirect is used** — the usual hack rewrites URLs and would interfere with the
deep links.
## Serving both exam years without branching
No component contains a per-dataset conditional. Two mechanisms do the work:
- **All-NULL columns are hidden.** `score-table.jsx` drops any column where
- **All-NULL columns are hidden.** `score-table.svelte` drops any column where
every row in the result set is NULL, so 2016 rows surface Cụm thi / GT / Đức /
Nhật and 2017 rows surface KHTN / KHXH / GDCD / Nga.
- **Incomplete admission blocks are skipped.** `computeBlocks()` only returns a
@@ -125,13 +123,14 @@ No component contains a per-dataset conditional. Two mechanisms do the work:
self-exclude wherever those languages were not sat.
Anything genuinely per-dataset — title, source, database size, search examples,
SQL presets — lives in `web/src/datasets.js`.
SQL presets — lives in `web/src/lib/datasets.ts`.
## Exam ID formats
`web/src/lib/query-mode.js` decides whether a query is an exam ID or a name, and is
shared by `App.jsx` and `search-form.jsx` (they previously held separate copies
and had drifted apart on exactly this rule).
`web/src/lib/query-mode.ts` decides whether a query is an exam ID or a name, and
is shared by the dataset page and `search-form.svelte` (they previously held
separate copies and had drifted apart on exactly this rule). `query-mode.test.ts`
covers every form in the table below.
| Form | Example | Where |
| --- | --- | --- |
@@ -175,16 +174,17 @@ total descending.
| Diacritics search | Pre-computed `ho_ten_ascii` | `LOWER(REPLACE(...))` at query time defeats the index |
| SQL safety | Leading-keyword allowlist | `sql.js` is in-memory so writes cannot persist; the allowlist prevents confusion |
| Row caps | 100 (lookup), 1000 (SQL) | Keeps DOM render sizes reasonable |
| Routing | Hand-rolled, ~15 lines | Three static routes do not justify a router dependency |
| Routing | SvelteKit file routes, prerendered | Each dataset gets a real HTML file with its own title |
| Styling | Tailwind, with tier colours as CSS variables | Tier classes are chosen at runtime, which no utility generator can see |
## Risks and limitations
- **Database size.** 44–48 MB gzipped per dataset; slow links wait, mitigated by
- **Database size.** 45–48 MB gzipped per dataset; slow links wait, mitigated by
a progress bar.
- **Browser memory.** The full database lives in RAM; older mobile devices may
run out.
- **`sql.js.org` dependency.** If that CDN is unreachable, the WASM fails to
load. Self-hosting `sql-wasm.wasm` and updating `SQL_WASM_URL` in
`web/src/hooks/use-sqlite.js` is the fix.
`web/src/lib/sqlite.svelte.ts` is the fix.
- **Excel format drift.** A new source file with an unseen header layout needs a
new branch in `parser/internal/ingest/detect2016.go` or a new config.