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

+39 -26
View File
@@ -1,15 +1,14 @@
// Package site turns the web app and the staged databases into the directory
// GitHub Pages publishes.
//
// The app resolves its dataset from the URL, so every page is the same
// index.html. Because Vite's `base` is absolute (/thptqg/), that file references
// /thptqg/assets/... no matter which directory it is served from — so copying it
// to each dataset path produces a real static file at every URL.
// SvelteKit prerenders one HTML file per route — the hub and one per dataset in
// the registry — so every URL is already a real static file when this runs. All
// that is left is the 404 document and the checks on what shipped.
//
// GitHub Pages serves those as directory indexes, which is why this needs no
// SPA 404-fallback redirect. That matters beyond tidiness: the usual fallback
// rewrites the URL and would interfere with the ?q= deep links the app relies
// on.
// GitHub Pages serves the prerendered files as directory indexes, which is why
// this needs no SPA fallback redirect. That matters beyond tidiness: the usual
// fallback rewrites the URL and would interfere with the ?q= deep links the app
// relies on.
package site
import (
@@ -26,9 +25,9 @@ import (
// Paths locates the pieces this package needs.
type Paths struct {
// Web is the Vite project directory.
// Web is the SvelteKit project directory.
Web string
// Dist is where Vite emits, inside the web workspace.
// Dist is where the static adapter emits, inside the web workspace.
Dist string
// Site is the artifact the deploy action uploads, at the repository root.
Site string
@@ -44,17 +43,17 @@ func DefaultPaths(root string) Paths {
}
}
// BuildWeb runs the Vite build.
// BuildWeb runs the web build.
//
// Shelling out to npm is not a wart: Vite is a Node tool, and web/ is the only
// npm project in the repository. This stage owns the sequencing, not the
// Shelling out to npm is not a wart: SvelteKit is a Node tool, and web/ is the
// only npm project in the repository. This stage owns the sequencing, not the
// bundling.
func BuildWeb(p Paths) error {
cmd := exec.Command("npm", "run", "build")
cmd.Dir = p.Web
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("vite build: %w", err)
return fmt.Errorf("web build: %w", err)
}
return nil
}
@@ -73,27 +72,20 @@ func Assemble(p Paths, datasets []registry.Dataset) error {
return err
}
// Base build: index.html, assets/, and the gzipped databases from publicDir.
// The prerendered pages, _app/ and the gzipped databases in one move.
if err := copyTree(p.Dist, p.Site); err != nil {
return err
}
// Unknown paths render the hub rather than the default Pages 404.
// Unknown paths render the hub rather than the default Pages 404. Asset URLs
// in that file are absolute, so it works at any depth.
if err := copyFile(index, filepath.Join(p.Site, "404.html")); err != nil {
return err
}
// One entry point per dataset.
for _, d := range datasets {
dir := filepath.Join(p.Site, d.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := copyFile(index, filepath.Join(dir, "index.html")); err != nil {
return err
}
if err := checkDatasetPages(p.Site, datasets); err != nil {
return err
}
if err := checkDatabasesPresent(p.Site, datasets); err != nil {
return err
}
@@ -109,6 +101,27 @@ func Assemble(p Paths, datasets []registry.Dataset) error {
return nil
}
// checkDatasetPages: every dataset must have prerendered an entry point.
//
// The pages come from the web build's entry generator reading the same
// registry, so a missing one means the two fell out of step — a dataset URL
// that 404s while the build stays green.
func checkDatasetPages(siteDir string, datasets []registry.Dataset) error {
var missing []string
for _, d := range datasets {
if _, err := os.Stat(filepath.Join(siteDir, d.ID, "index.html")); err != nil {
missing = append(missing, d.ID+"/index.html")
}
}
if len(missing) > 0 {
return fmt.Errorf(
"the web build prerendered no page for: %s\n"+
"Those URLs would 404. Check the entry generator in web/src/routes/[dataset]/+page.ts",
strings.Join(missing, ", "))
}
return nil
}
// checkDatabasesPresent: every dataset must have shipped its database.
//
// Without this the site assembles happily with an empty db/ directory — every
+26 -6
View File
@@ -11,14 +11,17 @@ import (
var datasets = []registry.Dataset{{ID: "2016"}, {ID: "2017"}}
// fakeBuild stands in for a Vite build: an index.html, an asset, and whatever
// databases the caller wants staged.
// fakeBuild stands in for a web build: the prerendered hub and one page per
// dataset, an asset, and whatever databases the caller wants staged.
func fakeBuild(t *testing.T, dbs ...string) Paths {
t.Helper()
root := t.TempDir()
dist := filepath.Join(root, "web", "dist")
write(t, filepath.Join(dist, "index.html"), "<html>app</html>")
write(t, filepath.Join(dist, "assets", "index.js"), "console.log(1)")
write(t, filepath.Join(dist, "index.html"), "<html>hub</html>")
for _, d := range datasets {
write(t, filepath.Join(dist, d.ID, "index.html"), "<html>"+d.ID+"</html>")
}
write(t, filepath.Join(dist, "_app", "immutable", "entry.js"), "console.log(1)")
for _, name := range dbs {
write(t, filepath.Join(dist, "db", name), "gzipped-bytes")
}
@@ -45,7 +48,7 @@ func TestAssembleProducesAPageForEveryDataset(t *testing.T) {
"404.html",
filepath.Join("2016", "index.html"),
filepath.Join("2017", "index.html"),
filepath.Join("assets", "index.js"),
filepath.Join("_app", "immutable", "entry.js"),
filepath.Join("db", "2016.db.gz"),
} {
if _, err := os.Stat(filepath.Join(p.Site, want)); err != nil {
@@ -54,6 +57,23 @@ func TestAssembleProducesAPageForEveryDataset(t *testing.T) {
}
}
// TestMissingDatasetPageFailsTheBuild: the pages come from the web build's
// entry generator, which reads the same registry this does. If the two fall out
// of step, that dataset's URL 404s — so the build stops instead.
func TestMissingDatasetPageFailsTheBuild(t *testing.T) {
p := fakeBuild(t, "2016.db.gz", "2017.db.gz")
if err := os.RemoveAll(filepath.Join(p.Dist, "2017")); err != nil {
t.Fatal(err)
}
err := Assemble(p, datasets)
if err == nil {
t.Fatal("expected an error when a dataset page was not prerendered")
}
if !strings.Contains(err.Error(), "2017") {
t.Errorf("the error should name the missing page, got: %v", err)
}
}
// TestMissingDatabaseFailsTheBuild is the guard that closes the widest hole.
//
// Without it the site assembles happily with an empty db/ directory: every page
@@ -115,7 +135,7 @@ func TestAssembleRejectsAMissingBuild(t *testing.T) {
root := t.TempDir()
p := Paths{Web: root, Dist: filepath.Join(root, "dist"), Site: filepath.Join(root, "_site")}
if err := Assemble(p, datasets); err == nil {
t.Fatal("expected an error when there is no Vite build")
t.Fatal("expected an error when there is no web build")
}
}