mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-10-17 06:15:15 +00:00
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:
1 parent
e2112fdb38
commit
3fd137a233
49 files changed
+4432
-3112
No files matched your search
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user