rastrillo / fiesta Public

Part 3's design-system addresses redirect to their pages

Part 4 moved every design-system page under /design-system/<theme>/<locale>/,
so part 3's published addresses (/design-system/day/button.html, its
right-to-left twin /design-system/day/rtl/button.html, the theme
indexes and every frame) would have answered 404 once it ships. Each
now answers a 301 to its page: a theme's page to the same page under
en/, a twin to the same page under ar/, an index to its directory.

The gallery owns the rules (gallery.Redirects, from the pages it
renders); www.Redirects hands them over and site.Build writes them
into _redirects after the release rewrites, refusing a rule that is
not one line or that takes the file past CARLOS's limits. 99 rules
for the design system, 100 with the release rewrite, 8,352 bytes:
literal theme directories, :theme placeholders for the index, frames
and right-to-left pages, and one rule per English page, because
<theme>/:page would also match <theme>/en.

The www test server now answers as CARLOS's edge does: its _redirects
grammar and matching are mirrored from internal/edge/redirects.go
(301 with Location, :name, :splat, shadowing, forced rules, first
match, the slash form of a directory), and a line `carlos ship`
would refuse is refused.

Tests:

- TestServeSiteRedirectsLikeTheEdge, TestServeSiteRefusesARuleCarlosWouldRefuse
  (new): red first by assertion against the old 200-only parse.
- TestThePagesOwnRulesFollowTheReleaseRewrites, TestARuleThatIsNotOneLineIsRefused
  (new, internal/site): red first by assertion, the rules dropped and
  every bad rule accepted.
- TestEveryPart3AddressStillReachesAPage (new): every one of 1,429
  part 3 addresses (testdata/part3-addresses.txt, generated from
  6af218e, regeneration in its header) plus each index's directory
  form: 1,434 answer 301 to their page, which answers 200, one hop,
  no fragment in the Location; the two that are still pages answer
  200. A sample of six pages keeps every id part 3 had
  (testdata/part3-ids.txt). Red first by assertion: every address 404.
- TestNoRedirectShadowsAPage, TestNoRedirectLeadsToAnotherRule (new):
  no rule matches any file or directory the site serves, none is
  forced, and no destination can match a source. With
  /design-system/:theme/* -> /design-system/:theme/en/:splat added,
  the first named 9,329 served paths and the second every redirect
  as a second hop.
- TestTheRedirectsFileIsWithinCarlosLimits (new): parses under the
  mirrored grammar, within 2,000 rules, 256 KiB and 2,048 bytes a
  line. carlos ship has no dry run for a static tree; CARLOS's own
  parseRedirects, run on the built file in a scratch module, also
  accepted all 100 rules.
- TestSiteWritesWhatBuildReturns now expects the rules in _redirects;
  red first by assertion before fiesta site passed them.

The www browser drives (16) pass through the new server. The four new
www tests are weighed in hack/test-weights.txt; the two www slices
deal to 39s each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 066cc40f48cdd46d4ca91f86628c806e79092342 parent 3f29229
13 files changed, +2482 −78
  • Makefile +4 −3
  • cmd/fiesta/main.go +10 −9
  • cmd/fiesta/release_test.go +6 −4
  • hack/test-weights.txt +4 −0
  • internal/gallery/redirects.go +93 −0
  • internal/site/site.go +25 −6
  • internal/site/site_test.go +54 −0
  • internal/www/legacy_test.go +270 −0
  • internal/www/redirects_test.go +394 −0
  • internal/www/serve_test.go +151 −56
  • internal/www/testdata/part3-addresses.txt +1439 −0
  • internal/www/testdata/part3-ids.txt +16 −0
  • internal/www/www.go +16 −0

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
go: downloading go1.26.10 (linux/arm64)
ok  	amadan.net/rastrillo/fiesta	1.240s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN   TestTheOneLinkIsUnder25KiBGzipped
    build_test.go:138: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.03s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.033s
=== history ===
history: 32 commits; release tags: v1.0.0
=== suite ===
--- shard 0 of 48 ---
==> shard 0 of 48: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh root 'internal/gallery@3 cmd/fiesta@2 internal/www@2'
ok  	amadan.net/rastrillo/fiesta	5.481s
ok  	amadan.net/rastrillo/fiesta/check	9.292s
ok  	amadan.net/rastrillo/fiesta/example	0.737s
?   	amadan.net/rastrillo/fiesta/hack/seed/english	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.368s
ok  	amadan.net/rastrillo/fiesta/internal/build	2.756s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.004s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.011s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.633s
ok  	amadan.net/rastrillo/fiesta/internal/site	5.017s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.008s
suite: root took 73s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m19.830000s 0m4.590000s
--- shard 1 of 48 ---
==> shard 1 of 48: make test.internal.gallery~1of3
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.gallery~1of3'
gotest: slice 1 of 3 of ./internal/gallery/ runs 35 of 104 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	141.078s
suite: test.internal.gallery~1of3 took 174s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m53.750000s 0m2.070000s
--- shard 2 of 48 ---
==> shard 2 of 48: make test.internal.gallery~2of3
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.gallery~2of3'
gotest: slice 2 of 3 of ./internal/gallery/ runs 34 of 104 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	134.008s
suite: test.internal.gallery~2of3 took 169s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m52.320000s 0m2.090000s
--- shard 3 of 48 ---
==> shard 3 of 48: make test.internal.gallery~3of3
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.gallery~3of3'
gotest: slice 3 of 3 of ./internal/gallery/ runs 35 of 104 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	126.050s
suite: test.internal.gallery~3of3 took 162s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m45.880000s 0m1.740000s
--- shard 4 of 48 ---
==> shard 4 of 48: make test.cmd.fiesta~1of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'cmd.fiesta~1of2'
gotest: slice 1 of 2 of ./cmd/fiesta/ runs 7 of 12 test tests
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	116.805s
suite: test.cmd.fiesta~1of2 took 154s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m36.670000s 0m2.850000s
--- shard 5 of 48 ---
==> shard 5 of 48: make test.cmd.fiesta~2of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'cmd.fiesta~2of2'
gotest: slice 2 of 2 of ./cmd/fiesta/ runs 5 of 12 test tests
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	112.856s
suite: test.cmd.fiesta~2of2 took 147s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m39.390000s 0m3.100000s
--- shard 6 of 48 ---
==> shard 6 of 48: make test.internal.www~1of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.www~1of2'
gotest: slice 1 of 2 of ./internal/www/ runs 11 of 25 test tests
ok  	amadan.net/rastrillo/fiesta/internal/www	122.876s
suite: test.internal.www~1of2 took 177s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
1m2.250000s 0m3.760000s
--- shard 7 of 48 ---
==> shard 7 of 48: make test.internal.www~2of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.www~2of2'
gotest: slice 2 of 2 of ./internal/www/ runs 14 of 25 test tests
ok  	amadan.net/rastrillo/fiesta/internal/www	128.793s
suite: test.internal.www~2of2 took 179s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m58.920000s 0m3.530000s
--- shard 8 of 48 ---
==> shard 8 of 48: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
go: downloading honnef.co/go/tools v0.8.2
go: downloading golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678
go: downloading golang.org/x/tools v0.51.0
go: downloading github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c
go: downloading golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
suite: staticcheck took 85s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m35.830000s 0m5.870000s
--- shard 9 of 48 ---
==> shard 9 of 48: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
go: downloading golang.org/x/vuln v1.8.0
go: downloading golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518
go: downloading golang.org/x/sync v0.23.0
go: downloading golang.org/x/tools v0.50.0
go: downloading golang.org/x/mod v0.41.0
=== Symbol Results ===

No vulnerabilities found.

Your code is affected by 0 vulnerabilities.
This scan also found 2 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.
suite: govulncheck took 67s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m18.390000s 0m2.620000s
--- shard 10 of 48 ---
==> shard 10 of 48: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
go: downloading github.com/zricethezav/gitleaks/v8 v8.30.1
go: downloading github.com/fatih/semgroup v1.2.0
go: downloading github.com/gitleaks/go-gitdiff v0.9.1
go: downloading github.com/Masterminds/sprig/v3 v3.3.0
go: downloading github.com/mholt/archives v0.1.2
go: downloading github.com/BobuSumisu/aho-corasick v1.0.3
go: downloading golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa
go: downloading github.com/hashicorp/go-version v1.7.0
go: downloading github.com/spf13/viper v1.19.0
go: downloading github.com/rs/zerolog v1.33.0
go: downloading github.com/charmbracelet/lipgloss v0.5.0
go: downloading github.com/spf13/cobra v1.9.1
go: downloading github.com/h2non/filetype v1.1.3
go: downloading golang.org/x/sync v0.11.0
go: downloading golang.org/x/text v0.22.0
go: downloading github.com/klauspost/pgzip v1.2.6
go: downloading github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3
go: downloading github.com/minio/minlz v1.0.0
go: downloading github.com/nwaples/rardecode/v2 v2.1.0
go: downloading github.com/bodgit/sevenzip v1.6.0
go: downloading github.com/klauspost/compress v1.17.11
go: downloading github.com/sorairolake/lzip-go v0.3.5
go: downloading github.com/therootcompany/xz v1.0.1
go: downloading github.com/ulikunitz/xz v0.5.12
go: downloading github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707
go: downloading github.com/STARRY-S/zip v0.2.1
go: downloading github.com/pierrec/lz4/v4 v4.1.21
go: downloading github.com/spf13/afero v1.12.0
go: downloading github.com/mitchellh/mapstructure v1.5.0
go: downloading github.com/fsnotify/fsnotify v1.8.0
go: downloading github.com/spf13/cast v1.7.1
go: downloading github.com/sagikazarmark/slog-shim v0.1.0
go: downloading github.com/spf13/pflag v1.0.6
go: downloading github.com/Masterminds/goutils v1.1.1
go: downloading dario.cat/mergo v1.0.1
go: downloading github.com/Masterminds/semver/v3 v3.3.0
go: downloading github.com/google/uuid v1.6.0
go: downloading github.com/huandu/xstrings v1.5.0
go: downloading github.com/mitchellh/copystructure v1.2.0
go: downloading github.com/shopspring/decimal v1.4.0
go: downloading golang.org/x/crypto v0.35.0
go: downloading github.com/subosito/gotenv v1.6.0
go: downloading github.com/hashicorp/hcl v1.0.0
go: downloading gopkg.in/ini.v1 v1.67.0
go: downloading gopkg.in/yaml.v3 v3.0.1
go: downloading github.com/pelletier/go-toml/v2 v2.2.3
go: downloading github.com/magiconair/properties v1.8.9
go: downloading github.com/lucasb-eyer/go-colorful v1.2.0
go: downloading github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
go: downloading github.com/mattn/go-runewidth v0.0.14
go: downloading github.com/muesli/termenv v0.15.1
go: downloading github.com/mattn/go-colorable v0.1.14
go: downloading github.com/bodgit/plumbing v1.3.0
go: downloading github.com/bodgit/windows v1.0.1
go: downloading github.com/hashicorp/go-multierror v1.1.1
go: downloading go4.org v0.0.0-20230225012048-214862532bf5
go: downloading github.com/mitchellh/reflectwalk v1.0.2
go: downloading github.com/rivo/uniseg v0.2.0
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/hashicorp/golang-lru/v2 v2.0.7
go: downloading golang.org/x/sys v0.30.0
go: downloading github.com/hashicorp/errwrap v1.1.0
go: downloading github.com/aymanbagabas/go-osc52/v2 v2.0.1
9:03PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
9:03PM INF 30 commits scanned.
9:03PM INF scanned ~7881895 bytes (7.88 MB) in 5.88s
9:03PM INF no leaks found
suite: gitleaks took 73s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m30.340000s 0m4.020000s
--- shard 11 of 48 ---
==> shard 11 of 48: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: ./internal/harness/ runs its 10 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/harness	18.956s
suite: browser.internal.harness took 67s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m13.630000s 0m3.290000s
--- shard 12 of 48 ---
==> shard 12 of 48: make browser.check~1of13
./hack/gotest.sh browser 'check~1of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 1 of 13 of ./check/ runs 5 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	135.529s
suite: browser.check~1of13 took 204s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m52.570000s 0m10.020000s
--- shard 13 of 48 ---
==> shard 13 of 48: make browser.check~2of13
./hack/gotest.sh browser 'check~2of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 2 of 13 of ./check/ runs 9 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	119.498s
suite: browser.check~2of13 took 186s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m42.880000s 0m9.710000s
--- shard 14 of 48 ---
==> shard 14 of 48: make browser.check~3of13
./hack/gotest.sh browser 'check~3of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 3 of 13 of ./check/ runs 10 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	133.680s
suite: browser.check~3of13 took 201s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m51.960000s 0m10.550000s
--- shard 15 of 48 ---
==> shard 15 of 48: make browser.check~4of13
./hack/gotest.sh browser 'check~4of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 4 of 13 of ./check/ runs 11 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	139.530s
suite: browser.check~4of13 took 206s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m56.320000s 0m13.340000s
--- shard 16 of 48 ---
==> shard 16 of 48: make browser.check~5of13
./hack/gotest.sh browser 'check~5of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 5 of 13 of ./check/ runs 16 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	84.778s
suite: browser.check~5of13 took 153s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m34.290000s 0m9.030000s
--- shard 17 of 48 ---
==> shard 17 of 48: make browser.check~6of13
./hack/gotest.sh browser 'check~6of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 6 of 13 of ./check/ runs 16 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	110.720s
suite: browser.check~6of13 took 178s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m43.790000s 0m12.030000s
--- shard 18 of 48 ---
==> shard 18 of 48: make browser.check~7of13
./hack/gotest.sh browser 'check~7of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 7 of 13 of ./check/ runs 16 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	108.293s
suite: browser.check~7of13 took 175s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m36.520000s 0m8.890000s
--- shard 19 of 48 ---
==> shard 19 of 48: make browser.check~8of13
./hack/gotest.sh browser 'check~8of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 8 of 13 of ./check/ runs 17 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	99.118s
suite: browser.check~8of13 took 168s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m37.240000s 0m10.990000s
--- shard 20 of 48 ---
==> shard 20 of 48: make browser.check~9of13
./hack/gotest.sh browser 'check~9of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 9 of 13 of ./check/ runs 18 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	103.629s
suite: browser.check~9of13 took 171s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m40.150000s 0m13.040000s
--- shard 21 of 48 ---
==> shard 21 of 48: make browser.check~10of13
./hack/gotest.sh browser 'check~10of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 10 of 13 of ./check/ runs 17 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	124.352s
suite: browser.check~10of13 took 190s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m50.640000s 0m13.970000s
--- shard 22 of 48 ---
==> shard 22 of 48: make browser.check~11of13
./hack/gotest.sh browser 'check~11of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 11 of 13 of ./check/ runs 18 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	93.773s
suite: browser.check~11of13 took 160s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m30.500000s 0m9.020000s
--- shard 23 of 48 ---
==> shard 23 of 48: make browser.check~12of13
./hack/gotest.sh browser 'check~12of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 12 of 13 of ./check/ runs 18 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	107.960s
suite: browser.check~12of13 took 174s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m40.460000s 0m12.000000s
--- shard 24 of 48 ---
==> shard 24 of 48: make browser.check~13of13
./hack/gotest.sh browser 'check~13of13'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 13 of 13 of ./check/ runs 18 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	92.949s
suite: browser.check~13of13 took 161s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m29.430000s 0m9.840000s
--- shard 25 of 48 ---
==> shard 25 of 48: make browser.internal.gallery~1of18
./hack/gotest.sh browser 'internal.gallery~1of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	67.533s
suite: browser.internal.gallery~1of18 took 132s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m30.580000s 0m5.470000s
--- shard 26 of 48 ---
==> shard 26 of 48: make browser.internal.gallery~2of18
./hack/gotest.sh browser 'internal.gallery~2of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 2 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	82.171s
suite: browser.internal.gallery~2of18 took 148s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m31.490000s 0m6.310000s
--- shard 27 of 48 ---
==> shard 27 of 48: make browser.internal.gallery~3of18
./hack/gotest.sh browser 'internal.gallery~3of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 3 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	101.801s
suite: browser.internal.gallery~3of18 took 168s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m31.460000s 0m6.370000s
--- shard 28 of 48 ---
==> shard 28 of 48: make browser.internal.gallery~4of18
./hack/gotest.sh browser 'internal.gallery~4of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	107.221s
suite: browser.internal.gallery~4of18 took 173s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m31.120000s 0m7.120000s
--- shard 29 of 48 ---
==> shard 29 of 48: make browser.internal.gallery~5of18
./hack/gotest.sh browser 'internal.gallery~5of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 5 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	113.538s
suite: browser.internal.gallery~5of18 took 177s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m29.950000s 0m6.710000s
--- shard 30 of 48 ---
==> shard 30 of 48: make browser.internal.gallery~6of18
./hack/gotest.sh browser 'internal.gallery~6of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 6 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	86.557s
suite: browser.internal.gallery~6of18 took 153s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m26.730000s 0m5.140000s
--- shard 31 of 48 ---
==> shard 31 of 48: make browser.internal.gallery~7of18
./hack/gotest.sh browser 'internal.gallery~7of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 7 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	80.994s
suite: browser.internal.gallery~7of18 took 147s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m27.870000s 0m4.510000s
--- shard 32 of 48 ---
==> shard 32 of 48: make browser.internal.gallery~8of18
./hack/gotest.sh browser 'internal.gallery~8of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 8 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	103.437s
suite: browser.internal.gallery~8of18 took 170s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m35.240000s 0m8.050000s
--- shard 33 of 48 ---
==> shard 33 of 48: make browser.internal.gallery~9of18
./hack/gotest.sh browser 'internal.gallery~9of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 9 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	121.797s
suite: browser.internal.gallery~9of18 took 186s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m37.010000s 0m9.350000s
--- shard 34 of 48 ---
==> shard 34 of 48: make browser.internal.gallery~10of18
./hack/gotest.sh browser 'internal.gallery~10of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 10 of 18 of ./internal/gallery/ runs 2 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	85.267s
suite: browser.internal.gallery~10of18 took 150s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m29.630000s 0m4.990000s
--- shard 35 of 48 ---
==> shard 35 of 48: make browser.internal.gallery~11of18
./hack/gotest.sh browser 'internal.gallery~11of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 11 of 18 of ./internal/gallery/ runs 2 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	89.806s
suite: browser.internal.gallery~11of18 took 155s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m31.670000s 0m5.250000s
--- shard 36 of 48 ---
==> shard 36 of 48: make browser.internal.gallery~12of18
./hack/gotest.sh browser 'internal.gallery~12of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 12 of 18 of ./internal/gallery/ runs 2 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	123.057s
suite: browser.internal.gallery~12of18 took 189s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m33.720000s 0m7.050000s
--- shard 37 of 48 ---
==> shard 37 of 48: make browser.internal.gallery~13of18
./hack/gotest.sh browser 'internal.gallery~13of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 13 of 18 of ./internal/gallery/ runs 3 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	122.873s
suite: browser.internal.gallery~13of18 took 188s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m33.640000s 0m7.580000s
--- shard 38 of 48 ---
==> shard 38 of 48: make browser.internal.gallery~14of18
./hack/gotest.sh browser 'internal.gallery~14of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 14 of 18 of ./internal/gallery/ runs 4 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	126.921s
suite: browser.internal.gallery~14of18 took 192s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m37.750000s 0m8.140000s
--- shard 39 of 48 ---
==> shard 39 of 48: make browser.internal.gallery~15of18
./hack/gotest.sh browser 'internal.gallery~15of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 15 of 18 of ./internal/gallery/ runs 4 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	126.978s
suite: browser.internal.gallery~15of18 took 191s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m36.610000s 0m8.470000s
--- shard 40 of 48 ---
==> shard 40 of 48: make browser.internal.gallery~16of18
./hack/gotest.sh browser 'internal.gallery~16of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 16 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	117.067s
suite: browser.internal.gallery~16of18 took 184s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m27.550000s 0m5.560000s
--- shard 41 of 48 ---
==> shard 41 of 48: make browser.internal.gallery~17of18
./hack/gotest.sh browser 'internal.gallery~17of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 17 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	123.654s
suite: browser.internal.gallery~17of18 took 189s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m34.680000s 0m8.280000s
--- shard 42 of 48 ---
==> shard 42 of 48: make browser.internal.gallery~18of18
./hack/gotest.sh browser 'internal.gallery~18of18'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 18 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	122.446s
suite: browser.internal.gallery~18of18 took 189s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m33.470000s 0m7.520000s
--- shard 43 of 48 ---
==> shard 43 of 48: make browser.internal.gallery.history
./hack/gotest.sh browser 'internal.gallery.history'
gotest: on ip-172-31-21-140, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: ./internal/gallery/history/ runs its 3 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery/history	70.559s
suite: browser.internal.gallery.history took 128s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m32.930000s 0m3.760000s
--- shard 44 of 48 ---
==> shard 44 of 48: make browser.internal.www~1of4
./hack/gotest.sh browser 'internal.www~1of4'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 4 of ./internal/www/ runs 4 of 16 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	106.083s
suite: browser.internal.www~1of4 took 168s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m44.760000s 0m3.850000s
--- shard 45 of 48 ---
==> shard 45 of 48: make browser.internal.www~2of4
./hack/gotest.sh browser 'internal.www~2of4'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 2 of 4 of ./internal/www/ runs 4 of 16 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	110.738s
suite: browser.internal.www~2of4 took 174s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m52.430000s 0m4.180000s
--- shard 46 of 48 ---
==> shard 46 of 48: make browser.internal.www~3of4
./hack/gotest.sh browser 'internal.www~3of4'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 3 of 4 of ./internal/www/ runs 4 of 16 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	101.407s
suite: browser.internal.www~3of4 took 166s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m48.240000s 0m5.200000s
--- shard 47 of 48 ---
==> shard 47 of 48: make browser.internal.www~4of4
./hack/gotest.sh browser 'internal.www~4of4'
gotest: on ip-172-31-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 4 of ./internal/www/ runs 4 of 16 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	101.102s
suite: browser.internal.www~4of4 took 162s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m48.000000s 0m4.280000s
diff --git a/Makefile b/Makefile
index 53690ab..f9f6c00 100644
--- a/Makefile
+++ b/Makefile
@@ -256,9 +256,10 @@ heights-sweep: | $(BIN)/tmp
# site assembles fiesta.rastrillo.org into .build/site/public from the
# committed releases and the site source: each release under its exact
-# version, _redirects sending /M/* to the newest M.y.z, and at the root
-# the pages internal/www assembles from site/, the landing page, the
-# design system under /design-system/ and site/static/. The tree is
+# version, _redirects sending /M/* to the newest M.y.z and the design
+# system's former addresses to their pages, and at the root the pages
+# internal/www assembles from site/, the landing page, the design
+# system under /design-system/ and site/static/. The tree is
# .build/site/public; the command's own .fiesta stamp sits above it in
# .build/site, so nothing hidden is in the shipped tree. Nothing in ci
# depends on this target, since the releases are committed and checked
diff --git a/cmd/fiesta/main.go b/cmd/fiesta/main.go
index 1c50a1f..4777f7a 100644
--- a/cmd/fiesta/main.go
+++ b/cmd/fiesta/main.go
@@ -34,11 +34,12 @@
// assembles the site from the releases and the site source, exactly
// what internal/site.Build returns, into DIR/public/: each release
// under its exact version, _redirects sending /M/* to the newest
-// M.y.z, and the pages at the root. The pages are what internal/www
-// assembles from the site source: the landing page, the design system
-// under /design-system/, and the source's static/ files. DIR/public/
-// is the tree to ship, and the stamp sits above it in DIR, so nothing
-// hidden is ever deployed.
+// M.y.z and the design system's former addresses to their pages, and
+// the pages at the root. The pages are what internal/www assembles
+// from the site source: the landing page, the design system under
+// /design-system/, and the source's static/ files. DIR/public/ is the
+// tree to ship, and the stamp sits above it in DIR, so nothing hidden
+// is ever deployed.
//
// Each command owns the directory it writes. It empties it before it
// writes, so what is there afterwards is the output and only the
@@ -205,15 +206,15 @@ func writeGallery(out, mount string) error {
const shipDir = "public"
// writeSite assembles the pages from the site source, then the site
-// from the releases and those pages, and puts it in out/public/, after
-// claiming out. A fault in the site source is reported on its own,
-// before the releases are read.
+// from the releases, those pages and their rules, and puts it in
+// out/public/, after claiming out. A fault in the site source is
+// reported on its own, before the releases are read.
func writeSite(releases, siteDir, out string) error {
pages, err := www.Pages(fiesta.FS(), os.DirFS(siteDir))
if err != nil {
return err
}
- files, err := site.Build(os.DirFS(releases), www.FS(pages))
+ files, err := site.Build(os.DirFS(releases), www.FS(pages), www.Redirects(pages)...)
if err != nil {
return err
}
diff --git a/cmd/fiesta/release_test.go b/cmd/fiesta/release_test.go
index 3e3464d..58d5542 100644
--- a/cmd/fiesta/release_test.go
+++ b/cmd/fiesta/release_test.go
@@ -361,7 +361,8 @@ func TestAnUnreachableOriginRefusesACandidate(t *testing.T) {
// -releases R -site S -out DIR` writes site.Build's tree into
// DIR/public, file for file, stamps DIR itself as its own, and leaves
// nothing hidden in the tree to ship. The pages are what www.Pages
-// assembles from S, so the expected tree is computed the same way.
+// assembles from S and their rules www.Redirects, so the expected tree
+// is computed the same way.
func TestSiteWritesWhatBuildReturns(t *testing.T) {
root := t.TempDir()
releases := filepath.Join(root, "releases")
@@ -412,7 +413,7 @@ func TestSiteWritesWhatBuildReturns(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- want, err := site.Build(os.DirFS(releases), www.FS(pages))
+ want, err := site.Build(os.DirFS(releases), www.FS(pages), www.Redirects(pages)...)
if err != nil {
t.Fatal(err)
}
@@ -431,8 +432,9 @@ func TestSiteWritesWhatBuildReturns(t *testing.T) {
}
}
major, _, _ := strings.Cut(fiesta.Version, ".")
- if got := string(want["_redirects"]); got != "/"+major+"/* /"+fiesta.Version+"/:splat 200\n" {
- t.Errorf("_redirects is %q", got)
+ rules := "/" + major + "/* /" + fiesta.Version + "/:splat 200\n" + strings.Join(www.Redirects(pages), "\n") + "\n"
+ if got := string(want["_redirects"]); got != rules {
+ t.Errorf("_redirects is %q, want the release rewrite and then the design system's rules", got)
}
if b, err := os.ReadFile(filepath.Join(out, stampName)); err != nil || string(b) != stampBody("fiesta site") {
t.Errorf("the stamp is not fiesta site's, or is not in -out itself: %q, %v", b, err)
diff --git a/hack/test-weights.txt b/hack/test-weights.txt
index af558a0..de71d83 100644
--- a/hack/test-weights.txt
+++ b/hack/test-weights.txt
@@ -197,7 +197,11 @@ test internal/www TestEveryLinkInTheSiteResolves 10
test internal/www TestSiteBuildAcceptsThePages 6
test internal/www TestEveryLanguageHasAPage 5
test internal/www TestEveryPageLinksEveryLanguage 5
+test internal/www TestEveryPart3AddressStillReachesAPage 5
+test internal/www TestNoRedirectLeadsToAnotherRule 5
+test internal/www TestNoRedirectShadowsAPage 5
test internal/www TestPagesHasARootAndTheDesignSystem 5
test internal/www TestStaticFilesAreCopied 5
test internal/www TestTheDesignSystemIsMounted 5
+test internal/www TestTheRedirectsFileIsWithinCarlosLimits 5
test internal/www TestAHiddenSiteFileIsRefused 4
diff --git a/internal/gallery/redirects.go b/internal/gallery/redirects.go
new file mode 100644
index 0000000..7a516e7
--- /dev/null
+++ b/internal/gallery/redirects.go
@@ -0,0 +1,93 @@
+package gallery
+
+import (
+ "sort"
+ "strings"
+
+ "amadan.net/rastrillo/fiesta"
+)
+
+// formerRTL is the directory a theme's right-to-left twin had before
+// every page moved under a locale: <theme>/rtl/<page>, beside the
+// English <theme>/<page>. Those addresses were published, so they are
+// redirected rather than left to 404.
+const formerRTL = "rtl"
+
+// Redirects are the _redirects rules, for a tree rendered for mount,
+// that send each address the gallery had before its pages moved under
+// a locale to the page that is there now, with a 301:
+//
+// <theme>/ <theme>/en/
+// <theme>/index.html <theme>/en/
+// <theme>/<page>.html <theme>/en/<page>.html
+// <theme>/frames/<example>/<state> <theme>/en/frames/<example>/<state>
+// <theme>/rtl/... the same under <theme>/ar/
+//
+// An index goes to its directory, the address the edge serves it at,
+// since the edge sends .../index.html on to ./ and that would be a
+// second hop. The rules are written so that no source can match a
+// file of the tree, and no destination can match a source, so none
+// shadows a page and none leads to another: every file of today's tree
+// sits under <theme>/<locale>/ or assets/, and no locale is "frames",
+// "rtl", "index.html" or a page's name. So <theme>/rtl/:page can be one
+// rule, but an English page is named rule by rule: <theme>/:page would
+// also match <theme>/en, the English tree's own directory. The pages
+// named are the ones tree holds at <theme>/en/ in every theme, so each
+// destination is a page whatever the theme. The order is fixed, so the
+// same tree gives the same rules.
+func Redirects(mount string, tree map[string][]byte) []string {
+ m := cleanMount(mount)
+ en, ar := Locales[0], rtlLocale()
+ var out []string
+ rule := func(from, to string) { out = append(out, m+from+" "+m+to+" 301") }
+
+ themes := fiesta.ThemeNames()
+ for _, theme := range themes {
+ rule("/"+theme, "/"+theme+"/"+en+"/")
+ }
+ rule("/:theme/index.html", "/:theme/"+en+"/")
+ rule("/:theme/frames/:example/:state", "/:theme/"+en+"/frames/:example/:state")
+ for _, page := range everyThemesPages(tree, themes, en) {
+ rule("/:theme/"+page, "/:theme/"+en+"/"+page)
+ }
+
+ twin := "/:theme/" + formerRTL
+ rule(twin, "/:theme/"+ar+"/")
+ rule(twin+"/index.html", "/:theme/"+ar+"/")
+ rule(twin+"/frames/:example/:state", "/:theme/"+ar+"/frames/:example/:state")
+ rule(twin+"/:page", "/:theme/"+ar+"/:page")
+ return out
+}
+
+// rtlLocale is the locale that renders right to left, where the former
+// twins now live.
+func rtlLocale() string {
+ for _, l := range Locales {
+ if Dir(l) == "rtl" {
+ return l
+ }
+ }
+ panic("gallery: no locale renders right to left")
+}
+
+// everyThemesPages are the names of the HTML pages directly under
+// <theme>/<locale>/ for every theme, the index aside, sorted.
+func everyThemesPages(tree map[string][]byte, themes []string, locale string) []string {
+ count := map[string]int{}
+ for name := range tree {
+ for _, theme := range themes {
+ page, ok := strings.CutPrefix(name, theme+"/"+locale+"/")
+ if ok && !strings.Contains(page, "/") && strings.HasSuffix(page, ".html") && page != "index.html" {
+ count[page]++
+ }
+ }
+ }
+ var pages []string
+ for page, n := range count {
+ if n == len(themes) {
+ pages = append(pages, page)
+ }
+ }
+ sort.Strings(pages)
+ return pages
+}
diff --git a/internal/site/site.go b/internal/site/site.go
index 29889e3..97d2689 100644
--- a/internal/site/site.go
+++ b/internal/site/site.go
@@ -86,13 +86,15 @@ func parseVersion(name string) (version, bool) {
}
// The edge's limits on one _redirects file, from CARLOS's
-// internal/edge/redirects.go: 2,000 rules and 256 KiB (and 2,048 bytes
-// a line, which one rule per major cannot reach once every component
-// fits an int). A site past either assembles here and is refused by the
-// packer, so it is refused here first, naming the figure.
+// internal/edge/redirects.go: 2,000 rules, 256 KiB and 2,048 bytes a
+// line (which one rule per major cannot reach once every component
+// fits an int, but a page's rule could). A site past any of them
+// assembles here and is refused by the packer, so it is refused here
+// first, naming the figure.
const (
redirectsMaxRules = 2000
redirectsMaxBytes = 256 << 10
+ redirectsMaxLine = 2048
)
// less orders versions numerically, so 1.10.0 is newer than 1.9.3.
@@ -114,6 +116,8 @@ func (v version) less(w version) bool {
// against the release's SHA256SUMS first (Release);
// - _redirects, one rule per major, newest major first, sending
// /M/* to that major's newest release: `/M/* /M.y.z/:splat 200`;
+// then own, the pages' rules, one a line as given, so a page's
+// rule never decides a /M/ URL first;
// - every page, at the path it has under pages.
//
// Anything else is an error, and the first error is the whole answer:
@@ -124,8 +128,9 @@ func (v version) less(w version) bool {
// under an all-digit first segment would answer for /M/*, released or
// not, and a page under a version-shaped segment would answer for that
// release. Both are refused, and so is a page named _redirects, which
-// is the rules file.
-func Build(releases fs.FS, pages fs.FS) (map[string][]byte, error) {
+// is the rules file. So is a rule that is not one line of it, or
+// rules that take it past the edge's limits.
+func Build(releases fs.FS, pages fs.FS, own ...string) (map[string][]byte, error) {
out := map[string][]byte{}
entries, err := fs.ReadDir(releases, ".")
if err != nil {
@@ -166,10 +171,24 @@ func Build(releases fs.FS, pages fs.FS) (map[string][]byte, error) {
if len(majors) > redirectsMaxRules {
return nil, fmt.Errorf("%s would hold %d rules, one per major, and the edge accepts %d", redirectsName, len(majors), redirectsMaxRules)
}
+ if n := len(majors) + len(own); n > redirectsMaxRules {
+ return nil, fmt.Errorf("%s would hold %d rules, %d for the releases and %d for the pages, and the edge accepts %d", redirectsName, n, len(majors), len(own), redirectsMaxRules)
+ }
var rules bytes.Buffer
for _, m := range majors {
fmt.Fprintf(&rules, "/%d/* /%s/:splat 200\n", m, newest[m].name)
}
+ for _, r := range own {
+ switch {
+ case strings.ContainsAny(r, "\n\r"):
+ return nil, fmt.Errorf("the %s rule %q is not one line", redirectsName, r)
+ case strings.TrimSpace(r) == "":
+ return nil, fmt.Errorf("a %s rule is empty", redirectsName)
+ case len(r) > redirectsMaxLine:
+ return nil, fmt.Errorf("the %s rule %.40q... is %d bytes, and the edge accepts %d a line", redirectsName, r, len(r), redirectsMaxLine)
+ }
+ rules.WriteString(r + "\n")
+ }
if rules.Len() > redirectsMaxBytes {
return nil, fmt.Errorf("%s would be %d bytes, and the edge accepts %d", redirectsName, rules.Len(), redirectsMaxBytes)
}
diff --git a/internal/site/site_test.go b/internal/site/site_test.go
index 07bf9f0..f275207 100644
--- a/internal/site/site_test.go
+++ b/internal/site/site_test.go
@@ -468,3 +468,57 @@ func TestTheRedirectsStayUnderTheEdgesLimits(t *testing.T) {
t.Errorf("the refusal does not give both figures: %v", err)
}
}
+
+// TestThePagesOwnRulesFollowTheReleaseRewrites: the rules Build is
+// handed go into _redirects as they are, one a line, in the order
+// given, after every release rewrite, so /M/ is decided first.
+func TestThePagesOwnRulesFollowTheReleaseRewrites(t *testing.T) {
+ out, err := site.Build(releasesOf("1.0.0", "2.0.0"), pagesOf(nil),
+ "/old/:page /new/:page 301",
+ "/gone.html /here.html 301")
+ if err != nil {
+ t.Fatalf("Build: %v", err)
+ }
+ const want = "/2/* /2.0.0/:splat 200\n" +
+ "/1/* /1.0.0/:splat 200\n" +
+ "/old/:page /new/:page 301\n" +
+ "/gone.html /here.html 301\n"
+ if got := string(out["_redirects"]); got != want {
+ t.Errorf("_redirects is %q, want %q", got, want)
+ }
+}
+
+// TestARuleThatIsNotOneLineIsRefused: a rule is one line of
+// _redirects, so a rule with a line break in it, an empty one, and one
+// longer than the edge's 2,048 bytes a line are refused by name, and so
+// are rules that take the file past 2,000 rules or 256 KiB with the
+// release rewrites, rather than assembling a site the packer rejects.
+func TestARuleThatIsNotOneLineIsRefused(t *testing.T) {
+ many := make([]string, 2000)
+ for i := range many {
+ many[i] = "/a" + strconv.Itoa(i) + " /b 301"
+ }
+ wide := make([]string, 130)
+ for i := range wide {
+ wide[i] = "/" + strconv.Itoa(i) + strings.Repeat("a", 2030) + " /b 301"
+ }
+ for _, c := range []struct {
+ name string
+ rules []string
+ want string
+ }{
+ {"a line break", []string{"/a /b 301\n/c /d 301"}, "one line"},
+ {"a carriage return", []string{"/a /b 301\r"}, "one line"},
+ {"an empty rule", []string{""}, "empty"},
+ {"a rule over 2048 bytes", []string{"/" + strings.Repeat("a", 2048) + " /b 301"}, "2048"},
+ {"2001 rules with the release's", many, "2000"},
+ {"over 256 KiB", wide, "bytes"},
+ } {
+ _, err := site.Build(releasesOf("1.0.0"), pagesOf(nil), c.rules...)
+ if err == nil {
+ t.Errorf("%s: Build accepted it", c.name)
+ } else if !strings.Contains(err.Error(), c.want) {
+ t.Errorf("%s: refused, but the error does not say %q: %v", c.name, c.want, err)
+ }
+ }
+}
diff --git a/internal/www/legacy_test.go b/internal/www/legacy_test.go
new file mode 100644
index 0000000..e54c2f1
--- /dev/null
+++ b/internal/www/legacy_test.go
@@ -0,0 +1,270 @@
+package www_test
+
+import (
+ "bufio"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path"
+ "strings"
+ "testing"
+
+ xhtml "golang.org/x/net/html"
+)
+
+// lines reads a testdata file: one entry a line, # comments and blank
+// lines skipped.
+func lines(t *testing.T, name string) []string {
+ t.Helper()
+ f, err := os.Open("testdata/" + name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ var out []string
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ if l := strings.TrimSpace(sc.Text()); l != "" && !strings.HasPrefix(l, "#") {
+ out = append(out, l)
+ }
+ }
+ if err := sc.Err(); err != nil {
+ t.Fatal(err)
+ }
+ if len(out) == 0 {
+ t.Fatalf("testdata/%s lists nothing", name)
+ }
+ return out
+}
+
+// part3Home is where a part 3 page lives now, as a URL: a theme's page
+// under its English tree, a right-to-left twin under its Arabic one,
+// same name, and an index as its directory, which is the address the
+// edge serves an index at (it sends .../index.html to ./, so a
+// Location naming the file would be a second hop).
+func part3Home(old string) string {
+ rest, ok := strings.CutPrefix(old, "design-system/")
+ if !ok {
+ return ""
+ }
+ theme, rest, ok := strings.Cut(rest, "/")
+ if !ok {
+ return "/" + old // the design system's own index, which stayed
+ }
+ locale := "en"
+ if r, ok := strings.CutPrefix(rest, "rtl/"); ok {
+ locale, rest = "ar", r
+ }
+ to := "/design-system/" + theme + "/" + locale + "/" + rest
+ if d, ok := strings.CutSuffix(to, "/index.html"); ok {
+ to = d + "/"
+ }
+ return to
+}
+
+// serve answers one GET through the test server's handler.
+func serve(h http.Handler, target string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
+ return rec
+}
+
+// TestEveryPart3AddressStillReachesAPage: every page part 3 published
+// under /design-system/ (testdata/part3-addresses.txt), and an index's
+// directory address too, answers either with the page itself, where
+// the path is still a page, or with a 301 whose Location is that
+// page's part 4 address and answers 200 with HTML: one hop, never two.
+// The Location carries no fragment, so the browser keeps the one the
+// old link had; and on a sample of pages
+// (testdata/part3-ids.txt) the page it lands on has every id part 3's
+// page had, so the fragment still finds its place.
+func TestEveryPart3AddressStillReachesAPage(t *testing.T) {
+ tree := builtSite(t)
+ h := newSiteHandler(t, tree)
+ redirected, stayed := 0, 0
+ for _, old := range lines(t, "part3-addresses.txt") {
+ want := part3Home(old)
+ if want == "" {
+ t.Errorf("%s is not under design-system/; the inventory is part 3's design system", old)
+ continue
+ }
+ asked := []string{"/" + old}
+ if d, ok := strings.CutSuffix(old, "/index.html"); ok {
+ asked = append(asked, "/"+d+"/")
+ }
+ for _, a := range asked {
+ res := serve(h, a)
+ if _, ok := tree[old]; ok {
+ if res.Code != http.StatusOK {
+ t.Errorf("%s is still a page and answers %d", a, res.Code)
+ }
+ stayed++
+ continue
+ }
+ loc := res.Header().Get("Location")
+ if res.Code != http.StatusMovedPermanently || loc != want {
+ t.Errorf("%s answers %d, Location %q; want 301 to %s", a, res.Code, loc, want)
+ continue
+ }
+ if strings.Contains(loc, "#") {
+ t.Errorf("%s: the Location %s has a fragment, which replaces the one the link had", a, loc)
+ }
+ there := serve(h, loc)
+ if there.Code != http.StatusOK || !strings.HasPrefix(there.Header().Get("Content-Type"), "text/html") {
+ t.Errorf("%s redirects to %s, which answers %d %s; one hop must reach the page", a, loc, there.Code, there.Header().Get("Content-Type"))
+ }
+ redirected++
+ }
+ }
+ t.Logf("%d part 3 addresses redirect to their pages in one hop, %d are still pages", redirected, stayed)
+
+ for _, l := range lines(t, "part3-ids.txt") {
+ f := strings.Fields(l)
+ old, ids := f[0], f[1:]
+ loc := serve(h, "/"+old).Header().Get("Location")
+ if loc == "" {
+ t.Errorf("%s does not redirect, so its ids cannot be compared", old)
+ continue
+ }
+ res := serve(h, loc)
+ if res.Code != http.StatusOK {
+ t.Errorf("%s: %s answers %d", old, loc, res.Code)
+ continue
+ }
+ have := map[string]bool{}
+ walk(parse(t, res.Body.Bytes()), func(n *xhtml.Node) {
+ if id := attr(n, "id"); id != "" {
+ have[id] = true
+ }
+ })
+ for _, id := range ids {
+ if !have[id] {
+ t.Errorf("%s#%s lands on %s#%s, and that page has no element with that id", old, id, loc, id)
+ }
+ }
+ }
+}
+
+// TestNoRedirectShadowsAPage: no rule in the site's _redirects matches
+// the path of anything the site serves, a file or an index's directory,
+// with or without its slash; and none is forced. The edge serves a file
+// before any rule not forced, so a rule that matched a page would be
+// dead today, but it is proved here not to match at all, so no page's
+// address depends on that order. Every page then answers 200 through
+// the test server.
+func TestNoRedirectShadowsAPage(t *testing.T) {
+ tree := builtSite(t)
+ rules, err := parseRedirects(tree["_redirects"])
+ if err != nil {
+ t.Fatalf("_redirects: %v", err)
+ }
+ if len(rules) < 2 {
+ t.Fatalf("_redirects holds %d rules; the release rewrite and the design system's moved pages are at least two", len(rules))
+ }
+ for _, r := range rules {
+ if r.force {
+ t.Errorf("_redirects line %d is forced, so it would answer for a file", r.line)
+ }
+ }
+ h := newSiteHandler(t, tree)
+ for name := range tree {
+ if name == "_redirects" {
+ continue
+ }
+ paths := []string{"/" + name}
+ if d, ok := strings.CutSuffix("/"+name, "/index.html"); ok {
+ paths = append(paths, d+"/")
+ }
+ for _, p := range paths {
+ for _, r := range rules {
+ if _, ok := r.match("", segments(path.Clean(p))); ok {
+ t.Errorf("_redirects line %d matches %s, which the site serves", r.line, p)
+ }
+ }
+ }
+ if res := serve(h, "/"+name); res.Code != http.StatusOK {
+ t.Errorf("/%s answers %d", name, res.Code)
+ }
+ }
+}
+
+// TestNoRedirectLeadsToAnotherRule: no redirect's destination, whatever
+// its placeholders are given, can match the source of any rule, so no
+// address, part 3's or any other, is redirected twice or in a loop.
+// A placeholder in a destination stands for any one segment and :splat
+// for any number, so this holds for every request, not only the
+// addresses the inventory lists.
+func TestNoRedirectLeadsToAnotherRule(t *testing.T) {
+ rules, err := parseRedirects(builtSite(t)["_redirects"])
+ if err != nil {
+ t.Fatalf("_redirects: %v", err)
+ }
+ for _, from := range rules {
+ if !isRedirect(from.status) || from.prefix != "" {
+ continue
+ }
+ dest := segments(path.Clean("/" + from.to))
+ for _, to := range rules {
+ if overlaps(dest, to) {
+ t.Errorf("_redirects line %d redirects to %s, which line %d can match: a second hop", from.line, from.to, to.line)
+ }
+ }
+ }
+}
+
+// overlaps reports whether some path fits both dest, a destination's
+// segments in which a segment holding a placeholder stands for any
+// segment and a final :splat for any number of them, and the source
+// of r.
+func overlaps(dest []string, r rule) bool {
+ wild := func(seg string) bool { return strings.Contains(seg, ":") }
+ tail := len(dest) > 0 && dest[len(dest)-1] == ":splat"
+ fixed := dest
+ if tail {
+ fixed = dest[:len(dest)-1]
+ }
+ // Where both have a segment, both must be able to hold the same one.
+ n := min(len(fixed), len(r.segs))
+ for i := 0; i < n; i++ {
+ if !wild(fixed[i]) && !strings.HasPrefix(r.segs[i], ":") && fixed[i] != r.segs[i] {
+ return false
+ }
+ }
+ switch {
+ case len(fixed) == len(r.segs):
+ return true
+ case len(fixed) > len(r.segs):
+ return r.splat
+ default:
+ // The source wants more segments than dest fixes: only a
+ // splat in dest can supply them.
+ return tail
+ }
+}
+
+// TestTheRedirectsFileIsWithinCarlosLimits: the site's _redirects is
+// one `carlos ship` would pack: every line parses under the edge's
+// grammar, and it holds no more than 2,000 rules, 256 KiB and 2,048
+// bytes a line. carlos ship has no dry run for a static tree, so the
+// packer's parse is mirrored in redirects_test.go.
+func TestTheRedirectsFileIsWithinCarlosLimits(t *testing.T) {
+ data := builtSite(t)["_redirects"]
+ rules, err := parseRedirects(data)
+ if err != nil {
+ t.Errorf("carlos ship would refuse _redirects: %v", err)
+ }
+ if len(rules) > redirectsMaxRules {
+ t.Errorf("_redirects holds %d rules; the edge accepts %d", len(rules), redirectsMaxRules)
+ }
+ if len(data) > redirectsMaxBytes {
+ t.Errorf("_redirects is %d bytes; the edge accepts %d", len(data), redirectsMaxBytes)
+ }
+ longest := 0
+ for i, l := range strings.Split(string(data), "\n") {
+ longest = max(longest, len(l))
+ if len(l) > redirectsMaxLine {
+ t.Errorf("_redirects line %d is %d bytes; the edge accepts %d", i+1, len(l), redirectsMaxLine)
+ }
+ }
+ t.Logf("_redirects: %d rules, %d bytes, longest line %d bytes", len(rules), len(data), longest)
+}
diff --git a/internal/www/redirects_test.go b/internal/www/redirects_test.go
new file mode 100644
index 0000000..d8fc6b6
--- /dev/null
+++ b/internal/www/redirects_test.go
@@ -0,0 +1,394 @@
+package www_test
+
+// The _redirects grammar and matching of CARLOS's static edge, mirrored
+// from internal/edge/redirects.go in carlos/platform (origin/main,
+// a8b52609) so the test server answers as the edge does and the tests
+// can refuse a file `carlos ship` would refuse. Only the edge's own
+// policy around the rules (its probe guard) is left out. When the edge
+// changes its grammar, this changes with it.
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+)
+
+// The edge's limits on one _redirects file.
+const (
+ redirectsMaxBytes = 256 << 10
+ redirectsMaxRules = 2000
+ redirectsMaxLine = 2048
+)
+
+// rule is one parsed line of a _redirects file.
+type rule struct {
+ line int // 1-based line number
+ host string // lowercased; "" matches any host
+ segs []string // source path segments; ":name" captures one
+ splat bool // the source ends in "*"
+ prefix string // a URL destination's "scheme://host", never substituted
+ to string // the destination template after prefix
+ status int
+ force bool // "!": applies even when a file exists at the request path
+}
+
+// isRedirect reports whether status is one a redirect rule may answer with.
+func isRedirect(status int) bool {
+ switch status {
+ case 301, 302, 303, 307, 308:
+ return true
+ }
+ return false
+}
+
+// parseRedirects parses a _redirects file as `carlos ship` does: every
+// line it cannot use is an error, named by number.
+func parseRedirects(data []byte) ([]rule, error) {
+ if len(data) > redirectsMaxBytes {
+ return nil, fmt.Errorf("_redirects is %d bytes; the limit is %d", len(data), redirectsMaxBytes)
+ }
+ var rules []rule
+ var errs []error
+ text := strings.TrimPrefix(string(data), "\ufeff")
+ for i, line := range strings.Split(text, "\n") {
+ n := i + 1
+ line = strings.TrimSpace(strings.TrimSuffix(line, "\r"))
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ if len(line) > redirectsMaxLine {
+ errs = append(errs, fmt.Errorf("line %d: longer than %d characters", n, redirectsMaxLine))
+ continue
+ }
+ r, err := parseRedirectLine(line)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("line %d: %v", n, err))
+ continue
+ }
+ if len(rules) == redirectsMaxRules {
+ errs = append(errs, fmt.Errorf("line %d: more than %d rules", n, redirectsMaxRules))
+ continue
+ }
+ r.line = n
+ rules = append(rules, r)
+ }
+ return rules, errors.Join(errs...)
+}
+
+func parseRedirectLine(line string) (rule, error) {
+ var fields []string
+ for _, f := range strings.Fields(line) {
+ if strings.HasPrefix(f, "#") {
+ break
+ }
+ fields = append(fields, f)
+ }
+ var r rule
+ switch {
+ case len(fields) < 2:
+ return r, errors.New("want a source and a destination")
+ case len(fields) > 3:
+ if strings.Contains(fields[3], "=") {
+ return r, fmt.Errorf("conditions such as %q are not supported", fields[3])
+ }
+ return r, fmt.Errorf("unexpected %q after the status", fields[3])
+ }
+
+ r.status = 301
+ if len(fields) == 3 {
+ st := fields[2]
+ if s, ok := strings.CutSuffix(st, "!"); ok {
+ r.force, st = true, s
+ }
+ code, err := strconv.Atoi(st)
+ if err != nil || !(isRedirect(code) || code == 200 || code == 404) {
+ return r, fmt.Errorf("status %q: want 301, 302, 303, 307, 308, 200 or 404, optionally followed by !", fields[2])
+ }
+ r.status = code
+ }
+
+ src := fields[0]
+ if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
+ u, err := url.Parse(src)
+ if err != nil || u.Host == "" || u.User != nil || u.Port() != "" || u.RawQuery != "" || u.Fragment != "" {
+ return r, fmt.Errorf("source %q: want a URL with only a host and a path", src)
+ }
+ r.host = hostName(u.Hostname())
+ src = u.Path
+ if src == "" {
+ src = "/"
+ }
+ }
+ if !strings.HasPrefix(src, "/") {
+ return r, fmt.Errorf("source %q: want a path starting with / or an http(s) URL", fields[0])
+ }
+ if strings.ContainsAny(src, "?#") {
+ return r, fmt.Errorf("source %q: query strings and fragments are not matched", fields[0])
+ }
+ names := map[string]bool{}
+ trimmed := strings.Trim(src, "/")
+ for _, seg := range strings.Split(trimmed, "/") {
+ switch {
+ case seg == "":
+ if trimmed != "" {
+ return r, fmt.Errorf("source %q: empty path segment", fields[0])
+ }
+ case r.splat:
+ return r, fmt.Errorf("source %q: * must be the last segment", fields[0])
+ case seg == "*":
+ r.splat = true
+ case strings.Contains(seg, "*"):
+ return r, fmt.Errorf("source %q: * must be a whole segment", fields[0])
+ case strings.HasPrefix(seg, ":"):
+ name := seg[1:]
+ if !validPlaceholder(name) || name == "splat" {
+ return r, fmt.Errorf("source %q: bad placeholder %q", fields[0], seg)
+ }
+ if names[name] {
+ return r, fmt.Errorf("source %q: placeholder %q appears twice", fields[0], seg)
+ }
+ names[name] = true
+ r.segs = append(r.segs, seg)
+ default:
+ dec, err := url.PathUnescape(seg)
+ if err != nil || strings.Contains(dec, "/") {
+ return r, fmt.Errorf("source %q: bad percent-encoding in %q", fields[0], seg)
+ }
+ if strings.HasPrefix(dec, ":") {
+ return r, fmt.Errorf("source %q: %q would read as a placeholder", fields[0], seg)
+ }
+ if dec == "." || dec == ".." {
+ return r, fmt.Errorf("source %q: dot segments never match", fields[0])
+ }
+ r.segs = append(r.segs, dec)
+ }
+ }
+
+ dst := fields[1]
+ switch {
+ case strings.HasPrefix(dst, "http://") || strings.HasPrefix(dst, "https://"):
+ if !isRedirect(r.status) {
+ return r, fmt.Errorf("destination %q: a %d serves a file from this site, so it takes a path, not a URL", dst, r.status)
+ }
+ u, err := url.Parse(dst)
+ if err != nil || u.Host == "" || u.User != nil {
+ return r, fmt.Errorf("destination %q: not a usable URL", dst)
+ }
+ i := len(u.Scheme) + len("://") + len(u.Host)
+ if !strings.EqualFold(dst[:i], u.Scheme+"://"+u.Host) {
+ return r, fmt.Errorf("destination %q: not a usable URL", dst)
+ }
+ r.prefix, r.to = dst[:i], dst[i:]
+ if r.to != "" && !strings.ContainsAny(r.to[:1], "/?#") {
+ return r, fmt.Errorf("destination %q: not a usable URL", dst)
+ }
+ case strings.HasPrefix(dst, "//") || strings.HasPrefix(dst, "/\\"):
+ return r, fmt.Errorf("destination %q: name another host with https://", dst)
+ case strings.HasPrefix(dst, "/"):
+ r.to = dst
+ default:
+ return r, fmt.Errorf("destination %q: want a path starting with / or an http(s) URL", dst)
+ }
+ return r, nil
+}
+
+// validPlaceholder: a placeholder name is letters, digits and underscores.
+func validPlaceholder(name string) bool {
+ if name == "" {
+ return false
+ }
+ for _, c := range name {
+ if !(c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') {
+ return false
+ }
+ }
+ return true
+}
+
+// hostName is a request's host as the edge compares it: lowercased,
+// with no port and no trailing dot. The edge also folds an
+// internationalised name to ASCII, which no host here needs.
+func hostName(h string) string {
+ if host, _, err := net.SplitHostPort(h); err == nil {
+ h = host
+ }
+ return strings.TrimSuffix(strings.ToLower(h), ".")
+}
+
+// segments splits a cleaned, rooted path into its segments; "/" has none.
+func segments(p string) []string {
+ if p == "/" {
+ return nil
+ }
+ return strings.Split(p[1:], "/")
+}
+
+// match reports whether the rule applies to a request for host whose
+// cleaned path splits into reqSegs, and returns what the source captured.
+func (r *rule) match(host string, reqSegs []string) (map[string]string, bool) {
+ if r.host != "" && r.host != host {
+ return nil, false
+ }
+ if len(reqSegs) < len(r.segs) || !r.splat && len(reqSegs) != len(r.segs) {
+ return nil, false
+ }
+ caps := map[string]string{}
+ for i, seg := range r.segs {
+ if strings.HasPrefix(seg, ":") {
+ caps[seg[1:]] = reqSegs[i]
+ } else if seg != reqSegs[i] {
+ return nil, false
+ }
+ }
+ if r.splat {
+ caps["splat"] = strings.Join(reqSegs[len(r.segs):], "/")
+ }
+ return caps, true
+}
+
+// expand substitutes the captures into the destination template, each
+// percent-encoded for a Location when escape is set, raw for a
+// rewrite's file lookup. A ":name" the source did not capture stays.
+func expand(t string, caps map[string]string, escape bool) string {
+ var b strings.Builder
+ inQuery := false
+ for i := 0; i < len(t); {
+ c := t[i]
+ if c == '?' || c == '#' {
+ inQuery = true
+ }
+ if c != ':' {
+ b.WriteByte(c)
+ i++
+ continue
+ }
+ j := i + 1
+ for j < len(t) && validPlaceholder(t[j:j+1]) {
+ j++
+ }
+ v, ok := caps[t[i+1:j]]
+ if !ok {
+ b.WriteString(t[i:j])
+ i = j
+ continue
+ }
+ switch {
+ case !escape:
+ b.WriteString(v)
+ case inQuery:
+ b.WriteString(url.QueryEscape(v))
+ default:
+ parts := strings.Split(v, "/")
+ for k, p := range parts {
+ parts[k] = url.PathEscape(p)
+ }
+ b.WriteString(strings.Join(parts, "/"))
+ }
+ i = j
+ }
+ return b.String()
+}
+
+// action is what a matched rule asks for: a redirect's status and
+// Location, or a rewrite's status and file.
+type action struct {
+ status int
+ location string
+ file string
+}
+
+// resolve finds the first rule that applies to host and the cleaned
+// path p. exists is whether the tree has a file at p, which shadows
+// every rule not forced. rawPath and rawQuery are the request's own, so
+// a redirect to the URL asked for is skipped rather than looped.
+func resolve(rules []rule, host, p, rawPath, rawQuery string, exists bool) (action, bool) {
+ reqSegs := segments(p)
+ for i := range rules {
+ r := &rules[i]
+ if exists && !r.force {
+ continue
+ }
+ caps, ok := r.match(host, reqSegs)
+ if !ok {
+ continue
+ }
+ if sp := caps["splat"]; sp != "" && strings.HasSuffix(rawPath, "/") {
+ caps["splat"] = sp + "/"
+ }
+ if !isRedirect(r.status) {
+ to := r.to
+ if k := strings.IndexAny(to, "?#"); k >= 0 {
+ to = to[:k]
+ }
+ return action{status: r.status, file: path.Clean("/" + expand(to, caps, false))}, true
+ }
+ loc := expand(r.to, caps, true)
+ frag := ""
+ if k := strings.IndexByte(loc, '#'); k >= 0 {
+ loc, frag = loc[:k], loc[k:]
+ }
+ if r.prefix == "" {
+ pth, q, hasQ := strings.Cut(loc, "?")
+ trailing := strings.HasSuffix(pth, "/")
+ pth = path.Clean("/" + pth)
+ if trailing && pth != "/" {
+ pth += "/"
+ }
+ loc = pth
+ if hasQ {
+ loc += "?" + q
+ }
+ }
+ if !strings.Contains(loc, "?") && rawQuery != "" {
+ loc += "?" + rawQuery
+ }
+ if r.prefix == "" || hostOf(r.prefix) == host {
+ pth, q, _ := strings.Cut(loc, "?")
+ if pth == "" {
+ pth = "/"
+ }
+ if u, err := url.Parse(pth); err == nil && u.Path == rawPath && q == rawQuery {
+ continue
+ }
+ }
+ return action{status: r.status, location: r.prefix + loc + frag}, true
+ }
+ return action{}, false
+}
+
+// names reports whether some rule names the cleaned path p outright:
+// no splat and no ":name", its segments p's.
+func names(rules []rule, host, p string) bool {
+ reqSegs := segments(p)
+outer:
+ for i := range rules {
+ r := &rules[i]
+ if r.splat || len(r.segs) != len(reqSegs) || r.host != "" && r.host != host {
+ continue
+ }
+ for j, seg := range r.segs {
+ if strings.HasPrefix(seg, ":") || seg != reqSegs[j] {
+ continue outer
+ }
+ }
+ return true
+ }
+ return false
+}
+
+// redirectsSlashForm reports whether the rules would redirect the slash
+// form of rawPath, given that the directory there exists.
+func redirectsSlashForm(rules []rule, host, p, rawPath, rawQuery string) bool {
+ act, ok := resolve(rules, host, p, rawPath+"/", rawQuery, true)
+ return ok && act.location != ""
+}
+
+// hostOf is the host of a "scheme://host[:port]" prefix.
+func hostOf(prefix string) string {
+ _, h, _ := strings.Cut(prefix, "://")
+ return hostName(h)
+}
diff --git a/internal/www/serve_test.go b/internal/www/serve_test.go
index 64bf114..55ceae1 100644
--- a/internal/www/serve_test.go
+++ b/internal/www/serve_test.go
@@ -30,36 +30,32 @@ func siteSource(t *testing.T) *os.Root {
}
// builtSite is the whole site as the edge would receive it: site.Build
-// over the committed releases and the pages www.Pages assembles from
-// the real site source.
+// over the committed releases, the pages www.Pages assembles from the
+// real site source and their rules, as fiesta site builds it.
func builtSite(t *testing.T) map[string][]byte {
t.Helper()
pages, err := www.Pages(fiesta.FS(), siteSource(t).FS())
if err != nil {
t.Fatalf("Pages: %v", err)
}
- tree, err := site.Build(os.DirFS("../../releases"), www.FS(pages))
+ tree, err := site.Build(os.DirFS("../../releases"), www.FS(pages), www.Redirects(pages)...)
if err != nil {
t.Fatalf("site.Build: %v", err)
}
return tree
}
-// rule is one line of _redirects: a from pattern, a to path, and the
-// status. Only what the edge applies to this site is understood: a
-// from ending in /* with a :splat in to, served as a 200 rewrite.
-type rule struct {
- from, to string
- status int
-}
-
-// siteHandler serves a site.Build tree the way the static edge does.
-// A directory URL serves its index.html; a real file wins over any
-// rewrite; a _redirects rule with status 200 is applied as a rewrite;
-// anything else is a 404; and any method other than GET or HEAD is a
-// 405, as on the edge, so a form a page should never submit cannot
-// pass a test by accident. It records every request's path and can be
-// told to delay one path, for the first-paint drive.
+// siteHandler serves a site.Build tree the way CARLOS's static edge
+// does (serveTree in internal/edge/static.go). A directory URL serves
+// its index.html, and one asked for without its slash is sent to the
+// slash form; a real file wins over every _redirects rule not forced;
+// the first rule that matches applies, a 200 or 404 serving another
+// file of the tree and a 301, 302, 303, 307 or 308 answering with its
+// Location (redirects_test.go); anything else is a 404; and any method
+// other than GET or HEAD is a 405, as on the edge, so a form a page
+// should never submit cannot pass a test by accident. It records every
+// request's path and can be told to delay one path, for the
+// first-paint drive.
type siteHandler struct {
tree map[string][]byte
rules []rule
@@ -72,16 +68,11 @@ type siteHandler struct {
func newSiteHandler(t *testing.T, tree map[string][]byte) *siteHandler {
t.Helper()
h := &siteHandler{tree: tree, delays: map[string]time.Duration{}}
- for _, line := range strings.Split(string(tree["_redirects"]), "\n") {
- f := strings.Fields(line)
- if len(f) == 0 || strings.HasPrefix(f[0], "#") {
- continue
- }
- if len(f) != 3 || f[2] != "200" {
- t.Fatalf("_redirects has a rule this server does not understand: %q", line)
- }
- h.rules = append(h.rules, rule{from: f[0], to: f[1], status: 200})
+ rules, err := parseRedirects(tree["_redirects"])
+ if err != nil {
+ t.Fatalf("_redirects: %v", err)
}
+ h.rules = rules
return h
}
@@ -106,32 +97,23 @@ func (h *siteHandler) Seen(reset bool) []string {
return out
}
-// file finds the tree entry for a request path: the file itself, or
-// the directory's index.
-func (h *siteHandler) file(p string) ([]byte, string, bool) {
- name := strings.TrimPrefix(p, "/")
- if strings.HasSuffix(p, "/") {
- name += "index.html"
- }
- data, ok := h.tree[name]
- return data, name, ok
-}
-
-// rewrite applies the first matching rule to p, or returns it as is.
-func (h *siteHandler) rewrite(p string) (string, bool) {
- for _, r := range h.rules {
- prefix, ok := strings.CutSuffix(r.from, "/*")
- if !ok {
- if p == r.from {
- return r.to, true
- }
- continue
- }
- if rest, ok := strings.CutPrefix(p, prefix+"/"); ok {
- return strings.Replace(r.to, ":splat", rest, 1), true
- }
+// file finds the tree entry for a cleaned, rooted path: the file
+// itself, or the index of the directory there, and whether it was a
+// directory. The rules file answers nothing.
+func (h *siteHandler) file(p string) (data []byte, name string, dir, ok bool) {
+ name = strings.TrimPrefix(p, "/")
+ if name == "_redirects" {
+ return nil, "", false, false
+ }
+ if data, ok = h.tree[name]; ok && name != "" {
+ return data, name, false, true
}
- return p, false
+ index := "index.html"
+ if name != "" {
+ index = name + "/index.html"
+ }
+ data, ok = h.tree[index]
+ return data, index, ok, ok
}
func (h *siteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -144,11 +126,28 @@ func (h *siteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- data, name, ok := h.file(r.URL.Path)
- if !ok {
- if to, matched := h.rewrite(r.URL.Path); matched {
- data, name, ok = h.file(to)
+ host := hostName(r.Host)
+ p := path.Clean("/" + r.URL.Path)
+ status := http.StatusOK
+ data, name, dir, ok := h.file(p)
+ if act, matched := resolve(h.rules, host, p, r.URL.Path, r.URL.RawQuery, ok); matched {
+ if act.location != "" {
+ http.Redirect(w, r, act.location, act.status)
+ return
}
+ // A rewrite is looked up once: the rules are not consulted
+ // again, and a destination with no file is a plain 404.
+ status = act.status
+ data, name, _, ok = h.file(act.file)
+ } else if ok && dir && !strings.HasSuffix(r.URL.Path, "/") &&
+ !names(h.rules, host, p) && !redirectsSlashForm(h.rules, host, p, r.URL.Path, r.URL.RawQuery) {
+ loc := "./" + path.Base(r.URL.EscapedPath()) + "/"
+ if q := r.URL.RawQuery; q != "" {
+ loc += "?" + q
+ }
+ w.Header().Set("Location", loc)
+ w.WriteHeader(http.StatusMovedPermanently)
+ return
}
if !ok {
http.NotFound(w, r)
@@ -163,6 +162,7 @@ func (h *siteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
w.Write(data)
}
@@ -225,3 +225,98 @@ func TestServeSiteServesLikeTheEdge(t *testing.T) {
}
}
}
+
+// TestServeSiteRedirectsLikeTheEdge holds the test server's redirects
+// to CARLOS's (internal/edge/redirects.go): a 301 with its Location, a
+// :name capturing one segment, a final * capturing the rest ("" too)
+// into :splat, a trailing slash ignored in matching and kept on a
+// non-empty splat, the first matching rule winning, a file at the path
+// winning over every rule but a forced one, a directory with an index
+// counting as that file, the query carried over, and a redirect to the
+// very URL asked for skipped. A test of the test double, not of the
+// site.
+func TestServeSiteRedirectsLikeTheEdge(t *testing.T) {
+ h := newSiteHandler(t, map[string][]byte{
+ "_redirects": []byte("# moved pages\n" +
+ "/1/* /1.0.0/:splat 200\n" +
+ "/old/first.html /new/first.html 301\n" +
+ "/old/first.html /never.html 301\n" +
+ "/old/:page /new/:page\n" +
+ "/deep/:a/:b /new/:b/:a.html 302\n" +
+ "/tree/* /new/tree/:splat 301\n" +
+ "/kept.html /new/kept.html 301\n" +
+ "/forced.html /new/forced.html 301!\n" +
+ "/dir /new/dir/ 301\n" +
+ "/same/* /same/:splat 301\n"),
+ "1.0.0/fiesta.css": []byte("newest"),
+ "kept.html": []byte("kept"),
+ "forced.html": []byte("forced"),
+ "dir/index.html": []byte("dir"),
+ "new/first.html": []byte("first"),
+ "new/second.html": []byte("second"),
+ "new/kept.html": []byte("new kept"),
+ "new/tree/index.html": []byte("tree"),
+ })
+ for _, c := range []struct {
+ path string
+ status int
+ location string
+ }{
+ {"/1/fiesta.css", 200, ""},
+ {"/old/first.html", 301, "/new/first.html"},
+ {"/old/second.html", 301, "/new/second.html"},
+ {"/old/second.html?x=1", 301, "/new/second.html?x=1"},
+ {"/old/", 404, ""},
+ {"/old/a/b", 404, ""},
+ {"/deep/one/two", 302, "/new/two/one.html"},
+ {"/tree", 301, "/new/tree/"},
+ {"/tree/", 301, "/new/tree/"},
+ {"/tree/a/b.html", 301, "/new/tree/a/b.html"},
+ {"/tree/a/", 301, "/new/tree/a/"},
+ {"/kept.html", 200, ""},
+ {"/forced.html", 301, "/new/forced.html"},
+ {"/dir/", 200, ""},
+ {"/same/x", 404, ""},
+ } {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, c.path, nil))
+ if loc := rec.Header().Get("Location"); rec.Code != c.status || loc != c.location {
+ t.Errorf("GET %s: %d, Location %q; want %d, Location %q", c.path, rec.Code, loc, c.status, c.location)
+ }
+ }
+}
+
+// TestServeSiteRefusesARuleCarlosWouldRefuse: the test server reads
+// _redirects with CARLOS's grammar, so a line `carlos ship` would
+// refuse is refused here too, by line, and a file the edge would serve
+// is not mistaken for one it would not.
+func TestServeSiteRefusesARuleCarlosWouldRefuse(t *testing.T) {
+ for _, line := range []string{
+ "/only-a-source",
+ "/a /b 418",
+ "/a /b 301 Country=ie",
+ "/a/*/b /c",
+ "/a/:x/:x /c",
+ "/a?q=1 /b",
+ "relative /b",
+ "/a //elsewhere.example/",
+ "/a https://example.com/ 200",
+ "/a /" + strings.Repeat("b", 2048),
+ } {
+ if _, err := parseRedirects([]byte(line + "\n")); err == nil {
+ t.Errorf("%q parsed; CARLOS refuses it", line)
+ }
+ }
+ for _, line := range []string{
+ "/a /b",
+ "/a/:x /b/:x 302",
+ "/a/* /b/:splat 200",
+ "/a https://example.com/b 301!",
+ "/a /b 404 # a comment",
+ "# a comment alone",
+ } {
+ if _, err := parseRedirects([]byte(line + "\n")); err != nil {
+ t.Errorf("%q was refused (%v); CARLOS accepts it", line, err)
+ }
+ }
+}
diff --git a/internal/www/testdata/part3-addresses.txt b/internal/www/testdata/part3-addresses.txt
new file mode 100644
index 0000000..d976682
--- /dev/null
+++ b/internal/www/testdata/part3-addresses.txt
@@ -0,0 +1,1439 @@
+# Every HTML page part 3 of Fiesta published under /design-system/ on
+# fiesta.rastrillo.org, one tree path a line: the site built from
+# 6af218e, main when part 3 shipped. TestEveryPart3AddressStillReachesAPage
+# holds the site to every one of them. To regenerate, from a checkout
+# of this repository:
+#
+# d=$(mktemp -d) && git archive 6af218e | tar -x -C "$d" &&
+# make -C "$d" site >/dev/null &&
+# (cd "$d/.build/site/public" && find design-system -name '*.html' | LC_ALL=C sort) &&
+# rm -rf "$d"
+design-system/day/actor.html
+design-system/day/api-tokens.html
+design-system/day/back-nav.html
+design-system/day/badge.html
+design-system/day/box.html
+design-system/day/bulk-bar.html
+design-system/day/button.html
+design-system/day/callout.html
+design-system/day/choice-field.html
+design-system/day/confirm-form.html
+design-system/day/detail-list.html
+design-system/day/dropdown.html
+design-system/day/empty-state.html
+design-system/day/error-page.html
+design-system/day/field-check.html
+design-system/day/field-date.html
+design-system/day/field-daterange.html
+design-system/day/field-datetime.html
+design-system/day/field-money.html
+design-system/day/field-select.html
+design-system/day/field-text.html
+design-system/day/field-textarea.html
+design-system/day/field-time.html
+design-system/day/field-url.html
+design-system/day/field.html
+design-system/day/form-error.html
+design-system/day/form-foot.html
+design-system/day/form-layout.html
+design-system/day/format-abbr.html
+design-system/day/format-addresses.html
+design-system/day/format-dates.html
+design-system/day/format-durations.html
+design-system/day/format-filesizes.html
+design-system/day/format-identifiers.html
+design-system/day/format-money.html
+design-system/day/format-numbers.html
+design-system/day/format-output.html
+design-system/day/format-people.html
+design-system/day/format-ratios.html
+design-system/day/frames/actor/a-person.html
+design-system/day/frames/actor/an-agent-acting-for-a-person.html
+design-system/day/frames/api-tokens/four-tokens-two-of-them-live.html
+design-system/day/frames/api-tokens/just-created-the-secret-shown-once.html
+design-system/day/frames/api-tokens/no-tokens-yet.html
+design-system/day/frames/back-nav/back-to-a-record.html
+design-system/day/frames/badge/negative.html
+design-system/day/frames/badge/neutral.html
+design-system/day/frames/badge/no-tone-quiet-default.html
+design-system/day/frames/badge/positive.html
+design-system/day/frames/badge/warning.html
+design-system/day/frames/box/default.html
+design-system/day/frames/bulk-bar/three-rows-selected.html
+design-system/day/frames/button/default.html
+design-system/day/frames/callout/info.html
+design-system/day/frames/callout/negative-announced-as-an-alert.html
+design-system/day/frames/callout/negative.html
+design-system/day/frames/callout/positive.html
+design-system/day/frames/callout/warning.html
+design-system/day/frames/choice-field/pick-any.html
+design-system/day/frames/choice-field/pick-one.html
+design-system/day/frames/confirm-form/destructive.html
+design-system/day/frames/confirm-form/ordinary-default-cancel-wording.html
+design-system/day/frames/detail-list/four-facts-one-machine-ish.html
+design-system/day/frames/dropdown/a-filter-menu-with-one-choice-applied.html
+design-system/day/frames/dropdown/menu-idiom.html
+design-system/day/frames/empty-state/sentence-only.html
+design-system/day/frames/empty-state/with-a-post-call-to-action.html
+design-system/day/frames/error-page/403-forbidden.html
+design-system/day/frames/error-page/404-not-found.html
+design-system/day/frames/error-page/422-unprocessable.html
+design-system/day/frames/error-page/500-server-error.html
+design-system/day/frames/error-page/503-unavailable.html
+design-system/day/frames/error-page/anything-else-the-generic-pair.html
+design-system/day/frames/field-check/disabled.html
+design-system/day/frames/field-check/off.html
+design-system/day/frames/field-check/on.html
+design-system/day/frames/field-date/bounded.html
+design-system/day/frames/field-date/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/day/frames/field-date/plain-the-bare-native-input.html
+design-system/day/frames/field-date/required-with-an-error.html
+design-system/day/frames/field-daterange/date-and-time-seeded.html
+design-system/day/frames/field-daterange/dates-only-with-an-error-on-the-end.html
+design-system/day/frames/field-datetime/bounded.html
+design-system/day/frames/field-datetime/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/day/frames/field-datetime/plain-the-bare-native-input.html
+design-system/day/frames/field-datetime/required-with-an-error.html
+design-system/day/frames/field-money/bare.html
+design-system/day/frames/field-money/with-a-hint.html
+design-system/day/frames/field-money/with-an-error.html
+design-system/day/frames/field-select/hand-written-with-optgroups.html
+design-system/day/frames/field-select/required-with-an-error.html
+design-system/day/frames/field-select/three-options-a-native-select.html
+design-system/day/frames/field-select/twelve-options-plain-the-enhancement-opted-out.html
+design-system/day/frames/field-select/twelve-options-the-filterable-combobox.html
+design-system/day/frames/field-text/bare.html
+design-system/day/frames/field-text/required.html
+design-system/day/frames/field-text/the-headline-field.html
+design-system/day/frames/field-text/with-a-hint.html
+design-system/day/frames/field-text/with-an-error.html
+design-system/day/frames/field-textarea/bare.html
+design-system/day/frames/field-textarea/required.html
+design-system/day/frames/field-textarea/with-a-hint.html
+design-system/day/frames/field-textarea/with-an-error.html
+design-system/day/frames/field-time/bounded.html
+design-system/day/frames/field-time/enhanced-input-type-half-seven-or-noon.html
+design-system/day/frames/field-time/plain-the-bare-native-input.html
+design-system/day/frames/field-time/required-with-an-error.html
+design-system/day/frames/field-url/bare.html
+design-system/day/frames/field-url/with-a-hint.html
+design-system/day/frames/field-url/with-an-error.html
+design-system/day/frames/field/bare.html
+design-system/day/frames/field/required.html
+design-system/day/frames/field/short.html
+design-system/day/frames/field/the-headline-field.html
+design-system/day/frames/field/with-a-hint.html
+design-system/day/frames/field/with-an-error.html
+design-system/day/frames/field/with-help-under-the-control.html
+design-system/day/frames/form-error/a-rejected-submission.html
+design-system/day/frames/form-foot/idle-the-button-before-anything-happens.html
+design-system/day/frames/form-foot/save-and-cancel.html
+design-system/day/frames/form-foot/submit-only.html
+design-system/day/frames/form-foot/working-what-busy-js-writes-on-the-way-out.html
+design-system/day/frames/form-layout/default.html
+design-system/day/frames/format-abbr/default.html
+design-system/day/frames/format-addresses/default.html
+design-system/day/frames/format-dates/default.html
+design-system/day/frames/format-durations/default.html
+design-system/day/frames/format-filesizes/default.html
+design-system/day/frames/format-identifiers/default.html
+design-system/day/frames/format-money/default.html
+design-system/day/frames/format-numbers/default.html
+design-system/day/frames/format-output/default.html
+design-system/day/frames/format-people/default.html
+design-system/day/frames/format-ratios/default.html
+design-system/day/frames/help/default.html
+design-system/day/frames/job-status/failed.html
+design-system/day/frames/job-status/finished.html
+design-system/day/frames/job-status/running.html
+design-system/day/frames/layout-column/default.html
+design-system/day/frames/layout-console-page/default.html
+design-system/day/frames/layout-console/default.html
+design-system/day/frames/layout-sidebar-page/default.html
+design-system/day/frames/layout-sidebar/default.html
+design-system/day/frames/layout-stage/default.html
+design-system/day/frames/layout-topbar/default.html
+design-system/day/frames/list-bar-search/empty-accessible-name-from-the-catalog.html
+design-system/day/frames/list-bar-search/with-a-query-on-page-4.html
+design-system/day/frames/list-bar-search/with-a-query.html
+design-system/day/frames/list-bar/search-with-a-filter-dropdown.html
+design-system/day/frames/list-grid/default.html
+design-system/day/frames/list-row-action/lead-marker-status-and-an-action.html
+design-system/day/frames/list-row-action/name-and-meta-line-only.html
+design-system/day/frames/list-search-submit/default-label.html
+design-system/day/frames/locale-menu/twelve-languages-current-highlighted.html
+design-system/day/frames/media-grid/screenshots-a-logo-and-a-banner.html
+design-system/day/frames/media-grid/select-mode-one-tile-selected.html
+design-system/day/frames/meter/empty.html
+design-system/day/frames/meter/full.html
+design-system/day/frames/meter/nearly-full.html
+design-system/day/frames/meter/over-budget.html
+design-system/day/frames/meter/part-way.html
+design-system/day/frames/modal/default.html
+design-system/day/frames/notice/a-saved-message.html
+design-system/day/frames/page-header/title-only.html
+design-system/day/frames/page-header/title-subhead-and-a-primary-action.html
+design-system/day/frames/pagination/first-page-default-accessible-name.html
+design-system/day/frames/pagination/middle-of-a-long-list.html
+design-system/day/frames/person/large-the-show-header-size.html
+design-system/day/frames/person/name-and-email.html
+design-system/day/frames/person/no-initial.html
+design-system/day/frames/row-menu/links-a-form-and-a-destructive-item.html
+design-system/day/frames/row-menu/links-only-for-a-list-inside-a-selection-form.html
+design-system/day/frames/scheme-menu/standalone.html
+design-system/day/frames/scheme-menu/three-choices-dark-current.html
+design-system/day/frames/seg-tabs/two-sections.html
+design-system/day/frames/selbox/default.html
+design-system/day/frames/shell-sidebar/default.html
+design-system/day/frames/shell-topbar/default.html
+design-system/day/frames/signin/a-passkey.html
+design-system/day/frames/signin/after-the-link-is-sent-in-another-browser.html
+design-system/day/frames/signin/after-the-link-is-sent.html
+design-system/day/frames/signin/an-address-that-does-not-look-right.html
+design-system/day/frames/signin/asking-for-an-address.html
+design-system/day/frames/signin/coming-back-with-an-email-link.html
+design-system/day/frames/signin/coming-back-with-keymail.html
+design-system/day/frames/signin/email-and-password.html
+design-system/day/frames/signin/google-apple-and-the-rest.html
+design-system/day/frames/signin/on-the-way-to-keymail.html
+design-system/day/frames/signin/when-keymail-could-not-confirm-it.html
+design-system/day/frames/signin/when-keymail-was-offered-and-a-link-went-out.html
+design-system/day/frames/stat-band/default.html
+design-system/day/frames/stat/a-shortened-number.html
+design-system/day/frames/stat/a-smaller-number.html
+design-system/day/frames/stat/the-big-number.html
+design-system/day/frames/stat/when-going-down-is-good.html
+design-system/day/frames/stat/when-going-up-is-bad.html
+design-system/day/frames/stat/when-you-cannot-say.html
+design-system/day/frames/status-pill/negative.html
+design-system/day/frames/status-pill/neutral.html
+design-system/day/frames/status-pill/positive.html
+design-system/day/frames/status-pill/warning.html
+design-system/day/frames/tblock/default.html
+design-system/day/help.html
+design-system/day/icons.html
+design-system/day/index.html
+design-system/day/job-status.html
+design-system/day/layout-column.html
+design-system/day/layout-console-page.html
+design-system/day/layout-console.html
+design-system/day/layout-sidebar-page.html
+design-system/day/layout-sidebar.html
+design-system/day/layout-stage.html
+design-system/day/layout-topbar.html
+design-system/day/list-bar-search.html
+design-system/day/list-bar.html
+design-system/day/list-grid.html
+design-system/day/list-row-action.html
+design-system/day/list-search-submit.html
+design-system/day/locale-menu.html
+design-system/day/media-grid.html
+design-system/day/meter.html
+design-system/day/modal.html
+design-system/day/notice.html
+design-system/day/page-header.html
+design-system/day/pagination.html
+design-system/day/person.html
+design-system/day/row-menu.html
+design-system/day/rtl/actor.html
+design-system/day/rtl/api-tokens.html
+design-system/day/rtl/back-nav.html
+design-system/day/rtl/badge.html
+design-system/day/rtl/box.html
+design-system/day/rtl/bulk-bar.html
+design-system/day/rtl/button.html
+design-system/day/rtl/callout.html
+design-system/day/rtl/choice-field.html
+design-system/day/rtl/confirm-form.html
+design-system/day/rtl/detail-list.html
+design-system/day/rtl/dropdown.html
+design-system/day/rtl/empty-state.html
+design-system/day/rtl/error-page.html
+design-system/day/rtl/field-check.html
+design-system/day/rtl/field-date.html
+design-system/day/rtl/field-daterange.html
+design-system/day/rtl/field-datetime.html
+design-system/day/rtl/field-money.html
+design-system/day/rtl/field-select.html
+design-system/day/rtl/field-text.html
+design-system/day/rtl/field-textarea.html
+design-system/day/rtl/field-time.html
+design-system/day/rtl/field-url.html
+design-system/day/rtl/field.html
+design-system/day/rtl/form-error.html
+design-system/day/rtl/form-foot.html
+design-system/day/rtl/form-layout.html
+design-system/day/rtl/format-abbr.html
+design-system/day/rtl/format-addresses.html
+design-system/day/rtl/format-dates.html
+design-system/day/rtl/format-durations.html
+design-system/day/rtl/format-filesizes.html
+design-system/day/rtl/format-identifiers.html
+design-system/day/rtl/format-money.html
+design-system/day/rtl/format-numbers.html
+design-system/day/rtl/format-output.html
+design-system/day/rtl/format-people.html
+design-system/day/rtl/format-ratios.html
+design-system/day/rtl/frames/actor/a-person.html
+design-system/day/rtl/frames/actor/an-agent-acting-for-a-person.html
+design-system/day/rtl/frames/api-tokens/four-tokens-two-of-them-live.html
+design-system/day/rtl/frames/api-tokens/just-created-the-secret-shown-once.html
+design-system/day/rtl/frames/api-tokens/no-tokens-yet.html
+design-system/day/rtl/frames/back-nav/back-to-a-record.html
+design-system/day/rtl/frames/badge/negative.html
+design-system/day/rtl/frames/badge/neutral.html
+design-system/day/rtl/frames/badge/no-tone-quiet-default.html
+design-system/day/rtl/frames/badge/positive.html
+design-system/day/rtl/frames/badge/warning.html
+design-system/day/rtl/frames/box/default.html
+design-system/day/rtl/frames/bulk-bar/three-rows-selected.html
+design-system/day/rtl/frames/button/default.html
+design-system/day/rtl/frames/callout/info.html
+design-system/day/rtl/frames/callout/negative-announced-as-an-alert.html
+design-system/day/rtl/frames/callout/negative.html
+design-system/day/rtl/frames/callout/positive.html
+design-system/day/rtl/frames/callout/warning.html
+design-system/day/rtl/frames/choice-field/pick-any.html
+design-system/day/rtl/frames/choice-field/pick-one.html
+design-system/day/rtl/frames/confirm-form/destructive.html
+design-system/day/rtl/frames/confirm-form/ordinary-default-cancel-wording.html
+design-system/day/rtl/frames/detail-list/four-facts-one-machine-ish.html
+design-system/day/rtl/frames/dropdown/a-filter-menu-with-one-choice-applied.html
+design-system/day/rtl/frames/dropdown/menu-idiom.html
+design-system/day/rtl/frames/empty-state/sentence-only.html
+design-system/day/rtl/frames/empty-state/with-a-post-call-to-action.html
+design-system/day/rtl/frames/error-page/403-forbidden.html
+design-system/day/rtl/frames/error-page/404-not-found.html
+design-system/day/rtl/frames/error-page/422-unprocessable.html
+design-system/day/rtl/frames/error-page/500-server-error.html
+design-system/day/rtl/frames/error-page/503-unavailable.html
+design-system/day/rtl/frames/error-page/anything-else-the-generic-pair.html
+design-system/day/rtl/frames/field-check/disabled.html
+design-system/day/rtl/frames/field-check/off.html
+design-system/day/rtl/frames/field-check/on.html
+design-system/day/rtl/frames/field-date/bounded.html
+design-system/day/rtl/frames/field-date/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/day/rtl/frames/field-date/plain-the-bare-native-input.html
+design-system/day/rtl/frames/field-date/required-with-an-error.html
+design-system/day/rtl/frames/field-daterange/date-and-time-seeded.html
+design-system/day/rtl/frames/field-daterange/dates-only-with-an-error-on-the-end.html
+design-system/day/rtl/frames/field-datetime/bounded.html
+design-system/day/rtl/frames/field-datetime/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/day/rtl/frames/field-datetime/plain-the-bare-native-input.html
+design-system/day/rtl/frames/field-datetime/required-with-an-error.html
+design-system/day/rtl/frames/field-money/bare.html
+design-system/day/rtl/frames/field-money/with-a-hint.html
+design-system/day/rtl/frames/field-money/with-an-error.html
+design-system/day/rtl/frames/field-select/hand-written-with-optgroups.html
+design-system/day/rtl/frames/field-select/required-with-an-error.html
+design-system/day/rtl/frames/field-select/three-options-a-native-select.html
+design-system/day/rtl/frames/field-select/twelve-options-plain-the-enhancement-opted-out.html
+design-system/day/rtl/frames/field-select/twelve-options-the-filterable-combobox.html
+design-system/day/rtl/frames/field-text/bare.html
+design-system/day/rtl/frames/field-text/required.html
+design-system/day/rtl/frames/field-text/the-headline-field.html
+design-system/day/rtl/frames/field-text/with-a-hint.html
+design-system/day/rtl/frames/field-text/with-an-error.html
+design-system/day/rtl/frames/field-textarea/bare.html
+design-system/day/rtl/frames/field-textarea/required.html
+design-system/day/rtl/frames/field-textarea/with-a-hint.html
+design-system/day/rtl/frames/field-textarea/with-an-error.html
+design-system/day/rtl/frames/field-time/bounded.html
+design-system/day/rtl/frames/field-time/enhanced-input-type-half-seven-or-noon.html
+design-system/day/rtl/frames/field-time/plain-the-bare-native-input.html
+design-system/day/rtl/frames/field-time/required-with-an-error.html
+design-system/day/rtl/frames/field-url/bare.html
+design-system/day/rtl/frames/field-url/with-a-hint.html
+design-system/day/rtl/frames/field-url/with-an-error.html
+design-system/day/rtl/frames/field/bare.html
+design-system/day/rtl/frames/field/required.html
+design-system/day/rtl/frames/field/short.html
+design-system/day/rtl/frames/field/the-headline-field.html
+design-system/day/rtl/frames/field/with-a-hint.html
+design-system/day/rtl/frames/field/with-an-error.html
+design-system/day/rtl/frames/field/with-help-under-the-control.html
+design-system/day/rtl/frames/form-error/a-rejected-submission.html
+design-system/day/rtl/frames/form-foot/idle-the-button-before-anything-happens.html
+design-system/day/rtl/frames/form-foot/save-and-cancel.html
+design-system/day/rtl/frames/form-foot/submit-only.html
+design-system/day/rtl/frames/form-foot/working-what-busy-js-writes-on-the-way-out.html
+design-system/day/rtl/frames/form-layout/default.html
+design-system/day/rtl/frames/format-abbr/default.html
+design-system/day/rtl/frames/format-addresses/default.html
+design-system/day/rtl/frames/format-dates/default.html
+design-system/day/rtl/frames/format-durations/default.html
+design-system/day/rtl/frames/format-filesizes/default.html
+design-system/day/rtl/frames/format-identifiers/default.html
+design-system/day/rtl/frames/format-money/default.html
+design-system/day/rtl/frames/format-numbers/default.html
+design-system/day/rtl/frames/format-output/default.html
+design-system/day/rtl/frames/format-people/default.html
+design-system/day/rtl/frames/format-ratios/default.html
+design-system/day/rtl/frames/help/default.html
+design-system/day/rtl/frames/job-status/failed.html
+design-system/day/rtl/frames/job-status/finished.html
+design-system/day/rtl/frames/job-status/running.html
+design-system/day/rtl/frames/layout-column/default.html
+design-system/day/rtl/frames/layout-console-page/default.html
+design-system/day/rtl/frames/layout-console/default.html
+design-system/day/rtl/frames/layout-sidebar-page/default.html
+design-system/day/rtl/frames/layout-sidebar/default.html
+design-system/day/rtl/frames/layout-stage/default.html
+design-system/day/rtl/frames/layout-topbar/default.html
+design-system/day/rtl/frames/list-bar-search/empty-accessible-name-from-the-catalog.html
+design-system/day/rtl/frames/list-bar-search/with-a-query-on-page-4.html
+design-system/day/rtl/frames/list-bar-search/with-a-query.html
+design-system/day/rtl/frames/list-bar/search-with-a-filter-dropdown.html
+design-system/day/rtl/frames/list-grid/default.html
+design-system/day/rtl/frames/list-row-action/lead-marker-status-and-an-action.html
+design-system/day/rtl/frames/list-row-action/name-and-meta-line-only.html
+design-system/day/rtl/frames/list-search-submit/default-label.html
+design-system/day/rtl/frames/locale-menu/twelve-languages-current-highlighted.html
+design-system/day/rtl/frames/media-grid/screenshots-a-logo-and-a-banner.html
+design-system/day/rtl/frames/media-grid/select-mode-one-tile-selected.html
+design-system/day/rtl/frames/meter/empty.html
+design-system/day/rtl/frames/meter/full.html
+design-system/day/rtl/frames/meter/nearly-full.html
+design-system/day/rtl/frames/meter/over-budget.html
+design-system/day/rtl/frames/meter/part-way.html
+design-system/day/rtl/frames/modal/default.html
+design-system/day/rtl/frames/notice/a-saved-message.html
+design-system/day/rtl/frames/page-header/title-only.html
+design-system/day/rtl/frames/page-header/title-subhead-and-a-primary-action.html
+design-system/day/rtl/frames/pagination/first-page-default-accessible-name.html
+design-system/day/rtl/frames/pagination/middle-of-a-long-list.html
+design-system/day/rtl/frames/person/large-the-show-header-size.html
+design-system/day/rtl/frames/person/name-and-email.html
+design-system/day/rtl/frames/person/no-initial.html
+design-system/day/rtl/frames/row-menu/links-a-form-and-a-destructive-item.html
+design-system/day/rtl/frames/row-menu/links-only-for-a-list-inside-a-selection-form.html
+design-system/day/rtl/frames/scheme-menu/standalone.html
+design-system/day/rtl/frames/scheme-menu/three-choices-dark-current.html
+design-system/day/rtl/frames/seg-tabs/two-sections.html
+design-system/day/rtl/frames/selbox/default.html
+design-system/day/rtl/frames/shell-sidebar/default.html
+design-system/day/rtl/frames/shell-topbar/default.html
+design-system/day/rtl/frames/signin/a-passkey.html
+design-system/day/rtl/frames/signin/after-the-link-is-sent-in-another-browser.html
+design-system/day/rtl/frames/signin/after-the-link-is-sent.html
+design-system/day/rtl/frames/signin/an-address-that-does-not-look-right.html
+design-system/day/rtl/frames/signin/asking-for-an-address.html
+design-system/day/rtl/frames/signin/coming-back-with-an-email-link.html
+design-system/day/rtl/frames/signin/coming-back-with-keymail.html
+design-system/day/rtl/frames/signin/email-and-password.html
+design-system/day/rtl/frames/signin/google-apple-and-the-rest.html
+design-system/day/rtl/frames/signin/on-the-way-to-keymail.html
+design-system/day/rtl/frames/signin/when-keymail-could-not-confirm-it.html
+design-system/day/rtl/frames/signin/when-keymail-was-offered-and-a-link-went-out.html
+design-system/day/rtl/frames/stat-band/default.html
+design-system/day/rtl/frames/stat/a-shortened-number.html
+design-system/day/rtl/frames/stat/a-smaller-number.html
+design-system/day/rtl/frames/stat/the-big-number.html
+design-system/day/rtl/frames/stat/when-going-down-is-good.html
+design-system/day/rtl/frames/stat/when-going-up-is-bad.html
+design-system/day/rtl/frames/stat/when-you-cannot-say.html
+design-system/day/rtl/frames/status-pill/negative.html
+design-system/day/rtl/frames/status-pill/neutral.html
+design-system/day/rtl/frames/status-pill/positive.html
+design-system/day/rtl/frames/status-pill/warning.html
+design-system/day/rtl/frames/tblock/default.html
+design-system/day/rtl/help.html
+design-system/day/rtl/icons.html
+design-system/day/rtl/index.html
+design-system/day/rtl/job-status.html
+design-system/day/rtl/layout-column.html
+design-system/day/rtl/layout-console-page.html
+design-system/day/rtl/layout-console.html
+design-system/day/rtl/layout-sidebar-page.html
+design-system/day/rtl/layout-sidebar.html
+design-system/day/rtl/layout-stage.html
+design-system/day/rtl/layout-topbar.html
+design-system/day/rtl/list-bar-search.html
+design-system/day/rtl/list-bar.html
+design-system/day/rtl/list-grid.html
+design-system/day/rtl/list-row-action.html
+design-system/day/rtl/list-search-submit.html
+design-system/day/rtl/locale-menu.html
+design-system/day/rtl/media-grid.html
+design-system/day/rtl/meter.html
+design-system/day/rtl/modal.html
+design-system/day/rtl/notice.html
+design-system/day/rtl/page-header.html
+design-system/day/rtl/pagination.html
+design-system/day/rtl/person.html
+design-system/day/rtl/row-menu.html
+design-system/day/rtl/scheme-menu.html
+design-system/day/rtl/seg-tabs.html
+design-system/day/rtl/selbox.html
+design-system/day/rtl/shell-sidebar.html
+design-system/day/rtl/shell-topbar.html
+design-system/day/rtl/signin.html
+design-system/day/rtl/stat-band.html
+design-system/day/rtl/stat.html
+design-system/day/rtl/status-pill.html
+design-system/day/rtl/tblock.html
+design-system/day/rtl/tokens.html
+design-system/day/scheme-menu.html
+design-system/day/seg-tabs.html
+design-system/day/selbox.html
+design-system/day/shell-sidebar.html
+design-system/day/shell-topbar.html
+design-system/day/signin.html
+design-system/day/stat-band.html
+design-system/day/stat.html
+design-system/day/status-pill.html
+design-system/day/tblock.html
+design-system/day/tokens.html
+design-system/index.html
+design-system/plain/actor.html
+design-system/plain/api-tokens.html
+design-system/plain/back-nav.html
+design-system/plain/badge.html
+design-system/plain/box.html
+design-system/plain/bulk-bar.html
+design-system/plain/button.html
+design-system/plain/callout.html
+design-system/plain/choice-field.html
+design-system/plain/confirm-form.html
+design-system/plain/detail-list.html
+design-system/plain/dropdown.html
+design-system/plain/empty-state.html
+design-system/plain/error-page.html
+design-system/plain/field-check.html
+design-system/plain/field-date.html
+design-system/plain/field-daterange.html
+design-system/plain/field-datetime.html
+design-system/plain/field-money.html
+design-system/plain/field-select.html
+design-system/plain/field-text.html
+design-system/plain/field-textarea.html
+design-system/plain/field-time.html
+design-system/plain/field-url.html
+design-system/plain/field.html
+design-system/plain/form-error.html
+design-system/plain/form-foot.html
+design-system/plain/form-layout.html
+design-system/plain/format-abbr.html
+design-system/plain/format-addresses.html
+design-system/plain/format-dates.html
+design-system/plain/format-durations.html
+design-system/plain/format-filesizes.html
+design-system/plain/format-identifiers.html
+design-system/plain/format-money.html
+design-system/plain/format-numbers.html
+design-system/plain/format-output.html
+design-system/plain/format-people.html
+design-system/plain/format-ratios.html
+design-system/plain/frames/actor/a-person.html
+design-system/plain/frames/actor/an-agent-acting-for-a-person.html
+design-system/plain/frames/api-tokens/four-tokens-two-of-them-live.html
+design-system/plain/frames/api-tokens/just-created-the-secret-shown-once.html
+design-system/plain/frames/api-tokens/no-tokens-yet.html
+design-system/plain/frames/back-nav/back-to-a-record.html
+design-system/plain/frames/badge/negative.html
+design-system/plain/frames/badge/neutral.html
+design-system/plain/frames/badge/no-tone-quiet-default.html
+design-system/plain/frames/badge/positive.html
+design-system/plain/frames/badge/warning.html
+design-system/plain/frames/box/default.html
+design-system/plain/frames/bulk-bar/three-rows-selected.html
+design-system/plain/frames/button/default.html
+design-system/plain/frames/callout/info.html
+design-system/plain/frames/callout/negative-announced-as-an-alert.html
+design-system/plain/frames/callout/negative.html
+design-system/plain/frames/callout/positive.html
+design-system/plain/frames/callout/warning.html
+design-system/plain/frames/choice-field/pick-any.html
+design-system/plain/frames/choice-field/pick-one.html
+design-system/plain/frames/confirm-form/destructive.html
+design-system/plain/frames/confirm-form/ordinary-default-cancel-wording.html
+design-system/plain/frames/detail-list/four-facts-one-machine-ish.html
+design-system/plain/frames/dropdown/a-filter-menu-with-one-choice-applied.html
+design-system/plain/frames/dropdown/menu-idiom.html
+design-system/plain/frames/empty-state/sentence-only.html
+design-system/plain/frames/empty-state/with-a-post-call-to-action.html
+design-system/plain/frames/error-page/403-forbidden.html
+design-system/plain/frames/error-page/404-not-found.html
+design-system/plain/frames/error-page/422-unprocessable.html
+design-system/plain/frames/error-page/500-server-error.html
+design-system/plain/frames/error-page/503-unavailable.html
+design-system/plain/frames/error-page/anything-else-the-generic-pair.html
+design-system/plain/frames/field-check/disabled.html
+design-system/plain/frames/field-check/off.html
+design-system/plain/frames/field-check/on.html
+design-system/plain/frames/field-date/bounded.html
+design-system/plain/frames/field-date/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/plain/frames/field-date/plain-the-bare-native-input.html
+design-system/plain/frames/field-date/required-with-an-error.html
+design-system/plain/frames/field-daterange/date-and-time-seeded.html
+design-system/plain/frames/field-daterange/dates-only-with-an-error-on-the-end.html
+design-system/plain/frames/field-datetime/bounded.html
+design-system/plain/frames/field-datetime/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/plain/frames/field-datetime/plain-the-bare-native-input.html
+design-system/plain/frames/field-datetime/required-with-an-error.html
+design-system/plain/frames/field-money/bare.html
+design-system/plain/frames/field-money/with-a-hint.html
+design-system/plain/frames/field-money/with-an-error.html
+design-system/plain/frames/field-select/hand-written-with-optgroups.html
+design-system/plain/frames/field-select/required-with-an-error.html
+design-system/plain/frames/field-select/three-options-a-native-select.html
+design-system/plain/frames/field-select/twelve-options-plain-the-enhancement-opted-out.html
+design-system/plain/frames/field-select/twelve-options-the-filterable-combobox.html
+design-system/plain/frames/field-text/bare.html
+design-system/plain/frames/field-text/required.html
+design-system/plain/frames/field-text/the-headline-field.html
+design-system/plain/frames/field-text/with-a-hint.html
+design-system/plain/frames/field-text/with-an-error.html
+design-system/plain/frames/field-textarea/bare.html
+design-system/plain/frames/field-textarea/required.html
+design-system/plain/frames/field-textarea/with-a-hint.html
+design-system/plain/frames/field-textarea/with-an-error.html
+design-system/plain/frames/field-time/bounded.html
+design-system/plain/frames/field-time/enhanced-input-type-half-seven-or-noon.html
+design-system/plain/frames/field-time/plain-the-bare-native-input.html
+design-system/plain/frames/field-time/required-with-an-error.html
+design-system/plain/frames/field-url/bare.html
+design-system/plain/frames/field-url/with-a-hint.html
+design-system/plain/frames/field-url/with-an-error.html
+design-system/plain/frames/field/bare.html
+design-system/plain/frames/field/required.html
+design-system/plain/frames/field/short.html
+design-system/plain/frames/field/the-headline-field.html
+design-system/plain/frames/field/with-a-hint.html
+design-system/plain/frames/field/with-an-error.html
+design-system/plain/frames/field/with-help-under-the-control.html
+design-system/plain/frames/form-error/a-rejected-submission.html
+design-system/plain/frames/form-foot/idle-the-button-before-anything-happens.html
+design-system/plain/frames/form-foot/save-and-cancel.html
+design-system/plain/frames/form-foot/submit-only.html
+design-system/plain/frames/form-foot/working-what-busy-js-writes-on-the-way-out.html
+design-system/plain/frames/form-layout/default.html
+design-system/plain/frames/format-abbr/default.html
+design-system/plain/frames/format-addresses/default.html
+design-system/plain/frames/format-dates/default.html
+design-system/plain/frames/format-durations/default.html
+design-system/plain/frames/format-filesizes/default.html
+design-system/plain/frames/format-identifiers/default.html
+design-system/plain/frames/format-money/default.html
+design-system/plain/frames/format-numbers/default.html
+design-system/plain/frames/format-output/default.html
+design-system/plain/frames/format-people/default.html
+design-system/plain/frames/format-ratios/default.html
+design-system/plain/frames/help/default.html
+design-system/plain/frames/job-status/failed.html
+design-system/plain/frames/job-status/finished.html
+design-system/plain/frames/job-status/running.html
+design-system/plain/frames/layout-column/default.html
+design-system/plain/frames/layout-console-page/default.html
+design-system/plain/frames/layout-console/default.html
+design-system/plain/frames/layout-sidebar-page/default.html
+design-system/plain/frames/layout-sidebar/default.html
+design-system/plain/frames/layout-stage/default.html
+design-system/plain/frames/layout-topbar/default.html
+design-system/plain/frames/list-bar-search/empty-accessible-name-from-the-catalog.html
+design-system/plain/frames/list-bar-search/with-a-query-on-page-4.html
+design-system/plain/frames/list-bar-search/with-a-query.html
+design-system/plain/frames/list-bar/search-with-a-filter-dropdown.html
+design-system/plain/frames/list-grid/default.html
+design-system/plain/frames/list-row-action/lead-marker-status-and-an-action.html
+design-system/plain/frames/list-row-action/name-and-meta-line-only.html
+design-system/plain/frames/list-search-submit/default-label.html
+design-system/plain/frames/locale-menu/twelve-languages-current-highlighted.html
+design-system/plain/frames/media-grid/screenshots-a-logo-and-a-banner.html
+design-system/plain/frames/media-grid/select-mode-one-tile-selected.html
+design-system/plain/frames/meter/empty.html
+design-system/plain/frames/meter/full.html
+design-system/plain/frames/meter/nearly-full.html
+design-system/plain/frames/meter/over-budget.html
+design-system/plain/frames/meter/part-way.html
+design-system/plain/frames/modal/default.html
+design-system/plain/frames/notice/a-saved-message.html
+design-system/plain/frames/page-header/title-only.html
+design-system/plain/frames/page-header/title-subhead-and-a-primary-action.html
+design-system/plain/frames/pagination/first-page-default-accessible-name.html
+design-system/plain/frames/pagination/middle-of-a-long-list.html
+design-system/plain/frames/person/large-the-show-header-size.html
+design-system/plain/frames/person/name-and-email.html
+design-system/plain/frames/person/no-initial.html
+design-system/plain/frames/row-menu/links-a-form-and-a-destructive-item.html
+design-system/plain/frames/row-menu/links-only-for-a-list-inside-a-selection-form.html
+design-system/plain/frames/scheme-menu/standalone.html
+design-system/plain/frames/scheme-menu/three-choices-dark-current.html
+design-system/plain/frames/seg-tabs/two-sections.html
+design-system/plain/frames/selbox/default.html
+design-system/plain/frames/shell-sidebar/default.html
+design-system/plain/frames/shell-topbar/default.html
+design-system/plain/frames/signin/a-passkey.html
+design-system/plain/frames/signin/after-the-link-is-sent-in-another-browser.html
+design-system/plain/frames/signin/after-the-link-is-sent.html
+design-system/plain/frames/signin/an-address-that-does-not-look-right.html
+design-system/plain/frames/signin/asking-for-an-address.html
+design-system/plain/frames/signin/coming-back-with-an-email-link.html
+design-system/plain/frames/signin/coming-back-with-keymail.html
+design-system/plain/frames/signin/email-and-password.html
+design-system/plain/frames/signin/google-apple-and-the-rest.html
+design-system/plain/frames/signin/on-the-way-to-keymail.html
+design-system/plain/frames/signin/when-keymail-could-not-confirm-it.html
+design-system/plain/frames/signin/when-keymail-was-offered-and-a-link-went-out.html
+design-system/plain/frames/stat-band/default.html
+design-system/plain/frames/stat/a-shortened-number.html
+design-system/plain/frames/stat/a-smaller-number.html
+design-system/plain/frames/stat/the-big-number.html
+design-system/plain/frames/stat/when-going-down-is-good.html
+design-system/plain/frames/stat/when-going-up-is-bad.html
+design-system/plain/frames/stat/when-you-cannot-say.html
+design-system/plain/frames/status-pill/negative.html
+design-system/plain/frames/status-pill/neutral.html
+design-system/plain/frames/status-pill/positive.html
+design-system/plain/frames/status-pill/warning.html
+design-system/plain/frames/tblock/default.html
+design-system/plain/help.html
+design-system/plain/icons.html
+design-system/plain/index.html
+design-system/plain/job-status.html
+design-system/plain/layout-column.html
+design-system/plain/layout-console-page.html
+design-system/plain/layout-console.html
+design-system/plain/layout-sidebar-page.html
+design-system/plain/layout-sidebar.html
+design-system/plain/layout-stage.html
+design-system/plain/layout-topbar.html
+design-system/plain/list-bar-search.html
+design-system/plain/list-bar.html
+design-system/plain/list-grid.html
+design-system/plain/list-row-action.html
+design-system/plain/list-search-submit.html
+design-system/plain/locale-menu.html
+design-system/plain/media-grid.html
+design-system/plain/meter.html
+design-system/plain/modal.html
+design-system/plain/notice.html
+design-system/plain/page-header.html
+design-system/plain/pagination.html
+design-system/plain/person.html
+design-system/plain/row-menu.html
+design-system/plain/rtl/actor.html
+design-system/plain/rtl/api-tokens.html
+design-system/plain/rtl/back-nav.html
+design-system/plain/rtl/badge.html
+design-system/plain/rtl/box.html
+design-system/plain/rtl/bulk-bar.html
+design-system/plain/rtl/button.html
+design-system/plain/rtl/callout.html
+design-system/plain/rtl/choice-field.html
+design-system/plain/rtl/confirm-form.html
+design-system/plain/rtl/detail-list.html
+design-system/plain/rtl/dropdown.html
+design-system/plain/rtl/empty-state.html
+design-system/plain/rtl/error-page.html
+design-system/plain/rtl/field-check.html
+design-system/plain/rtl/field-date.html
+design-system/plain/rtl/field-daterange.html
+design-system/plain/rtl/field-datetime.html
+design-system/plain/rtl/field-money.html
+design-system/plain/rtl/field-select.html
+design-system/plain/rtl/field-text.html
+design-system/plain/rtl/field-textarea.html
+design-system/plain/rtl/field-time.html
+design-system/plain/rtl/field-url.html
+design-system/plain/rtl/field.html
+design-system/plain/rtl/form-error.html
+design-system/plain/rtl/form-foot.html
+design-system/plain/rtl/form-layout.html
+design-system/plain/rtl/format-abbr.html
+design-system/plain/rtl/format-addresses.html
+design-system/plain/rtl/format-dates.html
+design-system/plain/rtl/format-durations.html
+design-system/plain/rtl/format-filesizes.html
+design-system/plain/rtl/format-identifiers.html
+design-system/plain/rtl/format-money.html
+design-system/plain/rtl/format-numbers.html
+design-system/plain/rtl/format-output.html
+design-system/plain/rtl/format-people.html
+design-system/plain/rtl/format-ratios.html
+design-system/plain/rtl/frames/actor/a-person.html
+design-system/plain/rtl/frames/actor/an-agent-acting-for-a-person.html
+design-system/plain/rtl/frames/api-tokens/four-tokens-two-of-them-live.html
+design-system/plain/rtl/frames/api-tokens/just-created-the-secret-shown-once.html
+design-system/plain/rtl/frames/api-tokens/no-tokens-yet.html
+design-system/plain/rtl/frames/back-nav/back-to-a-record.html
+design-system/plain/rtl/frames/badge/negative.html
+design-system/plain/rtl/frames/badge/neutral.html
+design-system/plain/rtl/frames/badge/no-tone-quiet-default.html
+design-system/plain/rtl/frames/badge/positive.html
+design-system/plain/rtl/frames/badge/warning.html
+design-system/plain/rtl/frames/box/default.html
+design-system/plain/rtl/frames/bulk-bar/three-rows-selected.html
+design-system/plain/rtl/frames/button/default.html
+design-system/plain/rtl/frames/callout/info.html
+design-system/plain/rtl/frames/callout/negative-announced-as-an-alert.html
+design-system/plain/rtl/frames/callout/negative.html
+design-system/plain/rtl/frames/callout/positive.html
+design-system/plain/rtl/frames/callout/warning.html
+design-system/plain/rtl/frames/choice-field/pick-any.html
+design-system/plain/rtl/frames/choice-field/pick-one.html
+design-system/plain/rtl/frames/confirm-form/destructive.html
+design-system/plain/rtl/frames/confirm-form/ordinary-default-cancel-wording.html
+design-system/plain/rtl/frames/detail-list/four-facts-one-machine-ish.html
+design-system/plain/rtl/frames/dropdown/a-filter-menu-with-one-choice-applied.html
+design-system/plain/rtl/frames/dropdown/menu-idiom.html
+design-system/plain/rtl/frames/empty-state/sentence-only.html
+design-system/plain/rtl/frames/empty-state/with-a-post-call-to-action.html
+design-system/plain/rtl/frames/error-page/403-forbidden.html
+design-system/plain/rtl/frames/error-page/404-not-found.html
+design-system/plain/rtl/frames/error-page/422-unprocessable.html
+design-system/plain/rtl/frames/error-page/500-server-error.html
+design-system/plain/rtl/frames/error-page/503-unavailable.html
+design-system/plain/rtl/frames/error-page/anything-else-the-generic-pair.html
+design-system/plain/rtl/frames/field-check/disabled.html
+design-system/plain/rtl/frames/field-check/off.html
+design-system/plain/rtl/frames/field-check/on.html
+design-system/plain/rtl/frames/field-date/bounded.html
+design-system/plain/rtl/frames/field-date/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/plain/rtl/frames/field-date/plain-the-bare-native-input.html
+design-system/plain/rtl/frames/field-date/required-with-an-error.html
+design-system/plain/rtl/frames/field-daterange/date-and-time-seeded.html
+design-system/plain/rtl/frames/field-daterange/dates-only-with-an-error-on-the-end.html
+design-system/plain/rtl/frames/field-datetime/bounded.html
+design-system/plain/rtl/frames/field-datetime/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/plain/rtl/frames/field-datetime/plain-the-bare-native-input.html
+design-system/plain/rtl/frames/field-datetime/required-with-an-error.html
+design-system/plain/rtl/frames/field-money/bare.html
+design-system/plain/rtl/frames/field-money/with-a-hint.html
+design-system/plain/rtl/frames/field-money/with-an-error.html
+design-system/plain/rtl/frames/field-select/hand-written-with-optgroups.html
+design-system/plain/rtl/frames/field-select/required-with-an-error.html
+design-system/plain/rtl/frames/field-select/three-options-a-native-select.html
+design-system/plain/rtl/frames/field-select/twelve-options-plain-the-enhancement-opted-out.html
+design-system/plain/rtl/frames/field-select/twelve-options-the-filterable-combobox.html
+design-system/plain/rtl/frames/field-text/bare.html
+design-system/plain/rtl/frames/field-text/required.html
+design-system/plain/rtl/frames/field-text/the-headline-field.html
+design-system/plain/rtl/frames/field-text/with-a-hint.html
+design-system/plain/rtl/frames/field-text/with-an-error.html
+design-system/plain/rtl/frames/field-textarea/bare.html
+design-system/plain/rtl/frames/field-textarea/required.html
+design-system/plain/rtl/frames/field-textarea/with-a-hint.html
+design-system/plain/rtl/frames/field-textarea/with-an-error.html
+design-system/plain/rtl/frames/field-time/bounded.html
+design-system/plain/rtl/frames/field-time/enhanced-input-type-half-seven-or-noon.html
+design-system/plain/rtl/frames/field-time/plain-the-bare-native-input.html
+design-system/plain/rtl/frames/field-time/required-with-an-error.html
+design-system/plain/rtl/frames/field-url/bare.html
+design-system/plain/rtl/frames/field-url/with-a-hint.html
+design-system/plain/rtl/frames/field-url/with-an-error.html
+design-system/plain/rtl/frames/field/bare.html
+design-system/plain/rtl/frames/field/required.html
+design-system/plain/rtl/frames/field/short.html
+design-system/plain/rtl/frames/field/the-headline-field.html
+design-system/plain/rtl/frames/field/with-a-hint.html
+design-system/plain/rtl/frames/field/with-an-error.html
+design-system/plain/rtl/frames/field/with-help-under-the-control.html
+design-system/plain/rtl/frames/form-error/a-rejected-submission.html
+design-system/plain/rtl/frames/form-foot/idle-the-button-before-anything-happens.html
+design-system/plain/rtl/frames/form-foot/save-and-cancel.html
+design-system/plain/rtl/frames/form-foot/submit-only.html
+design-system/plain/rtl/frames/form-foot/working-what-busy-js-writes-on-the-way-out.html
+design-system/plain/rtl/frames/form-layout/default.html
+design-system/plain/rtl/frames/format-abbr/default.html
+design-system/plain/rtl/frames/format-addresses/default.html
+design-system/plain/rtl/frames/format-dates/default.html
+design-system/plain/rtl/frames/format-durations/default.html
+design-system/plain/rtl/frames/format-filesizes/default.html
+design-system/plain/rtl/frames/format-identifiers/default.html
+design-system/plain/rtl/frames/format-money/default.html
+design-system/plain/rtl/frames/format-numbers/default.html
+design-system/plain/rtl/frames/format-output/default.html
+design-system/plain/rtl/frames/format-people/default.html
+design-system/plain/rtl/frames/format-ratios/default.html
+design-system/plain/rtl/frames/help/default.html
+design-system/plain/rtl/frames/job-status/failed.html
+design-system/plain/rtl/frames/job-status/finished.html
+design-system/plain/rtl/frames/job-status/running.html
+design-system/plain/rtl/frames/layout-column/default.html
+design-system/plain/rtl/frames/layout-console-page/default.html
+design-system/plain/rtl/frames/layout-console/default.html
+design-system/plain/rtl/frames/layout-sidebar-page/default.html
+design-system/plain/rtl/frames/layout-sidebar/default.html
+design-system/plain/rtl/frames/layout-stage/default.html
+design-system/plain/rtl/frames/layout-topbar/default.html
+design-system/plain/rtl/frames/list-bar-search/empty-accessible-name-from-the-catalog.html
+design-system/plain/rtl/frames/list-bar-search/with-a-query-on-page-4.html
+design-system/plain/rtl/frames/list-bar-search/with-a-query.html
+design-system/plain/rtl/frames/list-bar/search-with-a-filter-dropdown.html
+design-system/plain/rtl/frames/list-grid/default.html
+design-system/plain/rtl/frames/list-row-action/lead-marker-status-and-an-action.html
+design-system/plain/rtl/frames/list-row-action/name-and-meta-line-only.html
+design-system/plain/rtl/frames/list-search-submit/default-label.html
+design-system/plain/rtl/frames/locale-menu/twelve-languages-current-highlighted.html
+design-system/plain/rtl/frames/media-grid/screenshots-a-logo-and-a-banner.html
+design-system/plain/rtl/frames/media-grid/select-mode-one-tile-selected.html
+design-system/plain/rtl/frames/meter/empty.html
+design-system/plain/rtl/frames/meter/full.html
+design-system/plain/rtl/frames/meter/nearly-full.html
+design-system/plain/rtl/frames/meter/over-budget.html
+design-system/plain/rtl/frames/meter/part-way.html
+design-system/plain/rtl/frames/modal/default.html
+design-system/plain/rtl/frames/notice/a-saved-message.html
+design-system/plain/rtl/frames/page-header/title-only.html
+design-system/plain/rtl/frames/page-header/title-subhead-and-a-primary-action.html
+design-system/plain/rtl/frames/pagination/first-page-default-accessible-name.html
+design-system/plain/rtl/frames/pagination/middle-of-a-long-list.html
+design-system/plain/rtl/frames/person/large-the-show-header-size.html
+design-system/plain/rtl/frames/person/name-and-email.html
+design-system/plain/rtl/frames/person/no-initial.html
+design-system/plain/rtl/frames/row-menu/links-a-form-and-a-destructive-item.html
+design-system/plain/rtl/frames/row-menu/links-only-for-a-list-inside-a-selection-form.html
+design-system/plain/rtl/frames/scheme-menu/standalone.html
+design-system/plain/rtl/frames/scheme-menu/three-choices-dark-current.html
+design-system/plain/rtl/frames/seg-tabs/two-sections.html
+design-system/plain/rtl/frames/selbox/default.html
+design-system/plain/rtl/frames/shell-sidebar/default.html
+design-system/plain/rtl/frames/shell-topbar/default.html
+design-system/plain/rtl/frames/signin/a-passkey.html
+design-system/plain/rtl/frames/signin/after-the-link-is-sent-in-another-browser.html
+design-system/plain/rtl/frames/signin/after-the-link-is-sent.html
+design-system/plain/rtl/frames/signin/an-address-that-does-not-look-right.html
+design-system/plain/rtl/frames/signin/asking-for-an-address.html
+design-system/plain/rtl/frames/signin/coming-back-with-an-email-link.html
+design-system/plain/rtl/frames/signin/coming-back-with-keymail.html
+design-system/plain/rtl/frames/signin/email-and-password.html
+design-system/plain/rtl/frames/signin/google-apple-and-the-rest.html
+design-system/plain/rtl/frames/signin/on-the-way-to-keymail.html
+design-system/plain/rtl/frames/signin/when-keymail-could-not-confirm-it.html
+design-system/plain/rtl/frames/signin/when-keymail-was-offered-and-a-link-went-out.html
+design-system/plain/rtl/frames/stat-band/default.html
+design-system/plain/rtl/frames/stat/a-shortened-number.html
+design-system/plain/rtl/frames/stat/a-smaller-number.html
+design-system/plain/rtl/frames/stat/the-big-number.html
+design-system/plain/rtl/frames/stat/when-going-down-is-good.html
+design-system/plain/rtl/frames/stat/when-going-up-is-bad.html
+design-system/plain/rtl/frames/stat/when-you-cannot-say.html
+design-system/plain/rtl/frames/status-pill/negative.html
+design-system/plain/rtl/frames/status-pill/neutral.html
+design-system/plain/rtl/frames/status-pill/positive.html
+design-system/plain/rtl/frames/status-pill/warning.html
+design-system/plain/rtl/frames/tblock/default.html
+design-system/plain/rtl/help.html
+design-system/plain/rtl/icons.html
+design-system/plain/rtl/index.html
+design-system/plain/rtl/job-status.html
+design-system/plain/rtl/layout-column.html
+design-system/plain/rtl/layout-console-page.html
+design-system/plain/rtl/layout-console.html
+design-system/plain/rtl/layout-sidebar-page.html
+design-system/plain/rtl/layout-sidebar.html
+design-system/plain/rtl/layout-stage.html
+design-system/plain/rtl/layout-topbar.html
+design-system/plain/rtl/list-bar-search.html
+design-system/plain/rtl/list-bar.html
+design-system/plain/rtl/list-grid.html
+design-system/plain/rtl/list-row-action.html
+design-system/plain/rtl/list-search-submit.html
+design-system/plain/rtl/locale-menu.html
+design-system/plain/rtl/media-grid.html
+design-system/plain/rtl/meter.html
+design-system/plain/rtl/modal.html
+design-system/plain/rtl/notice.html
+design-system/plain/rtl/page-header.html
+design-system/plain/rtl/pagination.html
+design-system/plain/rtl/person.html
+design-system/plain/rtl/row-menu.html
+design-system/plain/rtl/scheme-menu.html
+design-system/plain/rtl/seg-tabs.html
+design-system/plain/rtl/selbox.html
+design-system/plain/rtl/shell-sidebar.html
+design-system/plain/rtl/shell-topbar.html
+design-system/plain/rtl/signin.html
+design-system/plain/rtl/stat-band.html
+design-system/plain/rtl/stat.html
+design-system/plain/rtl/status-pill.html
+design-system/plain/rtl/tblock.html
+design-system/plain/rtl/tokens.html
+design-system/plain/scheme-menu.html
+design-system/plain/seg-tabs.html
+design-system/plain/selbox.html
+design-system/plain/shell-sidebar.html
+design-system/plain/shell-topbar.html
+design-system/plain/signin.html
+design-system/plain/stat-band.html
+design-system/plain/stat.html
+design-system/plain/status-pill.html
+design-system/plain/tblock.html
+design-system/plain/tokens.html
+design-system/signal/actor.html
+design-system/signal/api-tokens.html
+design-system/signal/back-nav.html
+design-system/signal/badge.html
+design-system/signal/box.html
+design-system/signal/bulk-bar.html
+design-system/signal/button.html
+design-system/signal/callout.html
+design-system/signal/choice-field.html
+design-system/signal/confirm-form.html
+design-system/signal/detail-list.html
+design-system/signal/dropdown.html
+design-system/signal/empty-state.html
+design-system/signal/error-page.html
+design-system/signal/field-check.html
+design-system/signal/field-date.html
+design-system/signal/field-daterange.html
+design-system/signal/field-datetime.html
+design-system/signal/field-money.html
+design-system/signal/field-select.html
+design-system/signal/field-text.html
+design-system/signal/field-textarea.html
+design-system/signal/field-time.html
+design-system/signal/field-url.html
+design-system/signal/field.html
+design-system/signal/form-error.html
+design-system/signal/form-foot.html
+design-system/signal/form-layout.html
+design-system/signal/format-abbr.html
+design-system/signal/format-addresses.html
+design-system/signal/format-dates.html
+design-system/signal/format-durations.html
+design-system/signal/format-filesizes.html
+design-system/signal/format-identifiers.html
+design-system/signal/format-money.html
+design-system/signal/format-numbers.html
+design-system/signal/format-output.html
+design-system/signal/format-people.html
+design-system/signal/format-ratios.html
+design-system/signal/frames/actor/a-person.html
+design-system/signal/frames/actor/an-agent-acting-for-a-person.html
+design-system/signal/frames/api-tokens/four-tokens-two-of-them-live.html
+design-system/signal/frames/api-tokens/just-created-the-secret-shown-once.html
+design-system/signal/frames/api-tokens/no-tokens-yet.html
+design-system/signal/frames/back-nav/back-to-a-record.html
+design-system/signal/frames/badge/negative.html
+design-system/signal/frames/badge/neutral.html
+design-system/signal/frames/badge/no-tone-quiet-default.html
+design-system/signal/frames/badge/positive.html
+design-system/signal/frames/badge/warning.html
+design-system/signal/frames/box/default.html
+design-system/signal/frames/bulk-bar/three-rows-selected.html
+design-system/signal/frames/button/default.html
+design-system/signal/frames/callout/info.html
+design-system/signal/frames/callout/negative-announced-as-an-alert.html
+design-system/signal/frames/callout/negative.html
+design-system/signal/frames/callout/positive.html
+design-system/signal/frames/callout/warning.html
+design-system/signal/frames/choice-field/pick-any.html
+design-system/signal/frames/choice-field/pick-one.html
+design-system/signal/frames/confirm-form/destructive.html
+design-system/signal/frames/confirm-form/ordinary-default-cancel-wording.html
+design-system/signal/frames/detail-list/four-facts-one-machine-ish.html
+design-system/signal/frames/dropdown/a-filter-menu-with-one-choice-applied.html
+design-system/signal/frames/dropdown/menu-idiom.html
+design-system/signal/frames/empty-state/sentence-only.html
+design-system/signal/frames/empty-state/with-a-post-call-to-action.html
+design-system/signal/frames/error-page/403-forbidden.html
+design-system/signal/frames/error-page/404-not-found.html
+design-system/signal/frames/error-page/422-unprocessable.html
+design-system/signal/frames/error-page/500-server-error.html
+design-system/signal/frames/error-page/503-unavailable.html
+design-system/signal/frames/error-page/anything-else-the-generic-pair.html
+design-system/signal/frames/field-check/disabled.html
+design-system/signal/frames/field-check/off.html
+design-system/signal/frames/field-check/on.html
+design-system/signal/frames/field-date/bounded.html
+design-system/signal/frames/field-date/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/signal/frames/field-date/plain-the-bare-native-input.html
+design-system/signal/frames/field-date/required-with-an-error.html
+design-system/signal/frames/field-daterange/date-and-time-seeded.html
+design-system/signal/frames/field-daterange/dates-only-with-an-error-on-the-end.html
+design-system/signal/frames/field-datetime/bounded.html
+design-system/signal/frames/field-datetime/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/signal/frames/field-datetime/plain-the-bare-native-input.html
+design-system/signal/frames/field-datetime/required-with-an-error.html
+design-system/signal/frames/field-money/bare.html
+design-system/signal/frames/field-money/with-a-hint.html
+design-system/signal/frames/field-money/with-an-error.html
+design-system/signal/frames/field-select/hand-written-with-optgroups.html
+design-system/signal/frames/field-select/required-with-an-error.html
+design-system/signal/frames/field-select/three-options-a-native-select.html
+design-system/signal/frames/field-select/twelve-options-plain-the-enhancement-opted-out.html
+design-system/signal/frames/field-select/twelve-options-the-filterable-combobox.html
+design-system/signal/frames/field-text/bare.html
+design-system/signal/frames/field-text/required.html
+design-system/signal/frames/field-text/the-headline-field.html
+design-system/signal/frames/field-text/with-a-hint.html
+design-system/signal/frames/field-text/with-an-error.html
+design-system/signal/frames/field-textarea/bare.html
+design-system/signal/frames/field-textarea/required.html
+design-system/signal/frames/field-textarea/with-a-hint.html
+design-system/signal/frames/field-textarea/with-an-error.html
+design-system/signal/frames/field-time/bounded.html
+design-system/signal/frames/field-time/enhanced-input-type-half-seven-or-noon.html
+design-system/signal/frames/field-time/plain-the-bare-native-input.html
+design-system/signal/frames/field-time/required-with-an-error.html
+design-system/signal/frames/field-url/bare.html
+design-system/signal/frames/field-url/with-a-hint.html
+design-system/signal/frames/field-url/with-an-error.html
+design-system/signal/frames/field/bare.html
+design-system/signal/frames/field/required.html
+design-system/signal/frames/field/short.html
+design-system/signal/frames/field/the-headline-field.html
+design-system/signal/frames/field/with-a-hint.html
+design-system/signal/frames/field/with-an-error.html
+design-system/signal/frames/field/with-help-under-the-control.html
+design-system/signal/frames/form-error/a-rejected-submission.html
+design-system/signal/frames/form-foot/idle-the-button-before-anything-happens.html
+design-system/signal/frames/form-foot/save-and-cancel.html
+design-system/signal/frames/form-foot/submit-only.html
+design-system/signal/frames/form-foot/working-what-busy-js-writes-on-the-way-out.html
+design-system/signal/frames/form-layout/default.html
+design-system/signal/frames/format-abbr/default.html
+design-system/signal/frames/format-addresses/default.html
+design-system/signal/frames/format-dates/default.html
+design-system/signal/frames/format-durations/default.html
+design-system/signal/frames/format-filesizes/default.html
+design-system/signal/frames/format-identifiers/default.html
+design-system/signal/frames/format-money/default.html
+design-system/signal/frames/format-numbers/default.html
+design-system/signal/frames/format-output/default.html
+design-system/signal/frames/format-people/default.html
+design-system/signal/frames/format-ratios/default.html
+design-system/signal/frames/help/default.html
+design-system/signal/frames/job-status/failed.html
+design-system/signal/frames/job-status/finished.html
+design-system/signal/frames/job-status/running.html
+design-system/signal/frames/layout-column/default.html
+design-system/signal/frames/layout-console-page/default.html
+design-system/signal/frames/layout-console/default.html
+design-system/signal/frames/layout-sidebar-page/default.html
+design-system/signal/frames/layout-sidebar/default.html
+design-system/signal/frames/layout-stage/default.html
+design-system/signal/frames/layout-topbar/default.html
+design-system/signal/frames/list-bar-search/empty-accessible-name-from-the-catalog.html
+design-system/signal/frames/list-bar-search/with-a-query-on-page-4.html
+design-system/signal/frames/list-bar-search/with-a-query.html
+design-system/signal/frames/list-bar/search-with-a-filter-dropdown.html
+design-system/signal/frames/list-grid/default.html
+design-system/signal/frames/list-row-action/lead-marker-status-and-an-action.html
+design-system/signal/frames/list-row-action/name-and-meta-line-only.html
+design-system/signal/frames/list-search-submit/default-label.html
+design-system/signal/frames/locale-menu/twelve-languages-current-highlighted.html
+design-system/signal/frames/media-grid/screenshots-a-logo-and-a-banner.html
+design-system/signal/frames/media-grid/select-mode-one-tile-selected.html
+design-system/signal/frames/meter/empty.html
+design-system/signal/frames/meter/full.html
+design-system/signal/frames/meter/nearly-full.html
+design-system/signal/frames/meter/over-budget.html
+design-system/signal/frames/meter/part-way.html
+design-system/signal/frames/modal/default.html
+design-system/signal/frames/notice/a-saved-message.html
+design-system/signal/frames/page-header/title-only.html
+design-system/signal/frames/page-header/title-subhead-and-a-primary-action.html
+design-system/signal/frames/pagination/first-page-default-accessible-name.html
+design-system/signal/frames/pagination/middle-of-a-long-list.html
+design-system/signal/frames/person/large-the-show-header-size.html
+design-system/signal/frames/person/name-and-email.html
+design-system/signal/frames/person/no-initial.html
+design-system/signal/frames/row-menu/links-a-form-and-a-destructive-item.html
+design-system/signal/frames/row-menu/links-only-for-a-list-inside-a-selection-form.html
+design-system/signal/frames/scheme-menu/standalone.html
+design-system/signal/frames/scheme-menu/three-choices-dark-current.html
+design-system/signal/frames/seg-tabs/two-sections.html
+design-system/signal/frames/selbox/default.html
+design-system/signal/frames/shell-sidebar/default.html
+design-system/signal/frames/shell-topbar/default.html
+design-system/signal/frames/signin/a-passkey.html
+design-system/signal/frames/signin/after-the-link-is-sent-in-another-browser.html
+design-system/signal/frames/signin/after-the-link-is-sent.html
+design-system/signal/frames/signin/an-address-that-does-not-look-right.html
+design-system/signal/frames/signin/asking-for-an-address.html
+design-system/signal/frames/signin/coming-back-with-an-email-link.html
+design-system/signal/frames/signin/coming-back-with-keymail.html
+design-system/signal/frames/signin/email-and-password.html
+design-system/signal/frames/signin/google-apple-and-the-rest.html
+design-system/signal/frames/signin/on-the-way-to-keymail.html
+design-system/signal/frames/signin/when-keymail-could-not-confirm-it.html
+design-system/signal/frames/signin/when-keymail-was-offered-and-a-link-went-out.html
+design-system/signal/frames/stat-band/default.html
+design-system/signal/frames/stat/a-shortened-number.html
+design-system/signal/frames/stat/a-smaller-number.html
+design-system/signal/frames/stat/the-big-number.html
+design-system/signal/frames/stat/when-going-down-is-good.html
+design-system/signal/frames/stat/when-going-up-is-bad.html
+design-system/signal/frames/stat/when-you-cannot-say.html
+design-system/signal/frames/status-pill/negative.html
+design-system/signal/frames/status-pill/neutral.html
+design-system/signal/frames/status-pill/positive.html
+design-system/signal/frames/status-pill/warning.html
+design-system/signal/frames/tblock/default.html
+design-system/signal/help.html
+design-system/signal/icons.html
+design-system/signal/index.html
+design-system/signal/job-status.html
+design-system/signal/layout-column.html
+design-system/signal/layout-console-page.html
+design-system/signal/layout-console.html
+design-system/signal/layout-sidebar-page.html
+design-system/signal/layout-sidebar.html
+design-system/signal/layout-stage.html
+design-system/signal/layout-topbar.html
+design-system/signal/list-bar-search.html
+design-system/signal/list-bar.html
+design-system/signal/list-grid.html
+design-system/signal/list-row-action.html
+design-system/signal/list-search-submit.html
+design-system/signal/locale-menu.html
+design-system/signal/media-grid.html
+design-system/signal/meter.html
+design-system/signal/modal.html
+design-system/signal/notice.html
+design-system/signal/page-header.html
+design-system/signal/pagination.html
+design-system/signal/person.html
+design-system/signal/row-menu.html
+design-system/signal/rtl/actor.html
+design-system/signal/rtl/api-tokens.html
+design-system/signal/rtl/back-nav.html
+design-system/signal/rtl/badge.html
+design-system/signal/rtl/box.html
+design-system/signal/rtl/bulk-bar.html
+design-system/signal/rtl/button.html
+design-system/signal/rtl/callout.html
+design-system/signal/rtl/choice-field.html
+design-system/signal/rtl/confirm-form.html
+design-system/signal/rtl/detail-list.html
+design-system/signal/rtl/dropdown.html
+design-system/signal/rtl/empty-state.html
+design-system/signal/rtl/error-page.html
+design-system/signal/rtl/field-check.html
+design-system/signal/rtl/field-date.html
+design-system/signal/rtl/field-daterange.html
+design-system/signal/rtl/field-datetime.html
+design-system/signal/rtl/field-money.html
+design-system/signal/rtl/field-select.html
+design-system/signal/rtl/field-text.html
+design-system/signal/rtl/field-textarea.html
+design-system/signal/rtl/field-time.html
+design-system/signal/rtl/field-url.html
+design-system/signal/rtl/field.html
+design-system/signal/rtl/form-error.html
+design-system/signal/rtl/form-foot.html
+design-system/signal/rtl/form-layout.html
+design-system/signal/rtl/format-abbr.html
+design-system/signal/rtl/format-addresses.html
+design-system/signal/rtl/format-dates.html
+design-system/signal/rtl/format-durations.html
+design-system/signal/rtl/format-filesizes.html
+design-system/signal/rtl/format-identifiers.html
+design-system/signal/rtl/format-money.html
+design-system/signal/rtl/format-numbers.html
+design-system/signal/rtl/format-output.html
+design-system/signal/rtl/format-people.html
+design-system/signal/rtl/format-ratios.html
+design-system/signal/rtl/frames/actor/a-person.html
+design-system/signal/rtl/frames/actor/an-agent-acting-for-a-person.html
+design-system/signal/rtl/frames/api-tokens/four-tokens-two-of-them-live.html
+design-system/signal/rtl/frames/api-tokens/just-created-the-secret-shown-once.html
+design-system/signal/rtl/frames/api-tokens/no-tokens-yet.html
+design-system/signal/rtl/frames/back-nav/back-to-a-record.html
+design-system/signal/rtl/frames/badge/negative.html
+design-system/signal/rtl/frames/badge/neutral.html
+design-system/signal/rtl/frames/badge/no-tone-quiet-default.html
+design-system/signal/rtl/frames/badge/positive.html
+design-system/signal/rtl/frames/badge/warning.html
+design-system/signal/rtl/frames/box/default.html
+design-system/signal/rtl/frames/bulk-bar/three-rows-selected.html
+design-system/signal/rtl/frames/button/default.html
+design-system/signal/rtl/frames/callout/info.html
+design-system/signal/rtl/frames/callout/negative-announced-as-an-alert.html
+design-system/signal/rtl/frames/callout/negative.html
+design-system/signal/rtl/frames/callout/positive.html
+design-system/signal/rtl/frames/callout/warning.html
+design-system/signal/rtl/frames/choice-field/pick-any.html
+design-system/signal/rtl/frames/choice-field/pick-one.html
+design-system/signal/rtl/frames/confirm-form/destructive.html
+design-system/signal/rtl/frames/confirm-form/ordinary-default-cancel-wording.html
+design-system/signal/rtl/frames/detail-list/four-facts-one-machine-ish.html
+design-system/signal/rtl/frames/dropdown/a-filter-menu-with-one-choice-applied.html
+design-system/signal/rtl/frames/dropdown/menu-idiom.html
+design-system/signal/rtl/frames/empty-state/sentence-only.html
+design-system/signal/rtl/frames/empty-state/with-a-post-call-to-action.html
+design-system/signal/rtl/frames/error-page/403-forbidden.html
+design-system/signal/rtl/frames/error-page/404-not-found.html
+design-system/signal/rtl/frames/error-page/422-unprocessable.html
+design-system/signal/rtl/frames/error-page/500-server-error.html
+design-system/signal/rtl/frames/error-page/503-unavailable.html
+design-system/signal/rtl/frames/error-page/anything-else-the-generic-pair.html
+design-system/signal/rtl/frames/field-check/disabled.html
+design-system/signal/rtl/frames/field-check/off.html
+design-system/signal/rtl/frames/field-check/on.html
+design-system/signal/rtl/frames/field-date/bounded.html
+design-system/signal/rtl/frames/field-date/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/signal/rtl/frames/field-date/plain-the-bare-native-input.html
+design-system/signal/rtl/frames/field-date/required-with-an-error.html
+design-system/signal/rtl/frames/field-daterange/date-and-time-seeded.html
+design-system/signal/rtl/frames/field-daterange/dates-only-with-an-error-on-the-end.html
+design-system/signal/rtl/frames/field-datetime/bounded.html
+design-system/signal/rtl/frames/field-datetime/enhanced-input-type-tomorrow-or-in-3-weeks.html
+design-system/signal/rtl/frames/field-datetime/plain-the-bare-native-input.html
+design-system/signal/rtl/frames/field-datetime/required-with-an-error.html
+design-system/signal/rtl/frames/field-money/bare.html
+design-system/signal/rtl/frames/field-money/with-a-hint.html
+design-system/signal/rtl/frames/field-money/with-an-error.html
+design-system/signal/rtl/frames/field-select/hand-written-with-optgroups.html
+design-system/signal/rtl/frames/field-select/required-with-an-error.html
+design-system/signal/rtl/frames/field-select/three-options-a-native-select.html
+design-system/signal/rtl/frames/field-select/twelve-options-plain-the-enhancement-opted-out.html
+design-system/signal/rtl/frames/field-select/twelve-options-the-filterable-combobox.html
+design-system/signal/rtl/frames/field-text/bare.html
+design-system/signal/rtl/frames/field-text/required.html
+design-system/signal/rtl/frames/field-text/the-headline-field.html
+design-system/signal/rtl/frames/field-text/with-a-hint.html
+design-system/signal/rtl/frames/field-text/with-an-error.html
+design-system/signal/rtl/frames/field-textarea/bare.html
+design-system/signal/rtl/frames/field-textarea/required.html
+design-system/signal/rtl/frames/field-textarea/with-a-hint.html
+design-system/signal/rtl/frames/field-textarea/with-an-error.html
+design-system/signal/rtl/frames/field-time/bounded.html
+design-system/signal/rtl/frames/field-time/enhanced-input-type-half-seven-or-noon.html
+design-system/signal/rtl/frames/field-time/plain-the-bare-native-input.html
+design-system/signal/rtl/frames/field-time/required-with-an-error.html
+design-system/signal/rtl/frames/field-url/bare.html
+design-system/signal/rtl/frames/field-url/with-a-hint.html
+design-system/signal/rtl/frames/field-url/with-an-error.html
+design-system/signal/rtl/frames/field/bare.html
+design-system/signal/rtl/frames/field/required.html
+design-system/signal/rtl/frames/field/short.html
+design-system/signal/rtl/frames/field/the-headline-field.html
+design-system/signal/rtl/frames/field/with-a-hint.html
+design-system/signal/rtl/frames/field/with-an-error.html
+design-system/signal/rtl/frames/field/with-help-under-the-control.html
+design-system/signal/rtl/frames/form-error/a-rejected-submission.html
+design-system/signal/rtl/frames/form-foot/idle-the-button-before-anything-happens.html
+design-system/signal/rtl/frames/form-foot/save-and-cancel.html
+design-system/signal/rtl/frames/form-foot/submit-only.html
+design-system/signal/rtl/frames/form-foot/working-what-busy-js-writes-on-the-way-out.html
+design-system/signal/rtl/frames/form-layout/default.html
+design-system/signal/rtl/frames/format-abbr/default.html
+design-system/signal/rtl/frames/format-addresses/default.html
+design-system/signal/rtl/frames/format-dates/default.html
+design-system/signal/rtl/frames/format-durations/default.html
+design-system/signal/rtl/frames/format-filesizes/default.html
+design-system/signal/rtl/frames/format-identifiers/default.html
+design-system/signal/rtl/frames/format-money/default.html
+design-system/signal/rtl/frames/format-numbers/default.html
+design-system/signal/rtl/frames/format-output/default.html
+design-system/signal/rtl/frames/format-people/default.html
+design-system/signal/rtl/frames/format-ratios/default.html
+design-system/signal/rtl/frames/help/default.html
+design-system/signal/rtl/frames/job-status/failed.html
+design-system/signal/rtl/frames/job-status/finished.html
+design-system/signal/rtl/frames/job-status/running.html
+design-system/signal/rtl/frames/layout-column/default.html
+design-system/signal/rtl/frames/layout-console-page/default.html
+design-system/signal/rtl/frames/layout-console/default.html
+design-system/signal/rtl/frames/layout-sidebar-page/default.html
+design-system/signal/rtl/frames/layout-sidebar/default.html
+design-system/signal/rtl/frames/layout-stage/default.html
+design-system/signal/rtl/frames/layout-topbar/default.html
+design-system/signal/rtl/frames/list-bar-search/empty-accessible-name-from-the-catalog.html
+design-system/signal/rtl/frames/list-bar-search/with-a-query-on-page-4.html
+design-system/signal/rtl/frames/list-bar-search/with-a-query.html
+design-system/signal/rtl/frames/list-bar/search-with-a-filter-dropdown.html
+design-system/signal/rtl/frames/list-grid/default.html
+design-system/signal/rtl/frames/list-row-action/lead-marker-status-and-an-action.html
+design-system/signal/rtl/frames/list-row-action/name-and-meta-line-only.html
+design-system/signal/rtl/frames/list-search-submit/default-label.html
+design-system/signal/rtl/frames/locale-menu/twelve-languages-current-highlighted.html
+design-system/signal/rtl/frames/media-grid/screenshots-a-logo-and-a-banner.html
+design-system/signal/rtl/frames/media-grid/select-mode-one-tile-selected.html
+design-system/signal/rtl/frames/meter/empty.html
+design-system/signal/rtl/frames/meter/full.html
+design-system/signal/rtl/frames/meter/nearly-full.html
+design-system/signal/rtl/frames/meter/over-budget.html
+design-system/signal/rtl/frames/meter/part-way.html
+design-system/signal/rtl/frames/modal/default.html
+design-system/signal/rtl/frames/notice/a-saved-message.html
+design-system/signal/rtl/frames/page-header/title-only.html
+design-system/signal/rtl/frames/page-header/title-subhead-and-a-primary-action.html
+design-system/signal/rtl/frames/pagination/first-page-default-accessible-name.html
+design-system/signal/rtl/frames/pagination/middle-of-a-long-list.html
+design-system/signal/rtl/frames/person/large-the-show-header-size.html
+design-system/signal/rtl/frames/person/name-and-email.html
+design-system/signal/rtl/frames/person/no-initial.html
+design-system/signal/rtl/frames/row-menu/links-a-form-and-a-destructive-item.html
+design-system/signal/rtl/frames/row-menu/links-only-for-a-list-inside-a-selection-form.html
+design-system/signal/rtl/frames/scheme-menu/standalone.html
+design-system/signal/rtl/frames/scheme-menu/three-choices-dark-current.html
+design-system/signal/rtl/frames/seg-tabs/two-sections.html
+design-system/signal/rtl/frames/selbox/default.html
+design-system/signal/rtl/frames/shell-sidebar/default.html
+design-system/signal/rtl/frames/shell-topbar/default.html
+design-system/signal/rtl/frames/signin/a-passkey.html
+design-system/signal/rtl/frames/signin/after-the-link-is-sent-in-another-browser.html
+design-system/signal/rtl/frames/signin/after-the-link-is-sent.html
+design-system/signal/rtl/frames/signin/an-address-that-does-not-look-right.html
+design-system/signal/rtl/frames/signin/asking-for-an-address.html
+design-system/signal/rtl/frames/signin/coming-back-with-an-email-link.html
+design-system/signal/rtl/frames/signin/coming-back-with-keymail.html
+design-system/signal/rtl/frames/signin/email-and-password.html
+design-system/signal/rtl/frames/signin/google-apple-and-the-rest.html
+design-system/signal/rtl/frames/signin/on-the-way-to-keymail.html
+design-system/signal/rtl/frames/signin/when-keymail-could-not-confirm-it.html
+design-system/signal/rtl/frames/signin/when-keymail-was-offered-and-a-link-went-out.html
+design-system/signal/rtl/frames/stat-band/default.html
+design-system/signal/rtl/frames/stat/a-shortened-number.html
+design-system/signal/rtl/frames/stat/a-smaller-number.html
+design-system/signal/rtl/frames/stat/the-big-number.html
+design-system/signal/rtl/frames/stat/when-going-down-is-good.html
+design-system/signal/rtl/frames/stat/when-going-up-is-bad.html
+design-system/signal/rtl/frames/stat/when-you-cannot-say.html
+design-system/signal/rtl/frames/status-pill/negative.html
+design-system/signal/rtl/frames/status-pill/neutral.html
+design-system/signal/rtl/frames/status-pill/positive.html
+design-system/signal/rtl/frames/status-pill/warning.html
+design-system/signal/rtl/frames/tblock/default.html
+design-system/signal/rtl/help.html
+design-system/signal/rtl/icons.html
+design-system/signal/rtl/index.html
+design-system/signal/rtl/job-status.html
+design-system/signal/rtl/layout-column.html
+design-system/signal/rtl/layout-console-page.html
+design-system/signal/rtl/layout-console.html
+design-system/signal/rtl/layout-sidebar-page.html
+design-system/signal/rtl/layout-sidebar.html
+design-system/signal/rtl/layout-stage.html
+design-system/signal/rtl/layout-topbar.html
+design-system/signal/rtl/list-bar-search.html
+design-system/signal/rtl/list-bar.html
+design-system/signal/rtl/list-grid.html
+design-system/signal/rtl/list-row-action.html
+design-system/signal/rtl/list-search-submit.html
+design-system/signal/rtl/locale-menu.html
+design-system/signal/rtl/media-grid.html
+design-system/signal/rtl/meter.html
+design-system/signal/rtl/modal.html
+design-system/signal/rtl/notice.html
+design-system/signal/rtl/page-header.html
+design-system/signal/rtl/pagination.html
+design-system/signal/rtl/person.html
+design-system/signal/rtl/row-menu.html
+design-system/signal/rtl/scheme-menu.html
+design-system/signal/rtl/seg-tabs.html
+design-system/signal/rtl/selbox.html
+design-system/signal/rtl/shell-sidebar.html
+design-system/signal/rtl/shell-topbar.html
+design-system/signal/rtl/signin.html
+design-system/signal/rtl/stat-band.html
+design-system/signal/rtl/stat.html
+design-system/signal/rtl/status-pill.html
+design-system/signal/rtl/tblock.html
+design-system/signal/rtl/tokens.html
+design-system/signal/scheme-menu.html
+design-system/signal/seg-tabs.html
+design-system/signal/selbox.html
+design-system/signal/shell-sidebar.html
+design-system/signal/shell-topbar.html
+design-system/signal/signin.html
+design-system/signal/stat-band.html
+design-system/signal/stat.html
+design-system/signal/status-pill.html
+design-system/signal/tblock.html
+design-system/signal/tokens.html
diff --git a/internal/www/testdata/part3-ids.txt b/internal/www/testdata/part3-ids.txt
new file mode 100644
index 0000000..cb55099
--- /dev/null
+++ b/internal/www/testdata/part3-ids.txt
@@ -0,0 +1,16 @@
+# The element ids on a sample of part 3's example, family, Tokens and
+# Icons pages, in each theme, left to right and right to left, one page
+# a line: its tree path, then its ids, sorted. A part 3 link with a
+# fragment keeps it through the redirect, so the page it lands on has
+# to carry every one. From the same build of 6af218e as
+# part3-addresses.txt; to regenerate, in that build's public/:
+#
+# for p in <the paths below>; do
+# printf '%s' "$p"; grep -o ' id="[^"]*"' "$p" | sed 's/^ id="\(.*\)"$/ \1/' | LC_ALL=C sort -u | tr -d '\n'; echo
+# done
+design-system/day/signin.html a-passkey after-the-link-is-sent after-the-link-is-sent-in-another-browser an-address-that-does-not-look-right asking-for-an-address coming-back-with-an-email-link coming-back-with-keymail email-and-password google-apple-and-the-rest main on-the-way-to-keymail when-keymail-could-not-confirm-it when-keymail-was-offered-and-a-link-went-out
+design-system/day/rtl/tokens.html main tokens-accent tokens-controls tokens-depth tokens-radius tokens-spacing tokens-status-tones tokens-surfaces-and-lines tokens-text tokens-type-choices tokens-type-family tokens-type-scale
+design-system/signal/icons.html icon-alert-triangle icon-check icon-check-circle icon-chevron-down icon-help-circle icon-info icon-kebab icon-menu icon-plus icon-search icon-x icon-x-circle main
+design-system/signal/rtl/field.html bare main required short the-headline-field with-a-hint with-an-error with-help-under-the-control
+design-system/plain/stat.html a-shortened-number a-smaller-number main the-big-number when-going-down-is-good when-going-up-is-bad when-you-cannot-say
+design-system/plain/rtl/error-page.html 403-forbidden 404-not-found 422-unprocessable 500-server-error 503-unavailable anything-else-the-generic-pair main
diff --git a/internal/www/www.go b/internal/www/www.go
index 21579b3..38b58c1 100644
--- a/internal/www/www.go
+++ b/internal/www/www.go
@@ -122,3 +122,19 @@ func Pages(src fs.FS, siteDir fs.FS) (map[string][]byte, error) {
}
return out, nil
}
+
+// Redirects are the _redirects rules the pages need beside the files:
+// the design system's (gallery.Redirects), which send the addresses
+// its pages had before they moved under a locale to the pages there
+// now. pages is what Pages returned; site.Build writes the rules after
+// the release rewrites.
+func Redirects(pages map[string][]byte) []string {
+ prefix := strings.TrimPrefix(Mount, "/") + "/"
+ ds := map[string][]byte{}
+ for name, data := range pages {
+ if rest, ok := strings.CutPrefix(name, prefix); ok {
+ ds[rest] = data
+ }
+ }
+ return gallery.Redirects(Mount, ds)
+}