Build the release: one-file themes, stripped CSS and JS, string scripts, the sprite and a gate under 25 KiB
internal/build is the release as a function of the embedded tree. Dist reads LICENSE, css/, js/, icons/ and i18n/ and nothing else, and returns the files a site serves under /<version>/: fiesta.css, fiesta-plain.css and fiesta-signal.css, each the banner then tokens.css and the theme joined by a newline with their comments stripped; tokens.css, day.css, plain.css, signal.css and shell.css the same way one file at a time; every script with its comments stripped; i18n/<lang>.json as committed and i18n/<lang>.js, which adds the table to self.fiestaStrings; icons.svg, a sprite of one symbol per slug, and icons/<slug>.svg, the inline icon with its xmlns; LICENSE, NOTICE (the Lucide notice) and SHA256SUMS. The strippers are rastrillo's weight.go reader, verbatim, with its tests; check/'s plain span-removal stripCSSComments is left as it was inside the ported preservation tests, and consolidating the two is a follow-up. CheckSize is the gate: a one-file theme at 25 KiB gzipped or more fails it, naming the file and both figures. Today day is 23,018 bytes (22.5 KiB), plain 22,303 (21.8 KiB) and signal 22,414 (21.9 KiB). make size runs that one test verbosely so the figures are in every CI log, and ci is gofmt, no-rastrillo, size, suite, with .amadan/ci.d/30-size as its step. cmd/fiesta build -out DIR writes the dist under DIR/<version>/, after claiming that directory with dsgen's ownership check and a .fiesta stamp: absent is created, empty is adopted, stamped is emptied and reused, anything else is refused untouched. The version directory rather than DIR, so versions can sit side by side. The dist tests ran red under stubs first (Dist empty, CheckSize nil), eleven of eleven by name, with preconditions that keep the determinism, axe and icon tests from passing on an empty map, and each of those three shown red under a one-line mutation in a scratch copy: SHA256SUMS written in map order, axe.min.js copied into the dist, and a standalone icon left without xmlns. In the browser, every theme's matrix page under the source stylesheets paints exactly the pixels it paints under the one-file theme served alone and under the separate release files (samePixelsWithin at 0); the switch, scheme, select and calendar drives run against the stripped scripts on rigs of their own, since each puts its own document at /; and a page on localhost takes fiesta.css, the scripts, es.js and every icon from the release on 127.0.0.1, with the Spanish set label on screen and no error. Chromium reports naturalWidth 150 for the viewBox-only icons (the default object size), so the test also requires complete and a resolved decode(). Weights for the two new browser tests; shards 172/173/170s; make ci green.
Human
Prompt
Task 12: write the dist tests against stubs and watch them fail, with non-vacuous preconditions and a named scratch-copy mutation for the determinism, axe and icon tests; write the three release browser tests; implement internal/build, cmd/fiesta build with dsgen's ownership check renamed .fiesta, the size target and 30-size; run make size, the release browser tests and make ci once; commit.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
New: internal/build (build.go, strip.go and their tests), cmd/fiesta (main.go, claim.go, claim_test.go), check/release_browser_test.go, .amadan/ci.d/30-size. Modified: Makefile (size target, ci: gofmt no-rastrillo size suite), hack/test-weights.txt (two lines).
Agent notes
claim owns DIR/<version>, not DIR; the brief's wording allows either, and this one keeps other versions beside it. The CLI does not run CheckSize; make size is the gate. naturalWidth is 150 for every icon here, a load check only; decode() is the assertion to trust. Report: .superpowers/sdd/2026-10-27-fiesta-1-package/task-12-report.md in the fiesta-design worktree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
11 files changed,
+1876
−4
.amadan/ci.d/30-size+2 −0Makefile+12 −4check/release_browser_test.go+274 −0cmd/fiesta/claim.go+72 −0cmd/fiesta/claim_test.go+117 −0cmd/fiesta/main.go+108 −0hack/test-weights.txt+2 −0internal/build/build.go+221 −0internal/build/build_test.go+549 −0internal/build/strip.go+311 −0internal/build/strip_test.go+208 −0
CI passed — run details
CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok amadan.net/rastrillo/fiesta 0.091s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN TestTheOneLinkIsUnder25KiBGzipped
build_test.go:137: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
build_test.go:137: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
build_test.go:137: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok amadan.net/rastrillo/fiesta/internal/build 0.022s
=== suite ===
--- shard 0 of 8 ---
==> shard 0 of 8: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 go test -count=1 ./...
ok amadan.net/rastrillo/fiesta 0.351s
ok amadan.net/rastrillo/fiesta/check 3.860s
ok amadan.net/rastrillo/fiesta/cmd/fiesta 0.109s
ok amadan.net/rastrillo/fiesta/example 0.136s
ok amadan.net/rastrillo/fiesta/internal/axe 0.017s
ok amadan.net/rastrillo/fiesta/internal/build 0.572s
? amadan.net/rastrillo/fiesta/internal/harness [no test files]
ok amadan.net/rastrillo/fiesta/internal/markup 0.007s
ok amadan.net/rastrillo/fiesta/internal/nodetest 0.288s
--- shard 1 of 8 ---
==> shard 1 of 8: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 8 ---
==> shard 2 of 8: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
This scan also found 0 vulnerabilities in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
--- shard 3 of 8 ---
==> shard 3 of 8: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
[90m2:37PM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net
[90m2:37PM[0m [32mINF[0m [1m1 commits scanned.[0m
[90m2:37PM[0m [32mINF[0m [1mscanned ~4334013 bytes (4.33 MB) in 3.08s[0m
[90m2:37PM[0m [32mINF[0m [1mno leaks found[0m
--- shard 4 of 8 ---
==> shard 4 of 8: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok amadan.net/rastrillo/fiesta/internal/harness 8.630s
--- shard 5 of 8 ---
==> shard 5 of 8: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 92 of 276 tests
ok amadan.net/rastrillo/fiesta/check 186.372s
--- shard 6 of 8 ---
==> shard 6 of 8: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 92 of 276 tests
ok amadan.net/rastrillo/fiesta/check 189.511s
--- shard 7 of 8 ---
==> shard 7 of 8: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 92 of 276 tests
ok amadan.net/rastrillo/fiesta/check 189.208s
diff --git a/.amadan/ci.d/30-size b/.amadan/ci.d/30-size| new file mode 100755 |
| index 0000000..96d84ec |
| --- /dev/null |
| +++ b/.amadan/ci.d/30-size |
| @@ -0,0 +1,2 @@ |
| +#!/bin/sh |
| +exec make size |
diff --git a/Makefile b/Makefile| index b3655a4..54d2804 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -1,4 +1,4 @@ |
| -.PHONY: ci suite suite-roles gofmt no-rastrillo root staticcheck govulncheck gitleaks browser |
| +.PHONY: ci suite suite-roles gofmt no-rastrillo size root staticcheck govulncheck gitleaks browser |
| # CGO_ENABLED=0 across the tree is an acceptance criterion, not a |
| # preference: Fiesta is CSS, scripts and the Go that embeds and checks |
| @@ -35,8 +35,9 @@ export GOTMPDIR ?= $(BIN)/tmp |
| # one; it never keeps its own copy of a command. |
| # |
| # The cheap checks go first and alone, so a stray gofmt still costs |
| -# seconds. Everything else is the suite. |
| -ci: gofmt no-rastrillo suite |
| +# seconds, and the size gate next, so a bundle over the line is the |
| +# first thing a red run says. Everything else is the suite. |
| +ci: gofmt no-rastrillo size suite |
| # The suite's roles. Under amadan, .amadan/ci.d/50-suite@N runs N |
| # shards at once and each runs one role, so the run takes as long as |
| @@ -87,6 +88,13 @@ gofmt: |
| no-rastrillo: |
| go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$$' -count=1 . |
| +# size is the one-link promise: each one-file theme the release build |
| +# makes from the tree gzips under 25 KiB. One test, run alone and |
| +# verbosely so the three figures are in the log of every run; the |
| +# test's own package runs again inside root with the rest. |
| +size: |
| + go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$$' -count=1 -v |
| + |
| # FIESTA_TEST_REQUIRE_NODE turns every Node skip into a failure here: on |
| # a laptop without Node a skip is honest, but a gate that lost node |
| # would otherwise go green having checked none of the JavaScript. |
| @@ -174,7 +182,7 @@ export FULL_CHROMIUM := check |
| browser.%: | $(BIN)/tmp |
| ./hack/gotest.sh browser '$*' |
| -root no-rastrillo staticcheck govulncheck gitleaks: | $(BIN)/tmp |
| +root no-rastrillo size staticcheck govulncheck gitleaks: | $(BIN)/tmp |
| $(BIN)/tmp: |
| mkdir -p "$@" |
diff --git a/check/release_browser_test.go b/check/release_browser_test.go| new file mode 100644 |
| index 0000000..0e3a798 |
| --- /dev/null |
| +++ b/check/release_browser_test.go |
| @@ -0,0 +1,274 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/build" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The release is held to the tree it was built from, in a browser: |
| +// the one-file themes paint the same pixels as the source stylesheets, |
| +// the stripped scripts drive the same journeys, and a page on another |
| +// origin can use all of it from one directory. |
| + |
| +// releaseDist is build.Dist over the embedded tree at the shipped |
| +// version, once per test. |
| +func releaseDist(t *testing.T) map[string][]byte { |
| + t.Helper() |
| + d, err := build.Dist(fiesta.FS(), fiesta.Version) |
| + if err != nil { |
| + t.Fatalf("building the release: %v", err) |
| + } |
| + return d |
| +} |
| + |
| +// distScripts serves the release's scripts and i18n/*.js by the name |
| +// the fixture mux asks for, in place of the tree's, so a drive written |
| +// against the source runs against what ships. |
| +func distScripts(t *testing.T) func(name string) ([]byte, bool) { |
| + t.Helper() |
| + d := releaseDist(t) |
| + return func(name string) ([]byte, bool) { |
| + if !strings.HasSuffix(name, ".js") { |
| + return nil, false |
| + } |
| + b, ok := d[name] |
| + return b, ok |
| + } |
| +} |
| + |
| +// oneFile is the release's one-file theme for a theme. |
| +func oneFile(theme string) string { |
| + if theme == "day" { |
| + return "fiesta.css" |
| + } |
| + return "fiesta-" + theme + ".css" |
| +} |
| + |
| +// TestTheReleaseRendersAsTheSource: for every theme, the matrix page |
| +// under the source tokens.css and theme paints exactly the pixels it |
| +// paints under the release's one-file theme served alone, and again |
| +// under the release's separate tokens.css and theme. Exactly: the |
| +// strip removes comments and nothing else, so there is no corner |
| +// case to allow for, and samePixelsWithin's cap is 0. |
| +func TestTheReleaseRendersAsTheSource(t *testing.T) { |
| + d := releaseDist(t) |
| + for _, theme := range fiesta.ThemeNames() { |
| + oldTheme, ok := fiesta.ThemeCSS(theme) |
| + if !ok { |
| + t.Fatalf("no theme %s", theme) |
| + } |
| + for _, c := range []struct { |
| + name string |
| + tokens, theme []byte |
| + }{ |
| + {"one file", d[oneFile(theme)], []byte{}}, |
| + {"separate files", d["tokens.css"], d[theme+".css"]}, |
| + } { |
| + t.Run(theme+", "+c.name, func(t *testing.T) { |
| + if len(c.tokens) == 0 { |
| + t.Fatalf("the release has no stylesheet for %s", c.name) |
| + } |
| + rig := legacyRig(t, fiesta.TokensCSS(), oldTheme, c.tokens, c.theme, false) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| + defer cancel() |
| + for _, scheme := range []string{"light", "dark"} { |
| + old := matrixShots(t, ctx, rig.Origin, "/old/", scheme) |
| + now := matrixShots(t, ctx, rig.Origin, "/new/", scheme) |
| + if len(old) == 0 { |
| + t.Fatal("the matrix photographed nothing") |
| + } |
| + keys := make([]string, 0, len(old)) |
| + for key := range old { |
| + keys = append(keys, key) |
| + } |
| + sort.Strings(keys) |
| + for _, key := range keys { |
| + if ok, why := samePixelsWithin(t, old[key], now[key], 0); !ok { |
| + t.Errorf("%s renders differently from the release: %s", key, why) |
| + } |
| + } |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// TestTheReleaseScriptsDriveTheJourneys runs four drives, each on a |
| +// rig whose scripts are the release's: the switch drag, the |
| +// remembered scheme, the select filter and the calendar. Each drive's |
| +// pages put their own document at /, so each has a rig of its own. |
| +func TestTheReleaseScriptsDriveTheJourneys(t *testing.T) { |
| + scripts := distScripts(t) |
| + if b, ok := scripts("fiesta.js"); !ok || !bytes.HasPrefix(b, []byte(build.Banner(fiesta.Version))) { |
| + t.Fatal("distScripts does not serve the release's fiesta.js") |
| + } |
| + for _, drive := range []struct { |
| + name string |
| + pages map[string]string |
| + run func(*testing.T, *harness.Rig) |
| + }{ |
| + {"switch drag", switchDragPages, driveSwitchDrag}, |
| + {"scheme remember", schemePages(), driveSchemeRemember}, |
| + {"select filter", selectFilterPages(t), driveSelectFilter}, |
| + {"calendar open", calendarPages(t), driveCalendarOpen}, |
| + } { |
| + t.Run(drive.name, func(t *testing.T) { |
| + pages := drive.pages |
| + rig := harness.New(t, func(string) http.Handler { return sizingMuxWith(t, pages, scripts) }) |
| + drive.run(t, rig) |
| + }) |
| + } |
| +} |
| + |
| +// contentTypes is what the release server says each file is. |
| +var contentTypes = map[string]string{".css": "text/css", ".js": "text/javascript", ".svg": "image/svg+xml", ".json": "application/json"} |
| + |
| +// releaseServer serves the dist on 127.0.0.1, which is a different |
| +// origin from the rig's localhost, as a CDN or a site's static host |
| +// would be. |
| +func releaseServer(t *testing.T, d map[string][]byte) string { |
| + t.Helper() |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, ok := d[strings.TrimPrefix(r.URL.Path, "/")] |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + for ext, ct := range contentTypes { |
| + if strings.HasSuffix(r.URL.Path, ext) { |
| + w.Header().Set("Content-Type", ct) |
| + } |
| + } |
| + w.Write(b) |
| + })) |
| + t.Cleanup(srv.Close) |
| + if !strings.HasPrefix(srv.URL, "http://127.0.0.1:") { |
| + t.Fatalf("the release server is at %s, not on 127.0.0.1", srv.URL) |
| + } |
| + return srv.URL |
| +} |
| + |
| +// anotherOriginDoc is a Spanish page on the rig's origin that takes |
| +// everything from the release server: fiesta.css, the scripts the |
| +// date field needs, es.js, and every icon as an image. |
| +func anotherOriginDoc(files string, slugs []string) string { |
| + var icons strings.Builder |
| + for _, slug := range slugs { |
| + fmt.Fprintf(&icons, `<img id="icon-%s" src="%s/icons/%s.svg" alt="">`, slug, files, slug) |
| + } |
| + return `<!doctype html><html lang="es"><head><meta charset="utf-8"><title>release</title>` + |
| + `<link rel="stylesheet" href="` + files + `/fiesta.css">` + |
| + `<script defer src="` + files + `/fiesta.js"></script><script defer src="` + files + `/i18n/es.js"></script>` + |
| + `<script defer src="` + files + `/calendar.js"></script><script defer src="` + files + `/datetime.js"></script>` + |
| + `</head><body><div rst-page>` + |
| + `<p><button rst-btn id="btn">Guardar</button></p>` + |
| + `<div rst-field><label rst-field-label for="when">Fecha</label><input rst-input id="when" name="when" type="date" data-rst-date></div>` + |
| + `<p id="icons">` + icons.String() + `</p>` + |
| + `</div></body></html>` |
| +} |
| + |
| +// anotherOrigin is what the page reports: the button's computed |
| +// alignment and the root's accent token (the stylesheet, structure |
| +// and theme), the armed reading's set label (the strings), and each |
| +// icon's load. |
| +type anotherOrigin struct { |
| + AlignItems, Accent, Set string |
| + Icons []struct { |
| + Slug string |
| + Complete bool |
| + NaturalWidth, NaturalHeight int |
| + Decoded string |
| + } |
| +} |
| + |
| +// TestTheReleaseWorksFromAnotherOrigin: a page on localhost links |
| +// fiesta.css and loads fiesta.js, datetime.js, calendar.js and |
| +// i18n/es.js from the release on 127.0.0.1. The button is styled, the |
| +// date field's set label is es.json's, every standalone icon loads as |
| +// an image, and the page reports no error. |
| +func TestTheReleaseWorksFromAnotherOrigin(t *testing.T) { |
| + d := releaseDist(t) |
| + files := releaseServer(t, d) |
| + slugs := fiesta.IconSlugs() |
| + if len(slugs) == 0 { |
| + t.Fatal("no icons") |
| + } |
| + es, want, tomorrow := loadES(t) |
| + if !bytes.Contains(d["i18n/es.js"], es) { |
| + t.Fatal("the release's es.js does not carry es.json") |
| + } |
| + page := anotherOriginDoc(files, slugs) |
| + rig := harness.New(t, func(string) http.Handler { |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, page) |
| + }) |
| + return mux |
| + }) |
| + if !strings.HasPrefix(rig.Origin, "http://localhost:") { |
| + t.Fatalf("the page is at %s, not on localhost", rig.Origin) |
| + } |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + |
| + mustRun(t, ctx, |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-dtp] input[role="combobox"]`, chromedp.ByQuery), |
| + chromedp.Click(`[rst-dtp] input[role="combobox"]`, chromedp.ByQuery), |
| + chromedp.SendKeys(`[rst-dtp] input[role="combobox"]`, tomorrow, chromedp.ByQuery)) |
| + settleUntil(t, ctx, `!!document.querySelector('[rst-dtp] [role="option"].is-active [rst-dtp-set]')`) |
| + |
| + var got anotherOrigin |
| + at(t, ctx, `(async () => { |
| + const icons = []; |
| + for (const img of document.querySelectorAll("#icons img")) { |
| + let decoded = ""; |
| + try { await img.decode(); } catch (e) { decoded = e.name + ": " + e.message; } |
| + icons.push({Slug: img.id.replace(/^icon-/, ""), Complete: img.complete, NaturalWidth: img.naturalWidth, NaturalHeight: img.naturalHeight, Decoded: decoded}); |
| + } |
| + return JSON.stringify({ |
| + AlignItems: getComputedStyle(document.getElementById("btn")).alignItems, |
| + Accent: getComputedStyle(document.documentElement).getPropertyValue("--rst-accent").trim(), |
| + Set: document.querySelector('[rst-dtp] [role="option"].is-active [rst-dtp-set]')?.textContent ?? "", |
| + Icons: icons}); |
| + })()`, &got, awaitPromise) |
| + |
| + if got.AlignItems != "center" { |
| + t.Errorf("[rst-btn]'s computed align-items is %q, want center: fiesta.css did not apply across origins", got.AlignItems) |
| + } |
| + if got.Accent == "" { |
| + t.Error("--rst-accent is unset on the root: the theme half of fiesta.css did not apply") |
| + } |
| + if got.Set != want.Set { |
| + t.Errorf("the set label is %q, want es.json's %q", got.Set, want.Set) |
| + } |
| + if len(got.Icons) != len(slugs) { |
| + t.Fatalf("the page has %d icons, want %d", len(got.Icons), len(slugs)) |
| + } |
| + for _, icon := range got.Icons { |
| + t.Logf("icons/%s.svg: complete %v, natural %dx%d, decode %q", icon.Slug, icon.Complete, icon.NaturalWidth, icon.NaturalHeight, icon.Decoded) |
| + if !icon.Complete || icon.Decoded != "" { |
| + t.Errorf("icons/%s.svg did not load as an image: complete %v, decode %q", icon.Slug, icon.Complete, icon.Decoded) |
| + } |
| + if icon.NaturalWidth <= 0 { |
| + t.Errorf("icons/%s.svg has naturalWidth %d, want above 0", icon.Slug, icon.NaturalWidth) |
| + } |
| + } |
| + rig.Screen("body", "the release used from another origin") |
| +} |
diff --git a/cmd/fiesta/claim.go b/cmd/fiesta/claim.go| new file mode 100644 |
| index 0000000..49ecd24 |
| --- /dev/null |
| +++ b/cmd/fiesta/claim.go |
| @@ -0,0 +1,72 @@ |
| +package main |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| +) |
| + |
| +// The ownership check is rastrillo's dsgen's, with the stamp renamed. |
| +// That command once emptied its output root with no such check and, |
| +// pointed at the wrong directory, wiped 152 files that were not its |
| +// own; a version after it removed only the paths it was about to |
| +// write, which was safe and left a renamed file published for as long |
| +// as the build directory lived. The stamp is how it gets both. |
| + |
| +// stampName is the file fiesta build leaves in a directory to say the |
| +// directory is its own. Dotted so it stays out of the way of whatever |
| +// serves the tree. |
| +const stampName = ".fiesta" |
| + |
| +const stampBody = `This directory is written by fiesta build, which EMPTIES it on every |
| +run. Do not keep anything here. Delete this file and fiesta build will |
| +refuse the directory instead. |
| +` |
| + |
| +// claim makes root a directory fiesta build owns and empties it, or |
| +// fails without touching anything. |
| +// |
| +// Ownership is the whole of the safety story, and it is deliberately |
| +// conservative: an absent directory is created, an empty one is |
| +// adopted, one holding the stamp is emptied and reused, and anything |
| +// else is refused with an explanation. So the command can be pointed |
| +// at the wrong directory, and the wrong directory is almost never |
| +// empty. |
| +// |
| +// Emptying rather than removing named paths is the other half. |
| +// Removing only what the current build produces looks safer and is |
| +// not: a file renamed between versions leaves its old name behind, |
| +// published, linked, and invisible to a check that looks at which |
| +// files are present rather than which are not. The answer is that |
| +// there is never a second copy to drift: the output is the build, |
| +// every time. |
| +// |
| +// The stamp is written before the files, so a run interrupted halfway |
| +// leaves a directory the command still owns and will clean out on the |
| +// next run. It is not atomic: a failed run leaves a partial tree |
| +// behind, and the only thing saying so is the non-zero exit status. |
| +func claim(root string) error { |
| + entries, err := os.ReadDir(root) |
| + switch { |
| + case os.IsNotExist(err): |
| + if err := os.MkdirAll(root, 0o755); err != nil { |
| + return fmt.Errorf("creating %s: %w", root, err) |
| + } |
| + case err != nil: |
| + return fmt.Errorf("reading %s: %w", root, err) |
| + case len(entries) > 0: |
| + if _, err := os.Stat(filepath.Join(root, stampName)); err != nil { |
| + return fmt.Errorf("refusing to empty %s: it is not empty and holds no %s stamp, "+ |
| + "so fiesta build has not written it before. Empty it yourself, or point -out somewhere else", root, stampName) |
| + } |
| + for _, e := range entries { |
| + if err := os.RemoveAll(filepath.Join(root, e.Name())); err != nil { |
| + return fmt.Errorf("emptying %s: %w", root, err) |
| + } |
| + } |
| + } |
| + if err := os.WriteFile(filepath.Join(root, stampName), []byte(stampBody), 0o644); err != nil { |
| + return fmt.Errorf("writing %s: %w", filepath.Join(root, stampName), err) |
| + } |
| + return nil |
| +} |
diff --git a/cmd/fiesta/claim_test.go b/cmd/fiesta/claim_test.go| new file mode 100644 |
| index 0000000..d6f05e2 |
| --- /dev/null |
| +++ b/cmd/fiesta/claim_test.go |
| @@ -0,0 +1,117 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/build" |
| +) |
| + |
| +// fiestaBuild runs `fiesta build -out out` as main would, and returns |
| +// its error. |
| +func fiestaBuild(t *testing.T, out string) error { |
| + t.Helper() |
| + var stderr bytes.Buffer |
| + return run([]string{"build", "-out", out}, &stderr) |
| +} |
| + |
| +// TestBuildRefusesADirectoryItDoesNotOwn: the version directory under |
| +// -out is refused, untouched, when it holds anything and no stamp; |
| +// taken, emptied and rewritten when it holds the stamp; and created |
| +// when it is absent. What it writes is the dist, file for file. |
| +func TestBuildRefusesADirectoryItDoesNotOwn(t *testing.T) { |
| + out := t.TempDir() |
| + root := filepath.Join(out, fiesta.Version) |
| + stray := filepath.Join(root, "notes.txt") |
| + if err := os.MkdirAll(root, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(stray, []byte("mine"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + err := fiestaBuild(t, out) |
| + if err == nil { |
| + t.Fatalf("fiesta build took %s, which holds a file it did not write and no %s stamp", root, stampName) |
| + } |
| + if !strings.Contains(err.Error(), stampName) { |
| + t.Errorf("the refusal does not name the %s stamp: %v", stampName, err) |
| + } |
| + if b, err := os.ReadFile(stray); err != nil || string(b) != "mine" { |
| + t.Errorf("the refused directory was touched: %s is %q, %v", stray, b, err) |
| + } |
| + if entries, _ := os.ReadDir(root); len(entries) != 1 { |
| + t.Errorf("the refused directory holds %d entries, want the one stray file", len(entries)) |
| + } |
| + |
| + if err := os.WriteFile(filepath.Join(root, stampName), []byte("stamped by an earlier run"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fiestaBuild(t, out); err != nil { |
| + t.Fatalf("fiesta build refused a directory holding its own stamp: %v", err) |
| + } |
| + if _, err := os.Stat(stray); !os.IsNotExist(err) { |
| + t.Errorf("the stray file survived a build into a directory the command owns: %v", err) |
| + } |
| + if b, err := os.ReadFile(filepath.Join(root, stampName)); err != nil || string(b) != stampBody { |
| + t.Errorf("the stamp was not rewritten: %q, %v", b, err) |
| + } |
| + |
| + dist, err := build.Dist(fiesta.FS(), fiesta.Version) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(dist) == 0 { |
| + t.Fatal("the dist is empty") |
| + } |
| + for name, want := range dist { |
| + got, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name))) |
| + if err != nil { |
| + t.Errorf("the build did not write %s: %v", name, err) |
| + continue |
| + } |
| + if !bytes.Equal(got, want) { |
| + t.Errorf("%s on disk differs from the dist", name) |
| + } |
| + } |
| + var written int |
| + err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { |
| + if err == nil && !d.IsDir() && filepath.Base(p) != stampName { |
| + written++ |
| + } |
| + return err |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if written != len(dist) { |
| + t.Errorf("%s holds %d files besides the stamp, the dist has %d", root, written, len(dist)) |
| + } |
| + |
| + fresh := filepath.Join(t.TempDir(), "site", "fiesta") |
| + if err := fiestaBuild(t, fresh); err != nil { |
| + t.Fatalf("fiesta build could not create %s: %v", fresh, err) |
| + } |
| + if _, err := os.Stat(filepath.Join(fresh, fiesta.Version, "fiesta.css")); err != nil { |
| + t.Errorf("a build into an absent directory wrote no fiesta.css: %v", err) |
| + } |
| +} |
| + |
| +// TestBuildNeedsItsFlag: no -out is an error that says so, as is a |
| +// command the binary does not have. |
| +func TestBuildNeedsItsFlag(t *testing.T) { |
| + var stderr bytes.Buffer |
| + if err := run([]string{"build"}, &stderr); err == nil || !strings.Contains(err.Error(), "-out") { |
| + t.Errorf("build with no -out: %v", err) |
| + } |
| + if err := run([]string{"paint"}, &stderr); err == nil || !strings.Contains(err.Error(), "paint") { |
| + t.Errorf("an unknown command: %v", err) |
| + } |
| + if err := run(nil, &stderr); err == nil { |
| + t.Error("no command at all passed") |
| + } |
| +} |
diff --git a/cmd/fiesta/main.go b/cmd/fiesta/main.go| new file mode 100644 |
| index 0000000..f47cd11 |
| --- /dev/null |
| +++ b/cmd/fiesta/main.go |
| @@ -0,0 +1,108 @@ |
| +// Command fiesta is the design system's command line. Its one |
| +// subcommand, build, writes the release: |
| +// |
| +// fiesta build -out DIR |
| +// |
| +// puts the files under DIR/<version>/, where <version> is |
| +// fiesta.Version: the one-file themes, the separate stylesheets, the |
| +// stripped scripts, the strings, the icons, the licences and |
| +// SHA256SUMS, exactly what internal/build.Dist returns. A site mounts |
| +// that directory and a page links one file from it. |
| +// |
| +// It owns the version directory. It empties it before it writes, so |
| +// what is there afterwards is the release and only the release, and |
| +// it will only take a directory that is absent, empty, or marked |
| +// with its own stamp (.fiesta); anything else it refuses, changing |
| +// nothing. See claim. |
| +package main |
| + |
| +import ( |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/build" |
| +) |
| + |
| +const usage = "usage: fiesta build -out DIR" |
| + |
| +func main() { |
| + if err := run(os.Args[1:], os.Stderr); err != nil { |
| + fmt.Fprintln(os.Stderr, "fiesta:", err) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// run is main without the process: the arguments after the command |
| +// name, and where help goes. |
| +func run(args []string, stderr io.Writer) error { |
| + if len(args) == 0 { |
| + return errors.New(usage) |
| + } |
| + switch args[0] { |
| + case "build": |
| + return buildCmd(args[1:], stderr) |
| + default: |
| + return fmt.Errorf("unknown command %q\n%s", args[0], usage) |
| + } |
| +} |
| + |
| +// buildCmd is the build subcommand's flags, then write. |
| +func buildCmd(args []string, stderr io.Writer) error { |
| + flags := flag.NewFlagSet("build", flag.ContinueOnError) |
| + flags.SetOutput(io.Discard) |
| + out := flags.String("out", "", "directory to write the release into, under DIR/<version>/") |
| + if err := flags.Parse(args); err != nil { |
| + if errors.Is(err, flag.ErrHelp) { |
| + flags.SetOutput(stderr) |
| + fmt.Fprintln(stderr, usage) |
| + flags.PrintDefaults() |
| + return nil |
| + } |
| + return err |
| + } |
| + if flags.NArg() > 0 { |
| + return fmt.Errorf("unexpected argument %q\n%s", flags.Arg(0), usage) |
| + } |
| + if *out == "" { |
| + return errors.New("-out is required\n" + usage) |
| + } |
| + return write(*out) |
| +} |
| + |
| +// write builds the release and puts it under out/<version>/, a |
| +// directory it claims first. Separate from the flags so a test can |
| +// call it with a directory of its own. |
| +func write(out string) error { |
| + dist, err := build.Dist(fiesta.FS(), fiesta.Version) |
| + if err != nil { |
| + return err |
| + } |
| + root, err := filepath.Abs(filepath.Join(out, fiesta.Version)) |
| + if err != nil { |
| + return fmt.Errorf("resolving -out: %w", err) |
| + } |
| + if err := claim(root); err != nil { |
| + return err |
| + } |
| + names := make([]string, 0, len(dist)) |
| + for name := range dist { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + for _, name := range names { |
| + dest := filepath.Join(root, filepath.FromSlash(name)) |
| + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { |
| + return fmt.Errorf("mkdir for %s: %w", name, err) |
| + } |
| + if err := os.WriteFile(dest, dist[name], 0o644); err != nil { |
| + return fmt.Errorf("writing %s: %w", name, err) |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/hack/test-weights.txt b/hack/test-weights.txt| index ec1bc09..886a4c6 100644 |
| --- a/hack/test-weights.txt |
| +++ b/hack/test-weights.txt |
| @@ -9,6 +9,7 @@ |
| # Chromium, every test at 2s or more. |
| browser check TestEveryExampleStateMeetsTheFloors 58 |
| browser check TestEveryThemeRendersAsItDidExceptDay 45 |
| +browser check TestTheReleaseRendersAsTheSource 38 |
| browser check TestEveryThemeAndShellRendersAsTheBaseline 37 |
| browser check TestBusyButtonDrive 22 |
| browser check TestTheLightStaysWithinItsFrameBudget 17 |
| @@ -16,6 +17,7 @@ browser check TestTheProfileMenuIsADropUpAtTheRailsFoot 17 |
| browser check TestAnyAccentKeepsTheFillLabelReadable 13 |
| browser check TestFlatThemesFollowAnAppsOverrides 13 |
| browser check TestThePhoneIndexWorksWithScripts 10 |
| +browser check TestTheReleaseScriptsDriveTheJourneys 10 |
| browser check TestThePhoneIndexWorksWithNoScript 9 |
| browser check TestASwitchSlidesAndSnaps 8 |
| browser check TestADropdownOpensUnderItsOwnSummary 7 |
diff --git a/internal/build/build.go b/internal/build/build.go| new file mode 100644 |
| index 0000000..e034442 |
| --- /dev/null |
| +++ b/internal/build/build.go |
| @@ -0,0 +1,221 @@ |
| +// Package build makes the release: the files a site serves under |
| +// /<version>/ and a static page links from one tag. |
| +// |
| +// Dist is the whole of it, a pure function of the tree it is handed, |
| +// so the same tree at the same version is the same bytes and the same |
| +// SHA256SUMS on anyone's machine. cmd/fiesta writes what Dist returns. |
| +package build |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "io/fs" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +// Banner is the one comment the release keeps, at the top of every |
| +// stylesheet and script: the version, the licence and where to read |
| +// more. Its /*! opener is the form minifiers keep by convention. |
| +func Banner(version string) string { |
| + return "/*! Fiesta " + version + " | MPL-2.0 | https://fiesta.rastrillo.org */\n" |
| +} |
| + |
| +// OneLinkLimit is the most a one-file theme may weigh gzipped: 25 KiB. |
| +// It is what a static page that links fiesta.css alone pays, so it is |
| +// the figure the size gate holds the three bundles under. |
| +const OneLinkLimit = 25 * 1024 |
| + |
| +// Gzipped is the length of b compressed at gzip level 6, the level a |
| +// server or edge that compresses on the fly typically uses. |
| +func Gzipped(b []byte) int { return gzipped(b) } |
| + |
| +// defaultTheme is the theme fiesta.css carries; every other theme's |
| +// one-file form is fiesta-<theme>.css. |
| +const defaultTheme = "day" |
| + |
| +// svgNS is the namespace a standalone SVG document declares. The |
| +// inline icons leave it out, since HTML supplies it, and an <img> or |
| +// a CSS url() will not draw an SVG without it. |
| +const svgNS = "http://www.w3.org/2000/svg" |
| + |
| +// bundleName is the one-file theme's file name for a theme. |
| +func bundleName(theme string) string { |
| + if theme == defaultTheme { |
| + return "fiesta.css" |
| + } |
| + return "fiesta-" + theme + ".css" |
| +} |
| + |
| +// oneLink is the three one-file themes the size gate holds to the |
| +// limit: the bundles a static page links alone. |
| +var oneLink = []string{bundleName(defaultTheme), bundleName("plain"), bundleName("signal")} |
| + |
| +// Dist builds the release from src, which needs LICENSE, css/, js/, |
| +// icons/ and i18n/ and is read for nothing else, keyed by path |
| +// relative to the version directory: |
| +// |
| +// - fiesta.css, fiesta-<theme>.css: the banner, then tokens.css and |
| +// the theme joined by a newline, stripped of comments; |
| +// - tokens.css, <theme>.css, shell.css: the banner and the stripped |
| +// file; |
| +// - <script>.js: the banner and the stripped script; |
| +// - i18n/<lang>.json as committed, and i18n/<lang>.js, which adds |
| +// the table to self.fiestaStrings; |
| +// - icons.svg, a sprite of one <symbol id="rst-icon-<slug>"> per |
| +// icon, and icons/<slug>.svg, the inline icon with its xmlns; |
| +// - LICENSE, NOTICE (the icons' Lucide notice), and SHA256SUMS. |
| +func Dist(src fs.FS, version string) (map[string][]byte, error) { |
| + out := map[string][]byte{} |
| + banner := []byte(Banner(version)) |
| + read := func(path string) ([]byte, error) { |
| + b, err := fs.ReadFile(src, path) |
| + if err != nil { |
| + return nil, fmt.Errorf("the tree has no %s: %w", path, err) |
| + } |
| + return b, nil |
| + } |
| + list := func(dir, suffix string) ([]string, error) { |
| + entries, err := fs.ReadDir(src, dir) |
| + if err != nil { |
| + return nil, fmt.Errorf("the tree has no %s/: %w", dir, err) |
| + } |
| + var names []string |
| + for _, e := range entries { |
| + if !e.IsDir() && strings.HasSuffix(e.Name(), suffix) { |
| + names = append(names, strings.TrimSuffix(e.Name(), suffix)) |
| + } |
| + } |
| + if len(names) == 0 { |
| + return nil, fmt.Errorf("the tree's %s/ holds no %s file", dir, suffix) |
| + } |
| + sort.Strings(names) |
| + return names, nil |
| + } |
| + |
| + license, err := read("LICENSE") |
| + if err != nil { |
| + return nil, err |
| + } |
| + out["LICENSE"] = license |
| + notice, err := read("icons/LICENSE-lucide.txt") |
| + if err != nil { |
| + return nil, err |
| + } |
| + out["NOTICE"] = notice |
| + |
| + tokens, err := read("css/tokens.css") |
| + if err != nil { |
| + return nil, err |
| + } |
| + out["tokens.css"] = append(append([]byte{}, banner...), stripCSSComments(tokens)...) |
| + shell, err := read("css/shell.css") |
| + if err != nil { |
| + return nil, err |
| + } |
| + out["shell.css"] = append(append([]byte{}, banner...), stripCSSComments(shell)...) |
| + themes, err := list("css/themes", ".css") |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, theme := range themes { |
| + css, err := read("css/themes/" + theme + ".css") |
| + if err != nil { |
| + return nil, err |
| + } |
| + out[theme+".css"] = append(append([]byte{}, banner...), stripCSSComments(css)...) |
| + joined := append(append(append([]byte{}, tokens...), '\n'), css...) |
| + out[bundleName(theme)] = append(append([]byte{}, banner...), stripCSSComments(joined)...) |
| + } |
| + |
| + scripts, err := list("js", ".js") |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, name := range scripts { |
| + js, err := read("js/" + name + ".js") |
| + if err != nil { |
| + return nil, err |
| + } |
| + out[name+".js"] = append(append([]byte{}, banner...), stripJSComments(js)...) |
| + } |
| + |
| + langs, err := list("i18n", ".json") |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, lang := range langs { |
| + table, err := read("i18n/" + lang + ".json") |
| + if err != nil { |
| + return nil, err |
| + } |
| + out["i18n/"+lang+".json"] = table |
| + out["i18n/"+lang+".js"] = []byte(`(self.fiestaStrings = self.fiestaStrings || {})["` + lang + `"] = ` + string(table) + ";\n") |
| + } |
| + |
| + slugs, err := list("icons", ".svg") |
| + if err != nil { |
| + return nil, err |
| + } |
| + var sprite bytes.Buffer |
| + sprite.WriteString(`<svg xmlns="` + svgNS + `">` + "\n") |
| + for _, slug := range slugs { |
| + svg, err := read("icons/" + slug + ".svg") |
| + if err != nil { |
| + return nil, err |
| + } |
| + children, err := iconChildren(svg) |
| + if err != nil { |
| + return nil, fmt.Errorf("icons/%s.svg: %w", slug, err) |
| + } |
| + sprite.WriteString(`<symbol id="rst-icon-` + slug + `" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`) |
| + sprite.Write(children) |
| + sprite.WriteString("</symbol>\n") |
| + out["icons/"+slug+".svg"] = bytes.Replace(svg, []byte("<svg"), []byte(`<svg xmlns="`+svgNS+`"`), 1) |
| + } |
| + sprite.WriteString("</svg>\n") |
| + out["icons.svg"] = sprite.Bytes() |
| + |
| + names := make([]string, 0, len(out)) |
| + for name := range out { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + var sums bytes.Buffer |
| + for _, name := range names { |
| + sum := sha256.Sum256(out[name]) |
| + sums.WriteString(hex.EncodeToString(sum[:]) + " " + name + "\n") |
| + } |
| + out["SHA256SUMS"] = sums.Bytes() |
| + return out, nil |
| +} |
| + |
| +// iconChildren is what sits between an inline icon's <svg> root tags: |
| +// the paths the sprite's symbol wraps. |
| +func iconChildren(svg []byte) ([]byte, error) { |
| + s := bytes.TrimSpace(svg) |
| + open := bytes.IndexByte(s, '>') |
| + if !bytes.HasPrefix(s, []byte("<svg")) || open < 0 || !bytes.HasSuffix(s, []byte("</svg>")) { |
| + return nil, fmt.Errorf("not an <svg> element") |
| + } |
| + return bytes.TrimSpace(s[open+1 : len(s)-len("</svg>")]), nil |
| +} |
| + |
| +// CheckSize is the size gate: every one-file theme in dist gzips to |
| +// under OneLinkLimit, or the error names the first that does not and |
| +// gives both figures. A bundle the dist does not hold fails it too; |
| +// a gate that passed on absence would be no gate. |
| +func CheckSize(dist map[string][]byte) error { |
| + for _, name := range oneLink { |
| + b, ok := dist[name] |
| + if !ok { |
| + return fmt.Errorf("%s is not in the dist", name) |
| + } |
| + if g := Gzipped(b); g >= OneLinkLimit { |
| + return fmt.Errorf("%s is %.1f KiB gzipped; the one-link limit is %.1f KiB", name, float64(g)/1024, float64(OneLinkLimit)/1024) |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/build/build_test.go b/internal/build/build_test.go| new file mode 100644 |
| index 0000000..753f0e9 |
| --- /dev/null |
| +++ b/internal/build/build_test.go |
| @@ -0,0 +1,549 @@ |
| +package build |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "encoding/xml" |
| + "io/fs" |
| + "math/rand" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "testing/fstest" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/nodetest" |
| +) |
| + |
| +// The release is a function of the embedded tree, so every test here |
| +// but one runs Dist on a MapFS copied from that tree, which a test can |
| +// then alter or leave a file out of. The one exception is the size |
| +// gate, TestTheOneLinkIsUnder25KiBGzipped, whose claim is about the |
| +// real tree and nothing else. |
| + |
| +// bundles is each one-file theme and the theme it carries. |
| +var bundles = map[string]string{"fiesta.css": "day", "fiesta-plain.css": "plain", "fiesta-signal.css": "signal"} |
| + |
| +// bundleNames is bundles' keys, sorted, for a stable order of |
| +// failures and figures. |
| +func bundleNames() []string { |
| + names := make([]string, 0, len(bundles)) |
| + for n := range bundles { |
| + names = append(names, n) |
| + } |
| + sort.Strings(names) |
| + return names |
| +} |
| + |
| +// sourceFS is the embedded tree's build inputs as a MapFS: LICENSE, |
| +// css/, js/, icons/ and i18n/, byte for byte. |
| +func sourceFS(t *testing.T) fstest.MapFS { |
| + t.Helper() |
| + m := fstest.MapFS{} |
| + err := fs.WalkDir(fiesta.FS(), ".", func(p string, d fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + return nil |
| + } |
| + top, _, _ := strings.Cut(p, "/") |
| + switch top { |
| + case "LICENSE", "css", "js", "icons", "i18n": |
| + b, err := fs.ReadFile(fiesta.FS(), p) |
| + if err != nil { |
| + return err |
| + } |
| + m[p] = &fstest.MapFile{Data: b} |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(m) == 0 { |
| + t.Fatal("the embedded tree copied as nothing") |
| + } |
| + return m |
| +} |
| + |
| +// dist builds the release from src at the shipped version, failing |
| +// the test on an error. |
| +func dist(t *testing.T, src fs.FS) map[string][]byte { |
| + t.Helper() |
| + d, err := Dist(src, fiesta.Version) |
| + if err != nil { |
| + t.Fatalf("Dist: %v", err) |
| + } |
| + return d |
| +} |
| + |
| +// must is one file of a dist, failing the test when it is absent. |
| +func must(t *testing.T, d map[string][]byte, name string) []byte { |
| + t.Helper() |
| + b, ok := d[name] |
| + if !ok { |
| + t.Fatalf("the dist has no %s", name) |
| + } |
| + return b |
| +} |
| + |
| +// expectedFiles is every path the release holds, from the tree's own |
| +// lists: the licences, the sums, the sprite, the three one-file |
| +// themes, the separate stylesheets, every script, every language as |
| +// JSON and as a script, and every icon standalone. |
| +func expectedFiles() []string { |
| + names := []string{"LICENSE", "NOTICE", "SHA256SUMS", "icons.svg", "tokens.css", "shell.css"} |
| + for b, theme := range bundles { |
| + names = append(names, b, theme+".css") |
| + } |
| + names = append(names, fiesta.ScriptNames()...) |
| + for _, lang := range fiesta.Languages() { |
| + names = append(names, "i18n/"+lang+".json", "i18n/"+lang+".js") |
| + } |
| + for _, slug := range fiesta.IconSlugs() { |
| + names = append(names, "icons/"+slug+".svg") |
| + } |
| + sort.Strings(names) |
| + return names |
| +} |
| + |
| +// scriptsIn counts the dist's scripts outside i18n/, which is at |
| +// least the shipped scripts. |
| +func scriptsIn(d map[string][]byte) int { |
| + n := 0 |
| + for name := range d { |
| + if strings.HasSuffix(name, ".js") && !strings.HasPrefix(name, "i18n/") { |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| + |
| +// TestTheOneLinkIsUnder25KiBGzipped is the size gate, and `make size` |
| +// runs it alone: each one-file theme built from the real tree exists, |
| +// is not empty, and gzips under OneLinkLimit. The three figures are |
| +// logged so the gate's output says what the page costs today. |
| +func TestTheOneLinkIsUnder25KiBGzipped(t *testing.T) { |
| + d := dist(t, fiesta.FS()) |
| + for _, name := range bundleNames() { |
| + b := must(t, d, name) |
| + if len(b) == 0 { |
| + t.Fatalf("%s is empty", name) |
| + } |
| + g := Gzipped(b) |
| + t.Logf("%s: %d bytes, %d bytes gzipped (%.1f KiB of the %.1f KiB limit)", name, len(b), g, float64(g)/1024, float64(OneLinkLimit)/1024) |
| + } |
| + if err := CheckSize(d); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// incompressible is n bytes a fixed seed produced, which gzip cannot |
| +// shrink, trimmed from the front until the compressed length lands |
| +// on target exactly. |
| +func incompressible(t *testing.T, target int) []byte { |
| + t.Helper() |
| + r := rand.New(rand.NewSource(20261027)) |
| + buf := make([]byte, target+64) |
| + r.Read(buf) |
| + for n := target; n >= 0; n-- { |
| + if Gzipped(buf[:n]) == target { |
| + return buf[:n] |
| + } |
| + } |
| + t.Fatalf("no prefix of the random bytes gzips to exactly %d", target) |
| + return nil |
| +} |
| + |
| +// TestTheSizeGateFailsAtTheLine holds CheckSize to its line, one |
| +// bundle at a time: a dist whose three bundles gzip to 25,599 bytes |
| +// passes, and one with any bundle at 25,600 fails, naming the file. |
| +func TestTheSizeGateFailsAtTheLine(t *testing.T) { |
| + under := incompressible(t, OneLinkLimit-1) |
| + over := incompressible(t, OneLinkLimit) |
| + if g := Gzipped(under); g != OneLinkLimit-1 { |
| + t.Fatalf("the under bundle gzips to %d, want %d", g, OneLinkLimit-1) |
| + } |
| + if g := Gzipped(over); g != OneLinkLimit { |
| + t.Fatalf("the over bundle gzips to %d, want %d", g, OneLinkLimit) |
| + } |
| + for _, name := range bundleNames() { |
| + d := map[string][]byte{} |
| + for _, n := range bundleNames() { |
| + d[n] = under |
| + } |
| + if err := CheckSize(d); err != nil { |
| + t.Errorf("%s at %d gzipped bytes failed the gate: %v", name, OneLinkLimit-1, err) |
| + } |
| + d[name] = over |
| + err := CheckSize(d) |
| + if err == nil { |
| + t.Errorf("%s at %d gzipped bytes passed the gate; the line is %d", name, OneLinkLimit, OneLinkLimit) |
| + continue |
| + } |
| + if !strings.Contains(err.Error(), name) { |
| + t.Errorf("the gate's error does not name %s: %v", name, err) |
| + } |
| + if !strings.Contains(err.Error(), "25.0 KiB") { |
| + t.Errorf("the gate's error does not give its figures in KiB to one decimal: %v", err) |
| + } |
| + } |
| +} |
| + |
| +// TestTheBundlesAreTokensThenThemeStripped: each one-file theme is the |
| +// banner, then tokens.css and the theme joined by a newline and |
| +// stripped of comments, and nothing else. The separate files are the |
| +// banner and the stripped file. |
| +func TestTheBundlesAreTokensThenThemeStripped(t *testing.T) { |
| + src := sourceFS(t) |
| + d := dist(t, src) |
| + tokens, err := fs.ReadFile(src, "css/tokens.css") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range bundleNames() { |
| + theme, err := fs.ReadFile(src, "css/themes/"+bundles[name]+".css") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + joined := append(append(append([]byte{}, tokens...), '\n'), theme...) |
| + want := append([]byte(Banner(fiesta.Version)), stripCSSComments(joined)...) |
| + if got := must(t, d, name); !bytes.Equal(got, want) { |
| + t.Errorf("%s is not banner + strip(tokens.css + \"\\n\" + %s.css): %d bytes, want %d", name, bundles[name], len(got), len(want)) |
| + } |
| + want = append([]byte(Banner(fiesta.Version)), stripCSSComments(theme)...) |
| + if got := must(t, d, bundles[name]+".css"); !bytes.Equal(got, want) { |
| + t.Errorf("%s.css is not banner + strip(%s.css)", bundles[name], bundles[name]) |
| + } |
| + } |
| + for _, name := range []string{"tokens.css", "shell.css"} { |
| + in, err := fs.ReadFile(src, "css/"+name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := append([]byte(Banner(fiesta.Version)), stripCSSComments(in)...) |
| + if got := must(t, d, name); !bytes.Equal(got, want) { |
| + t.Errorf("%s is not banner + strip(css/%s)", name, name) |
| + } |
| + } |
| + for _, name := range fiesta.ScriptNames() { |
| + in, err := fs.ReadFile(src, "js/"+name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := append([]byte(Banner(fiesta.Version)), stripJSComments(in)...) |
| + if got := must(t, d, name); !bytes.Equal(got, want) { |
| + t.Errorf("%s is not banner + stripJS(js/%s)", name, name) |
| + } |
| + } |
| +} |
| + |
| +// TestDistNeedsOnlyItsFS: Dist reads src and nothing else, so a src |
| +// without LICENSE is an error that says so, not a dist that found |
| +// the file somewhere else. |
| +func TestDistNeedsOnlyItsFS(t *testing.T) { |
| + src := sourceFS(t) |
| + if _, ok := src["LICENSE"]; !ok { |
| + t.Fatal("the source has no LICENSE to leave out") |
| + } |
| + delete(src, "LICENSE") |
| + _, err := Dist(src, fiesta.Version) |
| + if err == nil { |
| + t.Fatal("Dist built a release from a tree with no LICENSE") |
| + } |
| + if !strings.Contains(err.Error(), "LICENSE") { |
| + t.Errorf("the error does not name LICENSE: %v", err) |
| + } |
| +} |
| + |
| +// parseJS is the Node side of TestTheDistHasNoComments: each source |
| +// compiled, and the error for one that does not parse. |
| +const parseJS = ` |
| +const vm = require("vm"); |
| +const srcs = JSON.parse(require("fs").readFileSync(0, "utf8")); |
| +process.stdout.write(JSON.stringify(srcs.map(s => { try { new vm.Script(s); return ""; } catch (e) { return e.name + ": " + e.message; } }))); |
| +` |
| + |
| +// TestTheDistHasNoComments: past the banner, no stylesheet holds a |
| +// comment opener, and every script, i18n/ included, still parses. |
| +func TestTheDistHasNoComments(t *testing.T) { |
| + d := dist(t, sourceFS(t)) |
| + banner := Banner(fiesta.Version) |
| + var names []string |
| + for name := range d { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + stylesheets, scripts := 0, 0 |
| + var srcs []string |
| + var scriptNames []string |
| + for _, name := range names { |
| + switch { |
| + case strings.HasSuffix(name, ".css"): |
| + stylesheets++ |
| + body := d[name] |
| + if !bytes.HasPrefix(body, []byte(banner)) { |
| + t.Errorf("%s does not open with the banner", name) |
| + continue |
| + } |
| + if bytes.Contains(body[len(banner):], []byte("/*")) { |
| + t.Errorf("%s still holds a comment after the banner", name) |
| + } |
| + case strings.HasSuffix(name, ".js"): |
| + scripts++ |
| + if !bytes.HasPrefix(d[name], []byte(banner)) && !strings.HasPrefix(name, "i18n/") { |
| + t.Errorf("%s does not open with the banner", name) |
| + } |
| + srcs = append(srcs, string(d[name])) |
| + scriptNames = append(scriptNames, name) |
| + } |
| + } |
| + if stylesheets == 0 { |
| + t.Fatal("the dist has no stylesheet") |
| + } |
| + if scriptsIn(d) < len(fiesta.ScriptNames()) { |
| + t.Fatalf("the dist has %d scripts, fewer than the %d shipped", scriptsIn(d), len(fiesta.ScriptNames())) |
| + } |
| + in, err := json.Marshal(srcs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var errs []string |
| + if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", parseJS}, Stdin: in}), &errs); err != nil { |
| + t.Fatal(err) |
| + } |
| + for i, e := range errs { |
| + if e != "" { |
| + t.Errorf("%s does not parse stripped: %s", scriptNames[i], e) |
| + } |
| + } |
| +} |
| + |
| +// TestTheSumsDescribeTheDist: SHA256SUMS is one line per file but |
| +// itself, sorted by path, each the file's sha256 in the two-space |
| +// form sha256sum -c reads. |
| +func TestTheSumsDescribeTheDist(t *testing.T) { |
| + d := dist(t, sourceFS(t)) |
| + sums := string(must(t, d, "SHA256SUMS")) |
| + var names []string |
| + for name := range d { |
| + if name != "SHA256SUMS" { |
| + names = append(names, name) |
| + } |
| + } |
| + sort.Strings(names) |
| + var want []string |
| + for _, name := range names { |
| + sum := sha256.Sum256(d[name]) |
| + want = append(want, hex.EncodeToString(sum[:])+" "+name) |
| + } |
| + if len(want) < 2 { |
| + t.Fatalf("the dist has %d files besides the sums", len(want)) |
| + } |
| + got := strings.Split(strings.TrimSuffix(sums, "\n"), "\n") |
| + if strings.Join(got, "\n") != strings.Join(want, "\n") { |
| + t.Errorf("SHA256SUMS is not one sorted line per file:\n got:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) |
| + } |
| +} |
| + |
| +// sprite is icons.svg as encoding/xml reads it. |
| +type sprite struct { |
| + XMLName xml.Name `xml:"svg"` |
| + Symbols []struct { |
| + ID string `xml:"id,attr"` |
| + ViewBox string `xml:"viewBox,attr"` |
| + Fill string `xml:"fill,attr"` |
| + Stroke string `xml:"stroke,attr"` |
| + Width string `xml:"stroke-width,attr"` |
| + Linecap string `xml:"stroke-linecap,attr"` |
| + Linejoin string `xml:"stroke-linejoin,attr"` |
| + Inner string `xml:",innerxml"` |
| + } `xml:"symbol"` |
| +} |
| + |
| +// iconInner is what sits between an inline icon's root tags, read |
| +// here on its own so the sprite is checked against the icon, not |
| +// against the build's reading of it. |
| +func iconInner(t *testing.T, svg []byte) string { |
| + t.Helper() |
| + s := strings.TrimSpace(string(svg)) |
| + open := strings.IndexByte(s, '>') |
| + if !strings.HasPrefix(s, "<svg") || open < 0 || !strings.HasSuffix(s, "</svg>") { |
| + t.Fatalf("not an inline icon: %q", s) |
| + } |
| + return strings.TrimSpace(s[open+1 : len(s)-len("</svg>")]) |
| +} |
| + |
| +// TestTheSpriteHasEverySlug: icons.svg is one SVG document in the SVG |
| +// namespace holding a symbol per slug, each with the icon's own |
| +// viewBox and stroke attributes and the icon's children inside. |
| +func TestTheSpriteHasEverySlug(t *testing.T) { |
| + src := sourceFS(t) |
| + d := dist(t, src) |
| + var s sprite |
| + if err := xml.Unmarshal(must(t, d, "icons.svg"), &s); err != nil { |
| + t.Fatalf("icons.svg does not parse: %v", err) |
| + } |
| + if s.XMLName.Space != svgNS { |
| + t.Errorf("icons.svg's root is in namespace %q, want %q", s.XMLName.Space, svgNS) |
| + } |
| + slugs := fiesta.IconSlugs() |
| + if len(slugs) == 0 { |
| + t.Fatal("the tree has no icons") |
| + } |
| + byID := map[string]int{} |
| + for i, sym := range s.Symbols { |
| + byID[sym.ID] = i |
| + } |
| + if len(s.Symbols) != len(slugs) { |
| + t.Errorf("icons.svg holds %d symbols, want one per slug: %d", len(s.Symbols), len(slugs)) |
| + } |
| + for _, slug := range slugs { |
| + i, ok := byID["rst-icon-"+slug] |
| + if !ok { |
| + t.Errorf("icons.svg has no symbol rst-icon-%s", slug) |
| + continue |
| + } |
| + sym := s.Symbols[i] |
| + if sym.ViewBox != "0 0 24 24" || sym.Fill != "none" || sym.Stroke != "currentColor" || sym.Width != "2" || sym.Linecap != "round" || sym.Linejoin != "round" { |
| + t.Errorf("rst-icon-%s's attributes are %+v", slug, sym) |
| + } |
| + icon, err := fs.ReadFile(src, "icons/"+slug+".svg") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := strings.TrimSpace(sym.Inner), iconInner(t, icon); got != want { |
| + t.Errorf("rst-icon-%s wraps %q, want the icon's children %q", slug, got, want) |
| + } |
| + } |
| +} |
| + |
| +// TestStandaloneIconsAreSVGDocuments: every icons/<slug>.svg in the |
| +// dist, one per shipped slug, parses as XML with a root in the SVG |
| +// namespace, which is what makes it an image a browser will draw from |
| +// an <img> or a CSS url(), where the inline form is not. |
| +func TestStandaloneIconsAreSVGDocuments(t *testing.T) { |
| + d := dist(t, sourceFS(t)) |
| + slugs := fiesta.IconSlugs() |
| + if len(slugs) == 0 { |
| + t.Fatal("the tree has no icons") |
| + } |
| + n := 0 |
| + for name := range d { |
| + if strings.HasPrefix(name, "icons/") && strings.HasSuffix(name, ".svg") { |
| + n++ |
| + } |
| + } |
| + if n != len(slugs) { |
| + t.Fatalf("the dist has %d standalone icons, want one per slug: %d", n, len(slugs)) |
| + } |
| + for _, slug := range slugs { |
| + var root struct { |
| + XMLName xml.Name |
| + } |
| + b := must(t, d, "icons/"+slug+".svg") |
| + if err := xml.Unmarshal(b, &root); err != nil { |
| + t.Errorf("icons/%s.svg does not parse: %v", slug, err) |
| + continue |
| + } |
| + if root.XMLName.Local != "svg" || root.XMLName.Space != svgNS { |
| + t.Errorf("icons/%s.svg's root is <%s> in namespace %q, want <svg> in %q", slug, root.XMLName.Local, root.XMLName.Space, svgNS) |
| + } |
| + } |
| +} |
| + |
| +// stringsJS is the Node side of TestTheStringsScriptIsTheJSON: the |
| +// scripts run in one context whose self is the global, as a page's |
| +// is, and each table is compared to the JSON it came from. The table |
| +// is round-tripped through JSON first, since an object made in the |
| +// vm's realm has that realm's prototypes and deepStrictEqual would |
| +// call it a different object on that alone. |
| +const stringsJS = ` |
| +const vm = require("vm"); |
| +const assert = require("assert"); |
| +const {scripts, tables} = JSON.parse(require("fs").readFileSync(0, "utf8")); |
| +const ctx = {}; |
| +ctx.self = ctx; |
| +for (const s of scripts) vm.runInNewContext(s, ctx); |
| +const out = {}; |
| +for (const [lang, json] of Object.entries(tables)) { |
| + try { assert.deepStrictEqual(JSON.parse(JSON.stringify(ctx.fiestaStrings[lang])), JSON.parse(json)); out[lang] = ""; } |
| + catch (e) { out[lang] = e.message; } |
| +} |
| +process.stdout.write(JSON.stringify(out)); |
| +` |
| + |
| +// TestTheStringsScriptIsTheJSON: i18n/es.js, run in Node, leaves |
| +// self.fiestaStrings.es deep-equal to es.json; and a second language's |
| +// script run after it adds to the registry instead of replacing it. |
| +func TestTheStringsScriptIsTheJSON(t *testing.T) { |
| + d := dist(t, sourceFS(t)) |
| + in, err := json.Marshal(map[string]any{ |
| + "scripts": []string{string(must(t, d, "i18n/es.js")), string(must(t, d, "i18n/en.js"))}, |
| + "tables": map[string]string{"es": string(must(t, d, "i18n/es.json")), "en": string(must(t, d, "i18n/en.json"))}, |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got map[string]string |
| + if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", stringsJS}, Stdin: in}), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, lang := range []string{"es", "en"} { |
| + if got[lang] != "" { |
| + t.Errorf("self.fiestaStrings.%s is not %s.json: %s", lang, lang, got[lang]) |
| + } |
| + } |
| + es, src := must(t, d, "i18n/es.json"), string(must(t, d, "i18n/es.js")) |
| + if want := `(self.fiestaStrings = self.fiestaStrings || {})["es"] = ` + string(es) + ";\n"; src != want { |
| + t.Errorf("i18n/es.js is not the one assignment:\n%s", src) |
| + } |
| +} |
| + |
| +// TestVendoredAxeIsNotAShippedAsset is rastrillo's containment gate |
| +// (ui/axe_test.go at SEED) over the release instead of the accessors: |
| +// the vendored axe-core is test data, and no file of the dist carries |
| +// its banner. The script count is the precondition that keeps an empty |
| +// dist from passing. |
| +func TestVendoredAxeIsNotAShippedAsset(t *testing.T) { |
| + // The banner is present in every build of axe-core and appears in |
| + // no hand-written asset, so it is the marker to hunt for. |
| + const marker = "Deque Systems" |
| + d := dist(t, sourceFS(t)) |
| + if n := scriptsIn(d); n < len(fiesta.ScriptNames()) { |
| + t.Fatalf("the dist has %d scripts, fewer than the %d shipped", n, len(fiesta.ScriptNames())) |
| + } |
| + for name, body := range d { |
| + if bytes.Contains(body, []byte(marker)) { |
| + t.Errorf("%s carries the vendored axe-core: it is test data and must not ship", name) |
| + } |
| + } |
| +} |
| + |
| +// TestTheDistIsDeterministic: two builds of the same tree are the same |
| +// bytes under the same names, and the names are the expected set. A |
| +// release that differed between runs could not be checked against its |
| +// own SHA256SUMS by anyone else. |
| +func TestTheDistIsDeterministic(t *testing.T) { |
| + src := sourceFS(t) |
| + a, b := dist(t, src), dist(t, src) |
| + var names []string |
| + for name := range a { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + if want := expectedFiles(); strings.Join(names, "\n") != strings.Join(want, "\n") { |
| + t.Fatalf("the dist's files are not the expected set:\n got: %s\nwant: %s", strings.Join(names, " "), strings.Join(want, " ")) |
| + } |
| + if len(b) != len(a) { |
| + t.Fatalf("the second build has %d files, the first %d", len(b), len(a)) |
| + } |
| + for _, name := range names { |
| + if !bytes.Equal(a[name], b[name]) { |
| + t.Errorf("%s differs between two builds of the same tree", name) |
| + } |
| + } |
| +} |
diff --git a/internal/build/strip.go b/internal/build/strip.go| new file mode 100644 |
| index 0000000..d65ee6e |
| --- /dev/null |
| +++ b/internal/build/strip.go |
| @@ -0,0 +1,311 @@ |
| +package build |
| + |
| +import ( |
| + "bytes" |
| + "compress/gzip" |
| + "strings" |
| +) |
| + |
| +// The comment strippers the release build runs over every stylesheet |
| +// and script, and the compressor the size gate measures with. Both |
| +// come from rastrillo's internal/designsystem/weight.go, where they |
| +// computed the figures on its Getting started page; here what they |
| +// produce is what ships, so the stripped output is held to being the |
| +// same stylesheet and the same script, not only a smaller one. |
| + |
| +// gzipLevel is the level every figure is compressed at: 6, gzip's own |
| +// default and the level a server or edge that compresses on the fly |
| +// typically uses. A fixed level, so the figure does not depend on |
| +// whatever DefaultCompression happens to mean in a given Go release. |
| +const gzipLevel = 6 |
| + |
| +// gzipped is the compressed length of b at gzipLevel. |
| +func gzipped(b []byte) int { |
| + var buf bytes.Buffer |
| + zw, err := gzip.NewWriterLevel(&buf, gzipLevel) |
| + if err != nil { |
| + // Only an out-of-range level errors, and gzipLevel is a constant |
| + // inside the range. |
| + panic("build: " + err.Error()) |
| + } |
| + zw.Write(b) |
| + zw.Close() |
| + return buf.Len() |
| +} |
| + |
| +// stripCSSComments removes every /* */ comment outside a string. A |
| +// comment on a line of its own takes the line with it, so the file is |
| +// one with no comments, not one full of blank lines. |
| +func stripCSSComments(src []byte) []byte { |
| + var out bytes.Buffer |
| + for i := 0; i < len(src); { |
| + c := src[i] |
| + switch { |
| + case c == '"' || c == '\'': |
| + j := endOfQuoted(src, i) |
| + out.Write(src[i:j]) |
| + i = j |
| + case c == '/' && i+1 < len(src) && src[i+1] == '*': |
| + i = dropComment(&out, src, i, blockCommentEnd(src, i)) |
| + default: |
| + out.WriteByte(c) |
| + i++ |
| + } |
| + } |
| + return out.Bytes() |
| +} |
| + |
| +// stripJSComments removes every // and /* */ comment from a script while |
| +// leaving everything that only looks like one alone: a // inside a |
| +// string, a /* inside a regular expression, a comment-shaped run inside |
| +// a template literal and the code inside that literal's ${}. That is |
| +// three lexical states plus the one ambiguity JavaScript is famous for, |
| +// whether a / starts a regular expression or divides; see regexAllowed. |
| +// |
| +// The result is served, so it is held to being a script a browser |
| +// parses to the same program: TestStrippedScriptsEvaluateAsWritten and |
| +// TestEveryShippedScriptStripsToTheSameShape run both halves in Node. |
| +func stripJSComments(src []byte) []byte { |
| + var out bytes.Buffer |
| + // braces is one entry per open { since the innermost template |
| + // literal's ${: when it reaches zero on a }, that } closes the ${ |
| + // and the scan is back inside the literal. |
| + var braces []int |
| + // parens is one entry per open (, true where it opened the head of |
| + // an if, while, for or with. A / after the ) closing one of those |
| + // starts the statement it governs, so it is a regex: if (x) /re/. |
| + // After any other ) it divides: (a + b) / 2. ctlEnd is the length |
| + // of out just past the last ) that closed such a head. |
| + var parens []bool |
| + ctlEnd := -1 |
| + for i := 0; i < len(src); { |
| + c := src[i] |
| + switch { |
| + case c == '(': |
| + parens = append(parens, controlHead(out.Bytes())) |
| + out.WriteByte(c) |
| + i++ |
| + case c == ')': |
| + head := false |
| + if n := len(parens); n > 0 { |
| + head, parens = parens[n-1], parens[:n-1] |
| + } |
| + out.WriteByte(c) |
| + i++ |
| + if head { |
| + ctlEnd = out.Len() |
| + } |
| + case c == '"' || c == '\'': |
| + j := endOfQuoted(src, i) |
| + out.Write(src[i:j]) |
| + i = j |
| + case c == '`': |
| + j, opened := endOfTemplate(src, i+1) |
| + out.Write(src[i:j]) |
| + i = j |
| + if opened { |
| + braces = append(braces, 0) |
| + } |
| + case c == '{' && len(braces) > 0: |
| + braces[len(braces)-1]++ |
| + out.WriteByte(c) |
| + i++ |
| + case c == '}' && len(braces) > 0 && braces[len(braces)-1] == 0: |
| + braces = braces[:len(braces)-1] |
| + j, opened := endOfTemplate(src, i+1) |
| + out.Write(src[i:j]) |
| + i = j |
| + if opened { |
| + braces = append(braces, 0) |
| + } |
| + case c == '}' && len(braces) > 0: |
| + braces[len(braces)-1]-- |
| + out.WriteByte(c) |
| + i++ |
| + case c == '/' && i+1 < len(src) && src[i+1] == '/': |
| + end := i + 2 |
| + for end < len(src) && src[end] != '\n' { |
| + end++ |
| + } |
| + i = dropComment(&out, src, i, end) |
| + case c == '/' && i+1 < len(src) && src[i+1] == '*': |
| + i = dropComment(&out, src, i, blockCommentEnd(src, i)) |
| + case c == '/' && regexAllowed(out.Bytes(), ctlEnd): |
| + j := endOfRegex(src, i) |
| + out.Write(src[i:j]) |
| + i = j |
| + default: |
| + out.WriteByte(c) |
| + i++ |
| + } |
| + } |
| + return out.Bytes() |
| +} |
| + |
| +// dropComment skips src[start:end], a comment, and returns where the |
| +// scan resumes. A comment alone on its line takes the line with it. A |
| +// block comment spanning lines inside code leaves a newline behind |
| +// rather than nothing: JavaScript's automatic semicolons read line |
| +// breaks, and a comment that held two statements apart must still. One |
| +// inside a line leaves a space unless a space is already beside it: a |
| +// comment separates tokens, and with nothing in its place a+/**/++b |
| +// would read a++ +b, a different sum that parses just as well. |
| +func dropComment(out *bytes.Buffer, src []byte, start, end int) int { |
| + b := out.Bytes() |
| + lineStart := bytes.LastIndexByte(b, '\n') + 1 |
| + aloneBefore := len(bytes.TrimLeft(b[lineStart:], " \t")) == 0 |
| + rest := end |
| + for rest < len(src) && (src[rest] == ' ' || src[rest] == '\t' || src[rest] == '\r') { |
| + rest++ |
| + } |
| + aloneAfter := rest == len(src) || src[rest] == '\n' |
| + if aloneBefore && aloneAfter { |
| + out.Truncate(lineStart) |
| + if rest < len(src) { |
| + rest++ // the line's own newline |
| + } |
| + return rest |
| + } |
| + if bytes.IndexByte(src[start:end], '\n') >= 0 { |
| + out.WriteByte('\n') |
| + } else if end < len(src) && !isSpaceByte(src[end]) && len(b) > 0 && !isSpaceByte(b[len(b)-1]) { |
| + out.WriteByte(' ') |
| + } |
| + return end |
| +} |
| + |
| +// blockCommentEnd is the index just past the */ closing the comment |
| +// that starts at i, or the end of src for one that never closes. |
| +func blockCommentEnd(src []byte, i int) int { |
| + if j := bytes.Index(src[i+2:], []byte("*/")); j >= 0 { |
| + return i + 2 + j + 2 |
| + } |
| + return len(src) |
| +} |
| + |
| +// endOfQuoted is the index just past the string literal opening at i, |
| +// honouring backslash escapes. |
| +func endOfQuoted(src []byte, i int) int { |
| + q := src[i] |
| + for j := i + 1; j < len(src); j++ { |
| + switch src[j] { |
| + case '\\': |
| + j++ |
| + case q: |
| + return j + 1 |
| + } |
| + } |
| + return len(src) |
| +} |
| + |
| +// endOfTemplate scans template-literal text from i and returns the |
| +// index just past where it stops: past the closing backtick, or past a |
| +// ${, in which case opened is true and code follows. |
| +func endOfTemplate(src []byte, i int) (int, bool) { |
| + for j := i; j < len(src); j++ { |
| + switch src[j] { |
| + case '\\': |
| + j++ |
| + case '`': |
| + return j + 1, false |
| + case '$': |
| + if j+1 < len(src) && src[j+1] == '{' { |
| + return j + 2, true |
| + } |
| + } |
| + } |
| + return len(src), false |
| +} |
| + |
| +// endOfRegex is the index just past the regular-expression literal |
| +// opening at i, flags included. A / inside a [class] does not close it. |
| +func endOfRegex(src []byte, i int) int { |
| + inClass := false |
| + for j := i + 1; j < len(src); j++ { |
| + switch c := src[j]; { |
| + case c == '\\': |
| + j++ |
| + case c == '\n': |
| + return j // not a regex after all; give the line back |
| + case c == '[': |
| + inClass = true |
| + case c == ']': |
| + inClass = false |
| + case c == '/' && !inClass: |
| + j++ |
| + for j < len(src) && isWordByte(src[j]) { |
| + j++ |
| + } |
| + return j |
| + } |
| + } |
| + return len(src) |
| +} |
| + |
| +// regexAllowed says whether a / after the code written so far starts a |
| +// regular expression rather than dividing. The rule is the usual one: a |
| +// regex can follow an operator, an opening bracket, a comma or a |
| +// semicolon, the ) of a control-flow head (ctlEnd, see stripJSComments), |
| +// or one of the keywords that take an expression; a division follows a |
| +// value: a name, a number, any other closing bracket, and x++ or x--. |
| +// |
| +// A } is taken for the end of an object, so a / after it divides. A |
| +// regex opening a statement straight after a block's } would be read |
| +// wrong; no shipped script writes one, and |
| +// TestEveryShippedScriptStripsToTheSameShape would say if one did. |
| +func regexAllowed(before []byte, ctlEnd int) bool { |
| + b := bytes.TrimRight(before, " \t\r\n") |
| + if len(b) == 0 { |
| + return true |
| + } |
| + last := b[len(b)-1] |
| + if last == ')' { |
| + return len(b) == ctlEnd |
| + } |
| + if (last == '+' || last == '-') && len(b) > 1 && b[len(b)-2] == last { |
| + return false |
| + } |
| + if strings.IndexByte("(,=:[!&|?{};+-*%<>~^", last) >= 0 { |
| + return true |
| + } |
| + if !isWordByte(last) { |
| + return false |
| + } |
| + switch lastWord(b) { |
| + case "return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield", "await": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// controlHead says whether a ( written after before opens the head of |
| +// an if, while, for or with, including for await (…), whose head is |
| +// a for's with one more word in front of the parenthesis. |
| +func controlHead(before []byte) bool { |
| + b := bytes.TrimRight(before, " \t\r\n") |
| + w := lastWord(b) |
| + if w == "await" { |
| + w = lastWord(bytes.TrimRight(b[:len(b)-len(w)], " \t\r\n")) |
| + return w == "for" |
| + } |
| + switch w { |
| + case "if", "while", "for", "with": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// lastWord is the identifier or keyword b ends with, if it ends with one. |
| +func lastWord(b []byte) string { |
| + j := len(b) |
| + for j > 0 && isWordByte(b[j-1]) { |
| + j-- |
| + } |
| + return string(b[j:]) |
| +} |
| + |
| +func isSpaceByte(c byte) bool { return c == ' ' || c == '\t' || c == '\r' || c == '\n' } |
| + |
| +func isWordByte(c byte) bool { |
| + return c == '_' || c == '$' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= 0x80 |
| +} |
diff --git a/internal/build/strip_test.go b/internal/build/strip_test.go| new file mode 100644 |
| index 0000000..1062d93 |
| --- /dev/null |
| +++ b/internal/build/strip_test.go |
| @@ -0,0 +1,208 @@ |
| +package build |
| + |
| +import ( |
| + "encoding/json" |
| + "io/fs" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/nodetest" |
| +) |
| + |
| +// The strippers' own tests, from rastrillo's weight_test.go at SEED, |
| +// over Fiesta's tree: the hard cases each stripper must leave alone, |
| +// and every shipped file run as written and as stripped in Node. |
| + |
| +// shippedFiles is every stylesheet and script in the tree, by file |
| +// name. |
| +func shippedFiles(t *testing.T) map[string][]byte { |
| + t.Helper() |
| + files := map[string][]byte{"tokens.css": fiesta.TokensCSS(), "shell.css": fiesta.ShellCSS()} |
| + for _, name := range fiesta.ThemeNames() { |
| + css, _ := fiesta.ThemeCSS(name) |
| + files[name+".css"] = css |
| + } |
| + for _, name := range fiesta.ScriptNames() { |
| + js, ok := fiesta.Script(name) |
| + if !ok { |
| + t.Fatalf("Script(%q) is not shipped", name) |
| + } |
| + files[name] = js |
| + } |
| + return files |
| +} |
| + |
| +// Every shipped file gets smaller under gzip, and smaller again without |
| +// its comments. The literal figures are not asserted anywhere: they are |
| +// the compressor's, not this package's. |
| +func TestEveryShippedFileWeighsLessCompressedAndLessAgainStripped(t *testing.T) { |
| + for name, body := range shippedFiles(t) { |
| + var bare []byte |
| + if strings.HasSuffix(name, ".css") { |
| + bare = stripCSSComments(body) |
| + } else { |
| + bare = stripJSComments(body) |
| + } |
| + raw, gz, stripped := len(body), Gzipped(body), Gzipped(bare) |
| + if gz >= raw { |
| + t.Errorf("%s: gzipped %d is not below raw %d", name, gz, raw) |
| + } |
| + if len(bare) == len(body) { |
| + t.Errorf("%s: no comments found to strip; every shipped file carries some", name) |
| + } else if stripped > gz { |
| + t.Errorf("%s: stripped %d is above gzipped %d", name, stripped, gz) |
| + } |
| + } |
| +} |
| + |
| +// The JavaScript stripper's hard cases: everything that only looks like |
| +// a comment stays, and code either side of a removed comment still |
| +// parses as the same code. |
| +func TestStripJSCommentsLeavesWhatOnlyLooksLikeAComment(t *testing.T) { |
| + for _, c := range []struct{ in, want string }{ |
| + {"var a = 1; // note\nvar b = 2;\n", "var a = 1; \nvar b = 2;\n"}, |
| + {"// alone\nvar a;\n", "var a;\n"}, |
| + {" /* alone */\nvar a;\n", "var a;\n"}, |
| + {"var s = 'http://x/*y*/';\n", "var s = 'http://x/*y*/';\n"}, |
| + {`var s = "a\"//b";` + "\n", `var s = "a\"//b";` + "\n"}, |
| + {"var r = /\\/*x/g; // c\n", "var r = /\\/*x/g; \n"}, |
| + {"var r = /[/]/.test(s);\n", "var r = /[/]/.test(s);\n"}, |
| + {"var d = a / b / c; // c\n", "var d = a / b / c; \n"}, |
| + {"return /x/.test(s);\n", "return /x/.test(s);\n"}, |
| + {"var t = `a ${ {b: 1}.b /* c */ } // d`;\n", "var t = `a ${ {b: 1}.b } // d`;\n"}, |
| + {"var t = `${`${'//'}`}`; // e\n", "var t = `${`${'//'}`}`; \n"}, |
| + {"a/**/b\n", "a b\n"}, |
| + {"a /* one\ntwo */ b\n", "a \n b\n"}, |
| + } { |
| + if got := string(stripJSComments([]byte(c.in))); got != c.want { |
| + t.Errorf("stripJSComments(%q)\n got %q\nwant %q", c.in, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestStripCSSCommentsLeavesStringsAlone(t *testing.T) { |
| + for _, c := range []struct{ in, want string }{ |
| + {"/* head */\na { color: red; }\n", "a { color: red; }\n"}, |
| + {"a { content: \"/* no */\"; } /* yes */\n", "a { content: \"/* no */\"; } \n"}, |
| + {"a { b: c; /* d */ e: f; }\n", "a { b: c; e: f; }\n"}, |
| + } { |
| + if got := string(stripCSSComments([]byte(c.in))); got != c.want { |
| + t.Errorf("stripCSSComments(%q)\n got %q\nwant %q", c.in, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// nodeEval runs each script in a fresh node vm context and returns what |
| +// it came to: its completion value as JSON (awaited, so an async case |
| +// reports what it resolved to), or the error it threw. |
| +// Equivalence is the question a stripper has to answer, and node |
| +// --check cannot: a+/**/++b and a+++b both parse. |
| +const nodeEval = ` |
| +const vm = require("vm"); |
| +const srcs = JSON.parse(require("fs").readFileSync(0, "utf8")); |
| +(async () => { |
| + const out = []; |
| + for (const src of srcs) { |
| + try { out.push("value " + JSON.stringify(await vm.runInNewContext(src, {}))); } |
| + catch (e) { out.push("throws " + e.name + ": " + e.message); } |
| + } |
| + process.stdout.write(JSON.stringify(out)); |
| +})(); |
| +` |
| + |
| +func evalInNode(t *testing.T, srcs []string) []string { |
| + t.Helper() |
| + in, err := json.Marshal(srcs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []string |
| + if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", nodeEval}, Stdin: in}), &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + return out |
| +} |
| + |
| +// The cases a character-level stripper gets wrong, each run in node as |
| +// written and as stripped: a regex after a control-flow header's ")", |
| +// where a / starts a regex rather than dividing, and a comment between |
| +// two operators, whose removal must not fuse them into a third. |
| +func TestStrippedScriptsEvaluateAsWritten(t *testing.T) { |
| + cases := []string{ |
| + `var r = 0; if (true) /[/*]/.test("/") && (r = 1); r`, |
| + `var n = 0; while (n < 1) /a/.test("a") && n++; n`, |
| + `var k = 0; for (var i = 0; i < 2; i++) /[*/]/.test("*") && k++; k`, |
| + `let a = 1, b = 2; [a+/**/++b, a, b]`, |
| + `let a = 5, b = 2; [a-/* x */-b, a /**/ / b]`, |
| + `var x = 4, g = 2; [x / 2 / g, (x) / 2, x++ / 2]`, |
| + `var s = "/*", t = '//', u = ` + "`${s}/*${t}*/`" + `; [s, t, u] // tail`, |
| + `var o = {a: /}/.source}; /* gone */ o.a`, |
| + `(async () => { var r = 0; for await (const x of [1]) /[/*]/.test("/") && (r = 1); return r })()`, |
| + } |
| + var both []string |
| + for _, c := range cases { |
| + both = append(both, c, string(stripJSComments([]byte(c)))) |
| + } |
| + got := evalInNode(t, both) |
| + for i, c := range cases { |
| + if got[2*i] != got[2*i+1] { |
| + t.Errorf("stripping changed what a script does:\n script: %s\nstripped: %s\n before: %s\n after: %s", c, both[2*i+1], got[2*i], got[2*i+1]) |
| + } |
| + if strings.HasPrefix(got[2*i], "throws") { |
| + t.Errorf("the case itself does not run: %s: %s", c, got[2*i]) |
| + } |
| + } |
| +} |
| + |
| +// shapeEval loads each shipped script into an empty context, with a |
| +// CommonJS module and no document, and reports what it left behind: the |
| +// globals it defined, what it exported, and the error it stopped at. |
| +// The scripts export their pure halves to node behind a document guard, |
| +// so this runs real code from each, not only its declarations. |
| +const shapeEval = ` |
| +const vm = require("vm"); |
| +const srcs = JSON.parse(require("fs").readFileSync(0, "utf8")); |
| +const shape = v => v === null ? "null" : typeof v === "function" ? "function/" + v.length : typeof v; |
| +const describe = o => Object.keys(o).sort().map(k => k + ":" + shape(o[k])).join(","); |
| +process.stdout.write(JSON.stringify(srcs.map(src => { |
| + const module = {exports: {}}; |
| + const ctx = {module, exports: module.exports}; |
| + let err = ""; |
| + try { vm.runInNewContext(src, ctx); } catch (e) { err = e.name + ": " + e.message; } |
| + return "globals " + describe(ctx) + " | exports " + describe(module.exports) + " | " + err; |
| +}))); |
| +` |
| + |
| +// Every shipped script, stripped, still evaluates to the same thing: |
| +// the same globals and exports of the same kinds and arities, and the |
| +// same error where it reaches for a document node does not have. A |
| +// stripper that ate code a regex was hiding would change one of them. |
| +func TestEveryShippedScriptStripsToTheSameShape(t *testing.T) { |
| + names := fiesta.ScriptNames() |
| + var srcs []string |
| + for _, name := range names { |
| + body, err := fs.ReadFile(fiesta.FS(), "js/"+name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srcs = append(srcs, string(body), string(stripJSComments(body))) |
| + } |
| + in, err := json.Marshal(srcs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got []string |
| + if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"-e", shapeEval}, Stdin: in}), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + for i, name := range names { |
| + if got[2*i] != got[2*i+1] { |
| + t.Errorf("%s evaluates differently stripped:\n as written: %s\n stripped: %s", name, got[2*i], got[2*i+1]) |
| + } |
| + t.Logf("%s: %s", name, got[2*i]) |
| + if strings.Contains(got[2*i], "SyntaxError") { |
| + t.Errorf("%s does not even parse as written: %s", name, got[2*i]) |
| + } |
| + } |
| +} |