The landing page in eleven languages, Arabic right to left
Human
Prompt
Task 5 of Fiesta part 3: one real static landing page per language, English at / and every other at /<code>/, from www.Languages and www.Strings, with <html lang dir> from www.Dir, a rel=alternate hreflang link for all eleven plus x-default, and a language menu in the locale-menu example's markup whose entries are links with lang set, named by the example's endonyms, the current one aria-current. No redirect from /. The link box's code left to right and untranslated. Site-relative links working from /<code>/ pages. Tests seen red by name: TestEveryLanguageHasAPage, TestEveryPageLinksEveryLanguage, TestArabicReadsRightToLeft, and the 320px and axe drives extended to every language and to Arabic and Japanese.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Pages now renders the landing page once per language in Languages: index.html for English and <code>/index.html for the other ten, each from its own catalog, with lang and dir on the html element. The head carries a rel=alternate hreflang link for every language and an x-default pointing at the root; the language menu lists the eleven as links with lang set, named as Fiesta's locale-menu example names them, the page's own language marked aria-current, inside the menu's translate="no". Every URL on the page is root-relative, a fragment or absolute, so a page under /<code>/ resolves nothing against the wrong directory. Two things the other languages showed at 320px are fixed in site.css: the sample pane's grid column grew to the course tabs' max-content width (328px in Spanish, 338 in Arabic) and clipped the Menu pane, so the pane now has one minmax(0, 1fr) column and Fiesta's seg-tabs wrap as designed; and Russian's Отпразднуйте at 2.6rem was wider than the hero, so below 560px the headline's size follows the viewport down to 1.9rem. TestTheLandingHasNoHorizontalScrollAt320 walks all eleven pages on one browser, TestTheLandingPassesAxe adds the Arabic and Japanese pages in Day, light and dark, and TestArabicReadsRightToLeft holds the Arabic page to dir="rtl", the brand to the right of the top bar's last item by bounding box, and the install code to ltr.
Agent notes
TestEveryLanguageHasAPage and TestEveryPageLinksEveryLanguage were red by assertion against the English-only tree (Pages wrote no ar/index.html; index.html has 0 hreflang links, want 12; the menu lists 1 language, want 11). The three browser drives were red by assertion on the missing /ar/ page, each in under three seconds. Green on the real pages found the two phone-width faults above in six languages, which English's shorter words had hidden; the Arabic drive first compared the brand's and the last item's top edges exactly, which Arabic glyph metrics put a pixel apart, so it now requires the two boxes to overlap vertically. The hreflang hrefs are root-relative as the plan says; search engines prefer absolute URLs there, which would need the site's host in the template. The scheme menu's hidden return inputs still say "/" on every page; they are the standalone example's markup and nothing submits them. Japanese's headline breaks mid-phrase at 390px, as CJK text wraps without word-break: keep-all. The endonyms are copied into landing.go from examples/locale-menu, and TestEveryPageLinksEveryLanguage reads the example to hold the menu to it. make ci was green in a clean worktree before this commit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+444
−100
internal/www/landing.go+66 −4internal/www/landing_browser_test.go+153 −84internal/www/landing_test.go+199 −0internal/www/www.go+13 −9site/landing/page.html.tmpl+4 −2site/static/site.css+9 −1
CI passed — run details
CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok amadan.net/rastrillo/fiesta 0.097s
=== 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.02s)
PASS
ok amadan.net/rastrillo/fiesta/internal/build 0.021s
=== suite ===
--- shard 0 of 12 ---
==> shard 0 of 12: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
ok amadan.net/rastrillo/fiesta 1.231s
ok amadan.net/rastrillo/fiesta/check 5.029s
ok amadan.net/rastrillo/fiesta/cmd/fiesta 7.846s
ok amadan.net/rastrillo/fiesta/example 0.120s
ok amadan.net/rastrillo/fiesta/internal/axe 0.181s
ok amadan.net/rastrillo/fiesta/internal/build 1.168s
ok amadan.net/rastrillo/fiesta/internal/codeview 0.012s
ok amadan.net/rastrillo/fiesta/internal/gallery 4.994s
? amadan.net/rastrillo/fiesta/internal/harness [no test files]
ok amadan.net/rastrillo/fiesta/internal/markup 0.034s
ok amadan.net/rastrillo/fiesta/internal/nodetest 0.388s
ok amadan.net/rastrillo/fiesta/internal/site 3.433s
ok amadan.net/rastrillo/fiesta/internal/tokens 0.052s
ok amadan.net/rastrillo/fiesta/internal/www 8.003s
--- shard 1 of 12 ---
==> shard 1 of 12: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 12 ---
==> shard 2 of 12: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
This scan also found 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.
--- shard 3 of 12 ---
==> shard 3 of 12: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
[90m7:46PM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net
[90m7:46PM[0m [32mINF[0m [1m1 commits scanned.[0m
[90m7:46PM[0m [32mINF[0m [1mscanned ~5811360 bytes (5.81 MB) in 2.85s[0m
[90m7:46PM[0m [32mINF[0m [1mno leaks found[0m
--- shard 4 of 12 ---
==> shard 4 of 12: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok amadan.net/rastrillo/fiesta/internal/harness 11.139s
--- shard 5 of 12 ---
==> shard 5 of 12: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 95 of 286 tests
ok amadan.net/rastrillo/fiesta/check 193.259s
--- shard 6 of 12 ---
==> shard 6 of 12: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 95 of 286 tests
ok amadan.net/rastrillo/fiesta/check 203.938s
--- shard 7 of 12 ---
==> shard 7 of 12: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 96 of 286 tests
ok amadan.net/rastrillo/fiesta/check 203.424s
--- shard 8 of 12 ---
==> shard 8 of 12: make browser.internal.gallery~1of2
./hack/gotest.sh browser 'internal.gallery~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/gallery/ runs 1 of 25 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 73.939s
--- shard 9 of 12 ---
==> shard 9 of 12: make browser.internal.gallery~2of2
./hack/gotest.sh browser 'internal.gallery~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/gallery/ runs 24 of 25 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 34.542s
--- shard 10 of 12 ---
==> shard 10 of 12: make browser.internal.www~1of2
./hack/gotest.sh browser 'internal.www~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/www/ runs 16 of 31 tests
ok amadan.net/rastrillo/fiesta/internal/www 31.785s
--- shard 11 of 12 ---
==> shard 11 of 12: make browser.internal.www~2of2
./hack/gotest.sh browser 'internal.www~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/www/ runs 15 of 31 tests
ok amadan.net/rastrillo/fiesta/internal/www 33.485s
Intent
diff --git a/internal/www/landing.go b/internal/www/landing.go| index a6eab41..e3b220d 100644 |
| --- a/internal/www/landing.go |
| +++ b/internal/www/landing.go |
| @@ -17,13 +17,70 @@ const landingTemplate = "landing/page.html.tmpl" |
| // page is what the template sees: the language and its direction for |
| // the html element, the version the footer shows, the still bunting, |
| -// and the catalog behind T. |
| +// every language the page is built in for the hreflang links and the |
| +// language menu, and the catalog behind T. |
| type page struct { |
| Lang, Dir, Version string |
| Bunting template.HTML |
| + Languages []menuLanguage |
| strings map[string]string |
| } |
| +// menuLanguage is one entry of the language menu and one hreflang |
| +// link: a language's code, its name in its own language, where its |
| +// page lives, and whether it is the page being rendered. |
| +type menuLanguage struct { |
| + Code, Name, URL string |
| + Current bool |
| +} |
| + |
| +// endonyms names each language in its own language, as Fiesta's |
| +// locale-menu example does (examples/locale-menu/twelve-languages- |
| +// current-highlighted.html), so the site's menu and Fiesta's agree. |
| +// TestEveryPageLinksEveryLanguage holds the menu to the example. |
| +var endonyms = map[string]string{ |
| + "en": "English", |
| + "ar": "العربية", |
| + "bn": "বাংলা", |
| + "es": "Español", |
| + "ga": "Gaeilge", |
| + "hi": "हिन्दी", |
| + "ja": "日本語", |
| + "pt-PT": "Português", |
| + "ru": "Русский", |
| + "vi": "Tiếng Việt", |
| + "zh-Hans": "简体中文", |
| +} |
| + |
| +// pagePath is where the landing page in lang lives in the tree: |
| +// English at the root, every other language under its code, so each |
| +// is a real page that a reader can bookmark and that needs no script. |
| +func pagePath(lang string) string { |
| + if lang == "en" { |
| + return "index.html" |
| + } |
| + return lang + "/index.html" |
| +} |
| + |
| +// pageURL is the same page's URL, a directory the edge serves its |
| +// index from. |
| +func pageURL(lang string) string { |
| + if lang == "en" { |
| + return "/" |
| + } |
| + return "/" + lang + "/" |
| +} |
| + |
| +// languages is the menu and hreflang list for the page in current, in |
| +// Languages' order, with the current one marked. |
| +func languages(current string) []menuLanguage { |
| + out := make([]menuLanguage, 0, len(Languages)) |
| + for _, l := range Languages { |
| + out = append(out, menuLanguage{Code: l, Name: endonyms[l], URL: pageURL(l), Current: l == current}) |
| + } |
| + return out |
| +} |
| + |
| // buntingGeometry is the bunting's shape in the SVG's own units, one |
| // unit a pixel at scale 1: pennants FW wide at the top and FH to the |
| // tip, a lattice NY rows deep, hung Tie below a rope that starts TopY |
| @@ -272,9 +329,14 @@ func (p page) T(key string) (string, error) { |
| // landing renders the landing page in lang from the site source: the |
| // template at landing/page.html.tmpl filled from Strings' catalog, with |
| -// the version from fiesta.Version. html/template escapes every string |
| -// for where it lands, so the install line's < reads as itself. |
| +// the version from fiesta.Version and every language's name and URL |
| +// for the menu. html/template escapes every string for where it lands, |
| +// so the install line's < reads as itself. A lang outside Languages |
| +// has no page, and is refused before its catalog is looked for. |
| func landing(siteDir fs.FS, lang string) ([]byte, error) { |
| + if _, ok := endonyms[lang]; !ok { |
| + return nil, fmt.Errorf("www: %s is not a language the landing page is built in", lang) |
| + } |
| strings, err := Strings(siteDir, lang) |
| if err != nil { |
| return nil, err |
| @@ -288,7 +350,7 @@ func landing(siteDir fs.FS, lang string) ([]byte, error) { |
| return nil, fmt.Errorf("www: %w", err) |
| } |
| var buf bytes.Buffer |
| - if err := tmpl.Execute(&buf, page{Lang: lang, Dir: Dir(lang), Version: fiesta.Version, Bunting: stillBunting(buntingShape), strings: strings}); err != nil { |
| + if err := tmpl.Execute(&buf, page{Lang: lang, Dir: Dir(lang), Version: fiesta.Version, Bunting: stillBunting(buntingShape), Languages: languages(lang), strings: strings}); err != nil { |
| return nil, fmt.Errorf("www: rendering the landing page in %s: %w", lang, err) |
| } |
| return buf.Bytes(), nil |
diff --git a/internal/www/landing_browser_test.go b/internal/www/landing_browser_test.go| index c62b7b4..2688c5e 100644 |
| --- a/internal/www/landing_browser_test.go |
| +++ b/internal/www/landing_browser_test.go |
| @@ -88,17 +88,24 @@ func soon(sel string) chromedp.Action { |
| }) |
| } |
| -// open loads the landing page at the given viewport, waits for it to |
| -// load, and requires the sample to be on it: a page without the sample |
| -// is not the landing page, and the drive fails here by name. |
| +// open loads the English landing page at the given viewport, waits |
| +// for it to load, and requires the sample to be on it: a page without |
| +// the sample is not the landing page, and the drive fails here by name. |
| func open(t *testing.T, ctx context.Context, rig *harness.Rig, w, h int64) { |
| t.Helper() |
| - mustRun(t, ctx, chromedp.EmulateViewport(w, h), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body"), |
| + openPath(t, ctx, rig, "/", w, h) |
| +} |
| + |
| +// openPath is open at any path: the landing page in another language |
| +// lives at /<code>/. |
| +func openPath(t *testing.T, ctx context.Context, rig *harness.Rig, p string, w, h int64) { |
| + t.Helper() |
| + mustRun(t, ctx, chromedp.EmulateViewport(w, h), chromedp.Navigate(rig.Origin+p), chromedp.WaitReady("body"), |
| chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) |
| var present bool |
| mustRun(t, ctx, chromedp.Evaluate(`!!document.getElementById("sample")`, &present)) |
| if !present { |
| - t.Fatal("the page has no #sample; this is not the landing page") |
| + t.Fatalf("the page at %s has no #sample; this is not the landing page", p) |
| } |
| } |
| @@ -171,16 +178,18 @@ func currentScheme(t *testing.T, ctx context.Context) string { |
| } |
| // TestTheLandingPassesAxe: the whole page, in Day, Plain and Signal, |
| -// each light and dark, with the WCAG 2.2 AA tag set. Each theme is |
| -// chosen from the theme bar, as a reader would, and each scheme is |
| -// set on the root, the way fiesta.js sets it. |
| +// each light and dark, with the WCAG 2.2 AA tag set; then the Arabic |
| +// and Japanese pages in Day, light and dark, for a right-to-left |
| +// script and one with no spaces between words. Each theme is chosen |
| +// from the theme bar, as a reader would, and each scheme is set on |
| +// the root, the way fiesta.js sets it. |
| func TestTheLandingPassesAxe(t *testing.T) { |
| rig, _ := siteRig(t) |
| ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) |
| defer cancel() |
| - open(t, ctx, rig, 1280, 900) |
| scans, failed := 0, 0 |
| - for _, theme := range []string{"day", "plain", "signal"} { |
| + scan := func(lang, theme string) { |
| + t.Helper() |
| chooseTheme(t, ctx, theme) |
| ground := map[string]string{} |
| for _, scheme := range []string{"light", "dark"} { |
| @@ -189,7 +198,7 @@ func TestTheLandingPassesAxe(t *testing.T) { |
| ground[scheme] = bg |
| res, err := axe.Scan(ctx, "", axe.DefaultTags()) |
| if err != nil { |
| - t.Fatalf("%s %s: %v", theme, scheme, err) |
| + t.Fatalf("%s %s %s: %v", lang, theme, scheme, err) |
| } |
| scans++ |
| for _, v := range res.Violations { |
| @@ -198,17 +207,31 @@ func TestTheLandingPassesAxe(t *testing.T) { |
| if len(nodes) > 5 { |
| nodes = append(nodes[:5], fmt.Sprintf("… and %d more", len(v.Nodes)-5)) |
| } |
| - t.Errorf("%s %s: %s (%s): %s\n %s", theme, scheme, v.ID, v.Impact, v.Help, strings.Join(nodes, "\n ")) |
| + t.Errorf("%s %s %s: %s (%s): %s\n %s", lang, theme, scheme, v.ID, v.Impact, v.Help, strings.Join(nodes, "\n ")) |
| } |
| - t.Logf("%s %s: needs review, axe could not decide: %v", theme, scheme, res.Incomplete) |
| + t.Logf("%s %s %s: needs review, axe could not decide: %v", lang, theme, scheme, res.Incomplete) |
| } |
| if ground["light"] == ground["dark"] { |
| - t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", theme, ground["light"]) |
| + t.Fatalf("%s %s: the body paints %s under both schemes, so the flip to dark did not take", lang, theme, ground["light"]) |
| } |
| + mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.removeAttribute("data-theme")`, nil)) |
| + } |
| + open(t, ctx, rig, 1280, 900) |
| + for _, theme := range []string{"day", "plain", "signal"} { |
| + scan("en", theme) |
| } |
| - mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.removeAttribute("data-theme")`, nil)) |
| - t.Logf("%d scans, %d violations", scans, failed) |
| rig.Screen("body", "the landing page") |
| + for _, lang := range []string{"ar", "ja"} { |
| + openPath(t, ctx, rig, pageURL(lang), 1280, 900) |
| + var got string |
| + mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.lang`, &got)) |
| + if got != lang { |
| + t.Fatalf("the page at %s is in %q; want %s", pageURL(lang), got, lang) |
| + } |
| + scan(lang, "day") |
| + rig.Screen("body", "the landing page in "+lang) |
| + } |
| + t.Logf("%d scans, %d violations", scans, failed) |
| } |
| // measureJS reports whether the document scrolls sideways, and the |
| @@ -250,85 +273,131 @@ const windowJS = `(() => { |
| })()` |
| // TestTheLandingHasNoHorizontalScrollAt320 is WCAG 1.4.10 Reflow on |
| -// the landing page: at a 320 CSS-pixel viewport nothing scrolls |
| -// sideways, on the page as it opens and with every sample pane and |
| -// every course shown, and nothing inside the sample window reaches |
| -// past the window's edge, where its clipping would hide it. The |
| -// page's content is required first, so a stub with a sample and |
| -// nothing else cannot pass. The bunting is allowed to be wider than |
| -// the page: it is clipped by its own box, and the check is the |
| -// document's scroll width, which clipping keeps. |
| +// the landing page, in every one of its eleven languages: at a 320 |
| +// CSS-pixel viewport nothing scrolls sideways, on the page as it opens |
| +// and with every sample pane and every course shown, and nothing |
| +// inside the sample window reaches past the window's edge, where its |
| +// clipping would hide it. Each page's content is required first, so a |
| +// stub with a sample and nothing else cannot pass. The bunting is |
| +// allowed to be wider than the page: it is clipped by its own box, and |
| +// the check is the document's scroll width, which clipping keeps. |
| func TestTheLandingHasNoHorizontalScrollAt320(t *testing.T) { |
| rig, _ := siteRig(t) |
| - ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) |
| defer cancel() |
| mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { |
| return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c) |
| - }), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body"), |
| - chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) |
| - for _, c := range []struct { |
| - sel string |
| - want int |
| - }{ |
| - {"#headline", 1}, |
| - {"#install code", 1}, |
| - {".site-themebar", 1}, |
| - {"#sample .site-side a", 5}, |
| - {"#sample .site-pane", 5}, |
| - {"#sample .site-pane h2", 5}, |
| - {"#sample .site-course", 3}, |
| - {"#reasons [rst-card]", 6}, |
| - {"#footer nav", 4}, |
| - {"#bunting svg", 1}, |
| - } { |
| - if s := visible(t, ctx, c.sel); s.Total != c.want { |
| - t.Fatalf("%s: %d on the page; want %d. This is not the landing page, and measuring it would prove nothing", c.sel, s.Total, c.want) |
| - } |
| - } |
| - mustRun(t, ctx, soon(".site-themebar")) |
| - |
| - measure := func(where string) { |
| - t.Helper() |
| - var page struct { |
| - Doc, Client, Body int |
| - Over []string |
| - } |
| - at(t, ctx, measureJS, &page) |
| - if page.Doc > page.Client || page.Body > page.Client { |
| - t.Errorf("%s: the page scrolls sideways at 320px (WCAG 1.4.10): document %dpx, body %dpx in a %dpx page\n widest: %s", |
| - where, page.Doc, page.Body, page.Client, strings.Join(page.Over, "\n ")) |
| - } |
| - var win struct { |
| - Left, Right, VW float64 |
| - Over []string |
| - } |
| - at(t, ctx, windowJS, &win) |
| - if win.Left < -1 || win.Right > win.VW+1 { |
| - t.Errorf("%s: the sample window spans %.0f…%.0f in a %.0fpx viewport", where, win.Left, win.Right, win.VW) |
| - } |
| - if len(win.Over) > 0 { |
| - t.Errorf("%s: %d element(s) inside the sample reach past the window's edge, where it clips them:\n %s", where, len(win.Over), strings.Join(win.Over, "\n ")) |
| + })) |
| + for _, lang := range www.Languages { |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+pageURL(lang)), chromedp.WaitReady("body"), |
| + chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) |
| + var got string |
| + mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.lang`, &got)) |
| + if got != lang { |
| + t.Fatalf("%s: the page is in %q; want %s. This is not the landing page in %s, and measuring it would prove nothing", pageURL(lang), got, lang, lang) |
| } |
| - } |
| - measure("as the page opens") |
| - for _, pane := range []string{"pane-guests", "pane-menu", "pane-music", "pane-settings", "pane-overview"} { |
| - showPane(t, ctx, pane) |
| - if p := readPanes(t, ctx); strings.Join(p.Panes, " ") != pane { |
| - t.Fatalf("after pressing %s the sample shows %v", pane, p.Panes) |
| + for _, c := range []struct { |
| + sel string |
| + want int |
| + }{ |
| + {"#headline", 1}, |
| + {"#install code", 1}, |
| + {".site-themebar", 1}, |
| + {"#sample .site-side a", 5}, |
| + {"#sample .site-pane", 5}, |
| + {"#sample .site-pane h2", 5}, |
| + {"#sample .site-course", 3}, |
| + {"#reasons [rst-card]", 6}, |
| + {"#footer nav", 4}, |
| + {"#bunting svg", 1}, |
| + } { |
| + if s := visible(t, ctx, c.sel); s.Total != c.want { |
| + t.Fatalf("%s: %s: %d on the page; want %d. This is not the landing page, and measuring it would prove nothing", pageURL(lang), c.sel, s.Total, c.want) |
| + } |
| } |
| - measure(pane) |
| - if pane != "pane-menu" { |
| - continue |
| + mustRun(t, ctx, soon(".site-themebar")) |
| + |
| + measure := func(where string) { |
| + t.Helper() |
| + where = lang + ", " + where |
| + var page struct { |
| + Doc, Client, Body int |
| + Over []string |
| + } |
| + at(t, ctx, measureJS, &page) |
| + if page.Doc > page.Client || page.Body > page.Client { |
| + t.Errorf("%s: the page scrolls sideways at 320px (WCAG 1.4.10): document %dpx, body %dpx in a %dpx page\n widest: %s", |
| + where, page.Doc, page.Body, page.Client, strings.Join(page.Over, "\n ")) |
| + } |
| + var win struct { |
| + Left, Right, VW float64 |
| + Over []string |
| + } |
| + at(t, ctx, windowJS, &win) |
| + if win.Left < -1 || win.Right > win.VW+1 { |
| + t.Errorf("%s: the sample window spans %.0f…%.0f in a %.0fpx viewport", where, win.Left, win.Right, win.VW) |
| + } |
| + if len(win.Over) > 0 { |
| + t.Errorf("%s: %d element(s) inside the sample reach past the window's edge, where it clips them:\n %s", where, len(win.Over), strings.Join(win.Over, "\n ")) |
| + } |
| } |
| - for _, course := range []string{"course-mains", "course-desserts", "course-starters"} { |
| - mustRun(t, ctx, chromedp.Click(fmt.Sprintf(`#sample [data-site-course][href="#%s"]`, course), chromedp.ByQuery)) |
| - if p := readPanes(t, ctx); strings.Join(p.Courses, " ") != course { |
| - t.Fatalf("after pressing %s the Menu pane shows %v", course, p.Courses) |
| + measure("as the page opens") |
| + for _, pane := range []string{"pane-guests", "pane-menu", "pane-music", "pane-settings", "pane-overview"} { |
| + showPane(t, ctx, pane) |
| + if p := readPanes(t, ctx); strings.Join(p.Panes, " ") != pane { |
| + t.Fatalf("%s: after pressing %s the sample shows %v", lang, pane, p.Panes) |
| + } |
| + measure(pane) |
| + if pane != "pane-menu" { |
| + continue |
| + } |
| + for _, course := range []string{"course-mains", "course-desserts", "course-starters"} { |
| + mustRun(t, ctx, chromedp.Click(fmt.Sprintf(`#sample [data-site-course][href="#%s"]`, course), chromedp.ByQuery)) |
| + if p := readPanes(t, ctx); strings.Join(p.Courses, " ") != course { |
| + t.Fatalf("%s: after pressing %s the Menu pane shows %v", lang, course, p.Courses) |
| + } |
| + measure(course) |
| } |
| - measure(course) |
| } |
| + rig.Screen("#sample", "the page at 320 in "+lang) |
| + } |
| +} |
| + |
| +// rtlJS measures the top bar: the brand's box and the last item's, the |
| +// document's direction, and the install code's computed direction. |
| +const rtlJS = `(() => { const b = document.querySelector(".site-brand").getBoundingClientRect(), items = document.querySelectorAll(".site-nav > *"), l = items[items.length - 1].getBoundingClientRect(), code = document.querySelector("#install code"); |
| + return JSON.stringify({Dir: document.documentElement.dir, BodyDir: getComputedStyle(document.body).direction, BrandLeft: b.left, BrandRight: b.right, LastLeft: l.left, LastRight: l.right, BrandTop: b.top, BrandBottom: b.bottom, LastTop: l.top, LastBottom: l.bottom, CodeDir: getComputedStyle(code).direction, CodeAttr: code.getAttribute("dir")}); })()` |
| + |
| +// TestArabicReadsRightToLeft: the Arabic page is dir="rtl" and lays |
| +// out that way, so the top bar's brand sits to the right of its last |
| +// item, by bounding box; and the install line's code stays left to |
| +// right, since a URL reads that way in any language. |
| +func TestArabicReadsRightToLeft(t *testing.T) { |
| + rig, _ := siteRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) |
| + defer cancel() |
| + openPath(t, ctx, rig, pageURL("ar"), 1280, 900) |
| + var g struct { |
| + Dir, BodyDir, CodeDir, CodeAttr string |
| + BrandLeft, BrandRight, LastLeft, LastRight, BrandTop, BrandBottom, LastTop, LastBottom float64 |
| + } |
| + at(t, ctx, rtlJS, &g) |
| + if g.Dir != "rtl" || g.BodyDir != "rtl" { |
| + t.Errorf("the Arabic page has dir=%q and the body's computed direction is %q; want rtl for both", g.Dir, g.BodyDir) |
| + } |
| + // On the same row: the two boxes overlap vertically. Their tops |
| + // differ by a pixel, since Arabic and Latin glyphs sit differently |
| + // in a centred flex row, so they are not compared exactly. |
| + if g.BrandBottom <= g.LastTop || g.LastBottom <= g.BrandTop { |
| + t.Errorf("the top bar wrapped: the brand spans y=%.0f…%.0f and the last item y=%.0f…%.0f, so left and right cannot be compared", g.BrandTop, g.BrandBottom, g.LastTop, g.LastBottom) |
| + } |
| + if g.BrandLeft < g.LastRight { |
| + t.Errorf("the brand spans %.0f…%.0f and the top bar's last item %.0f…%.0f; right to left, the brand is to the right of everything", g.BrandLeft, g.BrandRight, g.LastLeft, g.LastRight) |
| + } |
| + if g.CodeAttr != "ltr" || g.CodeDir != "ltr" { |
| + t.Errorf("the install code has dir=%q and a computed direction of %q; want ltr for both, so the URL reads as a URL", g.CodeAttr, g.CodeDir) |
| } |
| - rig.Screen("#sample", "the page at 320") |
| + rig.Screen("#headline", "the Arabic page") |
| } |
| // TestTheLandingReadsWithNoScript: with JavaScript off the page still |
diff --git a/internal/www/landing_test.go b/internal/www/landing_test.go| index 30d7c22..4e4745a 100644 |
| --- a/internal/www/landing_test.go |
| +++ b/internal/www/landing_test.go |
| @@ -13,6 +13,7 @@ import ( |
| xhtml "golang.org/x/net/html" |
| + "amadan.net/rastrillo/fiesta" |
| "amadan.net/rastrillo/fiesta/internal/www" |
| ) |
| @@ -412,3 +413,201 @@ func TestEveryTextSurfaceComesFromTheCatalog(t *testing.T) { |
| t.Errorf("the control, one hard-coded word, was reported as %v; want exactly it", got) |
| } |
| } |
| + |
| +// pagePath is where a language's landing page lives in the tree: |
| +// English at the root, every other language under its code. |
| +func pagePath(lang string) string { |
| + if lang == "en" { |
| + return "index.html" |
| + } |
| + return lang + "/index.html" |
| +} |
| + |
| +// pageURL is the same page's URL on the site, a directory with a |
| +// trailing slash, which is what the hreflang links and the language |
| +// menu point at. |
| +func pageURL(lang string) string { |
| + if lang == "en" { |
| + return "/" |
| + } |
| + return "/" + lang + "/" |
| +} |
| + |
| +// hasScheme reports whether u is an absolute URL, scheme and all. |
| +var hasScheme = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`) |
| + |
| +// TestEveryLanguageHasAPage: Pages writes one landing page per |
| +// language in www.Languages, English at index.html and every other |
| +// at <code>/index.html, each with its own lang and dir on the html |
| +// element and its own catalog's title. The root page is English and |
| +// does not redirect. And since every other page lives one directory |
| +// down, every URL on it is root-relative, a fragment or absolute: |
| +// a document-relative URL would resolve against /<code>/ and miss. |
| +func TestEveryLanguageHasAPage(t *testing.T) { |
| + out := pages(t, siteOf(nil)) |
| + for _, lang := range www.Languages { |
| + doc, ok := out[pagePath(lang)] |
| + if !ok { |
| + t.Errorf("Pages wrote no %s; the landing page in %s is missing", pagePath(lang), lang) |
| + continue |
| + } |
| + strs, err := www.Strings(siteSource(t).FS(), lang) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var html *xhtml.Node |
| + var title string |
| + var relative []string |
| + walk(parse(t, doc), func(n *xhtml.Node) { |
| + switch n.Data { |
| + case "html": |
| + html = n |
| + case "title": |
| + title = text(n) |
| + case "meta": |
| + if strings.EqualFold(attr(n, "http-equiv"), "refresh") { |
| + t.Errorf("%s carries a meta refresh; a landing page never redirects", pagePath(lang)) |
| + } |
| + } |
| + for _, a := range n.Attr { |
| + if a.Key != "href" && a.Key != "src" && a.Key != "action" { |
| + continue |
| + } |
| + if strings.HasPrefix(a.Val, "/") || strings.HasPrefix(a.Val, "#") || hasScheme.MatchString(a.Val) { |
| + continue |
| + } |
| + relative = append(relative, "<"+n.Data+" "+a.Key+"="+strconvQuote(a.Val)+">") |
| + } |
| + }) |
| + if html == nil { |
| + t.Errorf("%s has no html element", pagePath(lang)) |
| + continue |
| + } |
| + if got := attr(html, "lang"); got != lang { |
| + t.Errorf("%s has <html lang=%q>; want %q", pagePath(lang), got, lang) |
| + } |
| + if got, want := attr(html, "dir"), www.Dir(lang); got != want { |
| + t.Errorf("%s has <html dir=%q>; want %q", pagePath(lang), got, want) |
| + } |
| + if title != strs["title"] { |
| + t.Errorf("%s is titled %q; the %s catalog says %q", pagePath(lang), title, lang, strs["title"]) |
| + } |
| + if len(relative) > 0 { |
| + t.Errorf("%s has %d document-relative URLs, which resolve against %s rather than the root:\n %s", pagePath(lang), len(relative), pageURL(lang), strings.Join(relative, "\n ")) |
| + } |
| + } |
| + if _, ok := out["en/index.html"]; ok { |
| + t.Error("Pages wrote en/index.html; English lives at the root, not under /en/") |
| + } |
| +} |
| + |
| +// endonyms reads each language's name in its own language from |
| +// Fiesta's locale-menu example, so the site's menu names languages as |
| +// Fiesta does and invents nothing. |
| +func endonyms(t *testing.T) map[string]string { |
| + t.Helper() |
| + doc, err := fs.ReadFile(fiesta.FS(), "examples/locale-menu/twelve-languages-current-highlighted.html") |
| + if err != nil { |
| + t.Fatalf("reading the locale-menu example: %v", err) |
| + } |
| + out := map[string]string{} |
| + walk(parse(t, doc), func(n *xhtml.Node) { |
| + if n.Data == "button" && attr(n, "lang") != "" { |
| + out[attr(n, "lang")] = text(n) |
| + } |
| + }) |
| + for _, lang := range www.Languages { |
| + if out[lang] == "" { |
| + t.Fatalf("the locale-menu example names no %s; the site's menu has nothing to take its name from", lang) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestEveryPageLinksEveryLanguage: every landing page carries a |
| +// rel=alternate hreflang link for each of the eleven languages and an |
| +// x-default pointing at the root, and its language menu lists the |
| +// eleven as links with lang set, named by the locale-menu example's |
| +// endonyms, never translated, with the page's own language marked |
| +// aria-current and no other. |
| +func TestEveryPageLinksEveryLanguage(t *testing.T) { |
| + out := pages(t, siteOf(nil)) |
| + names := endonyms(t) |
| + for _, lang := range www.Languages { |
| + doc, ok := out[pagePath(lang)] |
| + if !ok { |
| + t.Errorf("Pages wrote no %s", pagePath(lang)) |
| + continue |
| + } |
| + alternates := map[string]string{} |
| + var menu []*xhtml.Node |
| + walk(parse(t, doc), func(n *xhtml.Node) { |
| + switch { |
| + case n.Data == "link" && attr(n, "rel") == "alternate": |
| + if hl := attr(n, "hreflang"); hl != "" { |
| + if _, dup := alternates[hl]; dup { |
| + t.Errorf("%s has two hreflang=%q links", pagePath(lang), hl) |
| + } |
| + alternates[hl] = attr(n, "href") |
| + } |
| + case n.Data == "a" && attr(n, "lang") != "": |
| + inMenu := false |
| + for p := n.Parent; p != nil; p = p.Parent { |
| + if attr(p, "class") == "site-lang" { |
| + inMenu = true |
| + } |
| + } |
| + if inMenu { |
| + menu = append(menu, n) |
| + } |
| + } |
| + }) |
| + for _, l := range www.Languages { |
| + if got, want := alternates[l], pageURL(l); got != want { |
| + t.Errorf("%s links hreflang=%q to %q; want %q", pagePath(lang), l, got, want) |
| + } |
| + } |
| + if got := alternates["x-default"]; got != "/" { |
| + t.Errorf("%s links hreflang=x-default to %q; want /", pagePath(lang), got) |
| + } |
| + if len(alternates) != len(www.Languages)+1 { |
| + t.Errorf("%s has %d hreflang links; want %d, the eleven languages and x-default", pagePath(lang), len(alternates), len(www.Languages)+1) |
| + } |
| + |
| + if len(menu) != len(www.Languages) { |
| + t.Errorf("%s's language menu lists %d languages; want %d", pagePath(lang), len(menu), len(www.Languages)) |
| + } |
| + seen := map[string]bool{} |
| + var current []string |
| + for _, a := range menu { |
| + code := attr(a, "lang") |
| + seen[code] = true |
| + if got, want := attr(a, "href"), pageURL(code); got != want { |
| + t.Errorf("%s's menu links %s to %q; want %q", pagePath(lang), code, got, want) |
| + } |
| + if got, want := text(a), names[code]; got != want { |
| + t.Errorf("%s's menu names %s %q; the locale-menu example says %q", pagePath(lang), code, got, want) |
| + } |
| + if attr(a, "aria-current") != "" { |
| + current = append(current, code) |
| + } |
| + noTranslate := false |
| + for p := a; p != nil; p = p.Parent { |
| + if p.Type == xhtml.ElementNode && attr(p, "translate") == "no" { |
| + noTranslate = true |
| + } |
| + } |
| + if !noTranslate { |
| + t.Errorf("%s's menu entry for %s is not translate=\"no\"; a language's name stays in that language", pagePath(lang), code) |
| + } |
| + } |
| + for _, l := range www.Languages { |
| + if !seen[l] { |
| + t.Errorf("%s's language menu has no entry for %s", pagePath(lang), l) |
| + } |
| + } |
| + if len(current) != 1 || current[0] != lang { |
| + t.Errorf("%s's menu marks %v aria-current; want %s alone", pagePath(lang), current, lang) |
| + } |
| + } |
| +} |
diff --git a/internal/www/www.go b/internal/www/www.go| index d5ba078..1590b7c 100644 |
| --- a/internal/www/www.go |
| +++ b/internal/www/www.go |
| @@ -7,7 +7,8 @@ |
| // the site root, and it is deterministic: the same tree from the same |
| // source every time. |
| // |
| -// index.html the landing page |
| +// index.html the landing page, in English |
| +// <lang>/index.html the landing page in each other language |
| // design-system/... internal/gallery's tree, rendered for |
| // Mount, so every URL in it is absolute |
| // under /design-system/ |
| @@ -47,8 +48,9 @@ func hidden(p string) bool { |
| // laid out the same way, and is what the design system renders from. |
| // siteDir is the site source, the repository's site/ directory: its |
| // static/ is copied whole, its landing/ holds the landing page's |
| -// template and catalogs, and the rest of it is the site's own |
| -// business, not published. A hidden file or directory anywhere in |
| +// template and catalogs, which render one page per language in |
| +// Languages, and the rest of it is the site's own business, not |
| +// published. A hidden file or directory anywhere in |
| // siteDir is refused by name rather than skipped: the directory is |
| // committed, so a dotfile there is a mistake someone should see. |
| // |
| @@ -102,12 +104,14 @@ func Pages(src fs.FS, siteDir fs.FS) (map[string][]byte, error) { |
| return nil, err |
| } |
| - root, err := landing(siteDir, "en") |
| - if err != nil { |
| - return nil, err |
| - } |
| - if err := put("index.html", root); err != nil { |
| - return nil, err |
| + for _, lang := range Languages { |
| + doc, err := landing(siteDir, lang) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := put(pagePath(lang), doc); err != nil { |
| + return nil, err |
| + } |
| } |
| return out, nil |
| } |
diff --git a/site/landing/page.html.tmpl b/site/landing/page.html.tmpl| index 8199d8e..3b45a08 100644 |
| --- a/site/landing/page.html.tmpl |
| +++ b/site/landing/page.html.tmpl |
| @@ -5,6 +5,8 @@ |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>{{.T "title"}}</title> |
| <meta name="description" content="{{.T "description"}}"> |
| +{{range .Languages}}<link rel="alternate" hreflang="{{.Code}}" href="{{.URL}}"> |
| +{{end}}<link rel="alternate" hreflang="x-default" href="/"> |
| <!-- The visitor's theme, before first paint: day, plain or signal from |
| localStorage, day for anything else or a storage that throws. One |
| parser-inserted stylesheet, which blocks rendering until it |
| @@ -39,8 +41,8 @@ |
| <details rst-dropdown rst-locale name="rst-menus" class="site-lang"> |
| <summary>{{.T "lang.label"}}<span rst-caret aria-hidden="true"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></span></summary> |
| <div rst-dropdown-menu translate="no"> |
| - <a href="/" lang="en" aria-current="page">English</a> |
| - </div> |
| + {{range .Languages}}<a href="{{.URL}}" lang="{{.Code}}"{{if .Current}} aria-current="page"{{end}}>{{.Name}}</a> |
| + {{end}}</div> |
| </details> |
| <a rst-btn="sm" href="#install">{{.T "nav.start"}}</a> |
| </nav> |
diff --git a/site/static/site.css b/site/static/site.css| index 4670dcf..bbb6f05 100644 |
| --- a/site/static/site.css |
| +++ b/site/static/site.css |
| @@ -56,7 +56,11 @@ |
| .site-side a { padding: 6px 10px; border-radius: 4px; color: var(--rst-text-muted); text-decoration: none; } |
| .site-side a[aria-current] { background: var(--rst-accent-soft); color: var(--rst-text); font-weight: 600; } |
| .site-main { padding: 16px 18px; min-width: 0; } |
| -.site-pane { display: grid; gap: 12px; align-content: start; } |
| +/* One column that never grows past the pane: an implicit auto column |
| + would widen to the course tabs' max-content (328px in Spanish, 338 |
| + in Arabic), and Fiesta's seg-tabs wrap only against a width that |
| + holds them. */ |
| +.site-pane { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; align-content: start; } |
| .site-pane[hidden] { display: none; } |
| .site-pane + .site-pane { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--rst-line); } |
| .site-pane h2 { margin: 0; font-size: 1.05rem; } |
| @@ -111,6 +115,10 @@ |
| margin: the stage drops its inline padding, the window its side |
| borders and corners, and the sidebar becomes a row across the top. */ |
| @media (max-width: 560px) { |
| + /* The headline's longest word has to fit a 320px phone in every |
| + language: Russian's Отпразднуйте is 329px at 2.6rem, so the size |
| + follows the viewport down to 1.9rem (240px at 320). */ |
| + .site-hero h1 { font-size: clamp(1.9rem, 9.5vw, 2.6rem); } |
| .site-stage { padding-inline: 0; } |
| .site-themebar { padding-inline: 14px; } |
| .site-window { border-inline: 0; border-radius: 0; } |