Twelve languages, a page per family, the sequence, hashed assets, and the seams the rest hangs on
The foundation the rest of part 4 hangs on, laid down in one piece so the shell, widget, frame and Getting started tasks can edit disjoint files after it. The tree is now <theme>/<locale>/ for Fiesta's twelve locales, the Arabic directory being the right-to-left rendering (the rtl/ twin goes); pageKinds() is the table of pages (Overview, Tokens, Icons, one light page per family), and the sequence, the Overview's routes and the rail's sections are read off it; every asset is written hashed and plain and every page and frame links the hash; gallery.css and gallery.js are each the join of a chrome half and a widget half (view.css, view.js); copy.go carries Rastrillo's approved chrome strings under Fiesta ids; and the password screen's warning renders as a callout when prose.json has one.
Seams for the concurrent tasks, each with its signature in task-2-report.md: pageKinds, fileOf, href, sequence, steps, routes, anchorID, slug, ReleaseBase (pages.go); navItem, navSection, indexRow, localeLink, controls, tokenNav, iconNav, familyNav, controls, galleryNav, phoneIndex (nav.go); page's fields and theme()'s loop with example(), frameFiles() and extraDocuments()/extraDocumentNames() (gallery.go, example_page.go); previewView, rowCode, HasHTML, preview, viewTemplate (view.go); frame(doc, theme, locale, e), exampleDocument (frame.go); say, proseIn, label, templates, trace, setTrace (i18n.go); hashedName, Asset (hashed.go); weight, weigh, kib, weightIn (weight.go); the galleryrig package and its short-name bindings.
New tests, each seen red by assertion against part 3's render before the implementation: TestTreeShapeIsComplete, TestRootIndexIsTheDefaultThemeInEnglishAtTheTreeRoot, TestEveryPageEndsWithItsPlaceInTheSequence, TestTheOverviewRoutesIntoEveryOtherPage, TestEveryAssetIsWrittenHashedAndPlainAndPagesLinkTheHash, TestEveryPageIsAWholeLocalisedDocument, TestAnchorIDsAreTheSameInEveryThemeAndLocale, TestEverySamplePictureIsUsedOnce, TestThePasswordScreenCarriesItsWarning, TestFrameFilesNameOneDocumentPerState, TestKiBIsOneDecimalBelowTenAndWholeAbove, TestKiBWritesTheLocalesDigits, TestTheWeightLineDropsItsLastPartCleanly, TestTheReleaseBaseIsTheSitesOrigin (www), TestTheRigServesTheTreeAtItsMount (browser).
Already passing when written, each proved live by a named mutation that turned it red: TestNoPageCarriesTheSameIdTwice (the h1 given the main landmark's id), TestEveryPagesH1IsItsOwnTitle (the h1 writing the theme in place of the page name), TestTitlesUseTheApprovedSeparators (the title key joined with hyphens), TestNoTitleOrStateLabelCarriesAnEmDash (an em dash in the title key). TestTreeShapeIsComplete's mutation, a file written in theme() that no seam names, was run too.
Preservation tests, adapted to the new paths, names or seams with their assertions kept: TestRenderWritesEveryStateInEveryTheme (Locales, frames counted through frameFiles), TestRTLDocumentsReadRightToLeft (day/ar), TestSampleURLsStayInTheMarkup (day/en; a frame-body link is a fragment or a data-gallery-nav link under the mount; the sample-route control stays), TestEveryExamplePassesAxe (frameFiles; the ar leg), TestHighlightColoursPassAgainstTheCodeBackground (reads view.css), TestTheFamilyPagesShowTheFamilyBlurbs (was TestTheIndexShowsTheFamilyBlurbs; the index's sections became pages), TestTheIdiomRulesHeadThePrimitivesFamily (primitives.html), TestAnExamplePageShowsItsBlurbAndNotes, TestTheDropdownIdiomNoteSitsOnItsState, TestTheStandaloneSchemeStateSaysNoServer, TestTheSigninPageHasItsSummary (day/en paths, ds- selectors), TestProseNamesOnlyRealExamplesAndStates (a stray warnings control), TestTheGalleryPagesPassAxe (day/ar/index.html), TestTheGalleryHasNoHorizontalScrollAt320, TestAFrameSubmitsNothing, TestTheSchemeMenuRepaintsTheFrames, TestFramesKeepTheReadersPlace, TestA11yWalksTheKeyboard, TestA11yWalksTheStageShell, TestGalleryWritesTheTreeAtItsMount (hashed gallery.css), TestTheDesignSystemIsMounted (day/en/button.html), TestTheSiteServesFromItsOwnOrigin (day/en/dropdown.html, the hashed stylesheet by pattern). TestEveryGalleryURLResolves is unchanged.
Carried copy, grepped in Rastrillo's prose.go at origin/main 5bb38c0a before it was written, with the differences: gallery.wrap is Rastrillo's "Put this inside {wrapper}." with its full stop, where the plan wrote it without; Settings, Account, Profile, Billing and Notifications are P calls in Rastrillo's modal template with no prose.go row of their own; gallery.skip, gallery.up, gallery.up.label, gallery.language and gallery.locale.name are from locales/en.toml. Keys removed: gallery.direction.*, and the ones the page-kind titles and the two carried example sentences replace (gallery.nav.examples, gallery.nav.tokens, gallery.nav.icons, gallery.index.heading, gallery.tokens.heading, gallery.icons.heading, gallery.example.lead). The Icons route reads the page's own reviewed lead, with the count, until gallery.routes.icons lands with the reviewed copy. No English was invented.
The families keep the manifest's order, which puts shells before formats and screens last, so the sequence ends on signin.html rather than layout-stage.html as the spec's family list has it; the test reads the sequence off the table and the manifest rather than naming the last page.
Two more tests written against the finished seams, each with its control: TestAnExampleDocumentFramesLikeASeededOne (exampleDocument through frame; a document with nothing in its head is refused) and TestEveryLookupIsTraced (setTrace hears every chrome, prose and label id a render asks for; a trace that drops the call turned it red). TestTheSharedRigDrivesAPhone exercises every galleryrig binding on one page, with a drawn scrollbar, an init script, the frames settled at 320px and a scripts-off tab.
golang.org/x/text is in go.mod at v0.41.0, the version the module graph already selected through x/net v0.58.0; go mod tidy ran with weight.go in place.
Human
Prompt
Implement Task 2 of the Fiesta part 4 plan: the serial foundation, every shared type, file, seam, helper and rename the later tasks rely on, with test-driven discipline.
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
Twelve locale directories per theme in place of the rtl/ twin; a page kinds table (Overview, Tokens, Icons, a page per family) that drives the sequence, the routes and the rail model; hashed assets; gallery.css/js split into a chrome half and a widget half joined at render; Rastrillo's approved chrome strings carried into copy.go; the warning callout; the weight formatting over x/text; the galleryrig browser helpers; the test seams (pageKinds, frameFiles, extraDocumentNames, the asset list) the whole-tree gates read.
Agent notes
The language menu names every locale "English" until Task 8's catalogs answer gallery.locale.name; the Up link is set on the page but not rendered until Task 3 draws the shell's back strip (its fragment has no target until the phone index exists); 30 manifest states have an empty label and render an empty .ds-state and a frame title ending in ": ", as part 3 did.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
36 files changed,
+3408
−770
cmd/fiesta/gallery_test.go+3 −2go.mod+1 −0go.sum+2 −0internal/gallery/a11y_browser_test.go+36 −38internal/gallery/copy.go+131 −24internal/gallery/example_page.go+109 −0internal/gallery/family_page.go+61 −0internal/gallery/frame.go+100 −0internal/gallery/frame.js+1 −1internal/gallery/gallery.css+48 −71internal/gallery/gallery.go+225 −390internal/gallery/gallery.js+7 −115internal/gallery/gallery_test.go+80 −45internal/gallery/galleryrig/rig.go+437 −0internal/gallery/hashed.go+51 −0internal/gallery/i18n.go+103 −0internal/gallery/icons_page.go+35 −0internal/gallery/keyboard_browser_test.go+5 −4internal/gallery/locale.go+17 −0internal/gallery/nav.go+141 −0internal/gallery/overview.go+35 −0internal/gallery/page_tmpl.go+50 −0internal/gallery/pages.go+176 −0internal/gallery/pages_a11y_browser_test.go+8 −14internal/gallery/prose.go+65 −9internal/gallery/prose_test.go+50 −49internal/gallery/rig_browser_test.go+142 −0internal/gallery/tokens_page.go+56 −0internal/gallery/tree_test.go+823 −0internal/gallery/view.css+39 −0internal/gallery/view.go+82 −0internal/gallery/view.js+118 −0internal/gallery/weight.go+67 −0internal/gallery/weight_test.go+85 −0internal/www/site_browser_test.go+8 −7internal/www/www_test.go+11 −1
CI passed — run details
CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok amadan.net/rastrillo/fiesta 2.954s
=== 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.019s
=== 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 0.818s
ok amadan.net/rastrillo/fiesta/check 4.460s
ok amadan.net/rastrillo/fiesta/cmd/fiesta 36.136s
ok amadan.net/rastrillo/fiesta/example 0.137s
ok amadan.net/rastrillo/fiesta/internal/axe 0.115s
ok amadan.net/rastrillo/fiesta/internal/build 0.778s
ok amadan.net/rastrillo/fiesta/internal/codeview 0.010s
ok amadan.net/rastrillo/fiesta/internal/gallery 21.248s
? amadan.net/rastrillo/fiesta/internal/harness [no test files]
ok amadan.net/rastrillo/fiesta/internal/markup 0.008s
ok amadan.net/rastrillo/fiesta/internal/nodetest 0.306s
ok amadan.net/rastrillo/fiesta/internal/site 2.899s
ok amadan.net/rastrillo/fiesta/internal/tokens 0.013s
ok amadan.net/rastrillo/fiesta/internal/www 30.473s
--- 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' .
[90m12:03AM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net
[90m12:03AM[0m [32mINF[0m [1m1 commits scanned.[0m
[90m12:03AM[0m [32mINF[0m [1mscanned ~5923849 bytes (5.92 MB) in 3.68s[0m
[90m12:03AM[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.619s
--- 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 203.671s
--- 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 218.050s
--- 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 213.013s
--- 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 46 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 88.778s
--- 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 45 of 46 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 54.494s
--- 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 17 of 34 tests
ok amadan.net/rastrillo/fiesta/internal/www 68.268s
--- 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 17 of 34 tests
ok amadan.net/rastrillo/fiesta/internal/www 62.050s
Intent
diff --git a/cmd/fiesta/gallery_test.go b/cmd/fiesta/gallery_test.go| index 45b2ea5..4a951d8 100644 |
| --- a/cmd/fiesta/gallery_test.go |
| +++ b/cmd/fiesta/gallery_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "bytes" |
| "os" |
| "path/filepath" |
| + "regexp" |
| "strings" |
| "testing" |
| @@ -38,8 +39,8 @@ func TestGalleryWritesTheTreeAtItsMount(t *testing.T) { |
| } |
| } |
| index, err := os.ReadFile(filepath.Join(out, "index.html")) |
| - if err != nil || !bytes.Contains(index, []byte(`href="/x/assets/gallery.css"`)) { |
| - t.Errorf("index.html does not link the gallery's stylesheet under /x: %v", err) |
| + if err != nil || !regexp.MustCompile(`href="/x/assets/gallery\.[0-9a-f]{16}\.css"`).Match(index) { |
| + t.Errorf("index.html does not link the gallery's hashed stylesheet under /x: %v", err) |
| } |
| if b, err := os.ReadFile(filepath.Join(out, stampName)); err != nil || string(b) != stampBody("fiesta gallery") { |
| t.Errorf("the stamp is not fiesta gallery's: %q, %v", b, err) |
diff --git a/go.mod b/go.mod| index c73d930..c6b0855 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -7,6 +7,7 @@ require ( |
| github.com/chromedp/chromedp v0.14.2 |
| github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 |
| golang.org/x/net v0.58.0 |
| + golang.org/x/text v0.41.0 |
| ) |
| require ( |
diff --git a/go.sum b/go.sum| index cf0290c..8696815 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -21,3 +21,5 @@ golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= |
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= |
| +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= |
diff --git a/internal/gallery/a11y_browser_test.go b/internal/gallery/a11y_browser_test.go| index 73b2444..732cb79 100644 |
| --- a/internal/gallery/a11y_browser_test.go |
| +++ b/internal/gallery/a11y_browser_test.go |
| @@ -129,12 +129,8 @@ func (tr *tree) seen() []string { |
| func href(rel string) string { return mountPath + "/" + rel } |
| // frameFile is the path of one state's frame document within the tree. |
| -func frameFile(theme string, rtl bool, name, state string) string { |
| - dir := theme + "/" |
| - if rtl { |
| - dir += rtlDir |
| - } |
| - return dir + "frames/" + name + "/" + state + ".html" |
| +func frameFile(theme, locale, name, state string) string { |
| + return theme + "/" + locale + "/frames/" + name + "/" + state + ".html" |
| } |
| func mustRun(t *testing.T, ctx context.Context, actions ...chromedp.Action) { |
| @@ -161,15 +157,17 @@ func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p. |
| // settleJS takes every frame off lazy loading and reports how many |
| // there are and how many gallery.js has painted. |
| const settleJS = `(() => { |
| - const f = [...document.querySelectorAll(".gallery-frame")]; |
| + const f = [...document.querySelectorAll(".ds-view__frame")]; |
| f.forEach(x => { if (x.loading !== "eager") x.loading = "eager"; }); |
| return JSON.stringify({total: f.length, painted: f.filter(x => x.hasAttribute("data-gallery-painted")).length}); |
| })()` |
| -// eagerly loads every frame on the page and waits until gallery.js |
| +// painted loads every frame on the page and waits until gallery.js |
| // has painted them all, so a reading that follows is of documents |
| -// that are there. |
| -func eagerly(t *testing.T, ctx context.Context, where string) int { |
| +// that are there. It is Fiesta's own wait, on the painted mark; the |
| +// ported drives' eagerly, in rig_browser_test.go, waits on each |
| +// frame's document settling instead. |
| +func painted(t *testing.T, ctx context.Context, where string) int { |
| t.Helper() |
| deadline := time.Now().Add(60 * time.Second) |
| for { |
| @@ -203,22 +201,25 @@ func report(t *testing.T, where string, vs []axe.Violation) int { |
| return len(vs) |
| } |
| -// TestEveryExamplePassesAxe is the gate: every frame document, scanned |
| -// in day, plain and signal with data-theme light and dark, and in |
| -// right-to-left day, with the WCAG 2.2 AA tag set. Expected: no |
| +// TestEveryExamplePassesAxe is the gate: every frame document frameFiles |
| +// names, scanned in day, plain and signal with data-theme light and |
| +// dark, and in Arabic day, with the WCAG 2.2 AA tag set. Expected: no |
| // violations. The rules axe could not decide are logged per leg, never |
| // silently dropped: they are the seam between what this scan holds and |
| -// what a person reviews. |
| +// what a person reviews. Preservation: part 3's gate, iterating |
| +// frameFiles so a document a later task adds is scanned the day it |
| +// lands. |
| func TestEveryExamplePassesAxe(t *testing.T) { |
| rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) |
| ctx, cancel := context.WithTimeout(rig.Context(), 18*time.Minute) |
| defer cancel() |
| - m := manifest(t) |
| + s := newTestSite(t, fiesta.FS(), mountPath) |
| + m := s.m |
| legs := []struct { |
| - theme string |
| - rtl bool |
| - }{{"day", false}, {"plain", false}, {"signal", false}, {"day", true}} |
| + theme string |
| + locale string |
| + }{{"day", "en"}, {"plain", "en"}, {"signal", "en"}, {"day", "ar"}} |
| for _, theme := range fiesta.ThemeNames() { |
| found := false |
| for _, l := range legs { |
| @@ -230,18 +231,15 @@ func TestEveryExamplePassesAxe(t *testing.T) { |
| } |
| scans, failed := 0, 0 |
| for _, leg := range legs { |
| - name := leg.theme |
| - if leg.rtl { |
| - name += " rtl" |
| - } |
| + name := leg.theme + " " + leg.locale |
| incomplete := map[string]int{} |
| start := time.Now() |
| for _, e := range m.Examples { |
| - for _, s := range e.States { |
| - where := name + " " + e.Name + "/" + s.ID |
| + for _, f := range s.frameFiles(e) { |
| + where := name + " " + strings.TrimSuffix(strings.TrimPrefix(f, "frames/"), ".html") |
| mustRun(t, ctx, |
| chromedp.EmulateViewport(1280, 900), |
| - chromedp.Navigate(rig.Origin+href(frameFile(leg.theme, leg.rtl, e.Name, s.ID))), |
| + chromedp.Navigate(rig.Origin+href(leg.theme+"/"+leg.locale+"/"+f)), |
| chromedp.WaitReady("body"), |
| ) |
| // The body's ground is read back after each flip, and the |
| @@ -314,11 +312,11 @@ func TestTheGalleryHasNoHorizontalScrollAt320(t *testing.T) { |
| m := manifest(t) |
| theme := fiesta.ThemeNames()[0] |
| - pages := []string{theme + "/index.html", theme + "/tokens.html", theme + "/icons.html", theme + "/" + rtlDir + "index.html"} |
| + pages := []string{theme + "/en/index.html", theme + "/en/tokens.html", theme + "/en/icons.html", theme + "/ar/index.html"} |
| for _, e := range m.Examples { |
| - pages = append(pages, theme+"/"+e.Name+".html") |
| + pages = append(pages, theme+"/en/"+e.Name+".html") |
| } |
| - pages = append(pages, theme+"/"+rtlDir+"field.html") |
| + pages = append(pages, theme+"/ar/field.html") |
| for _, p := range pages { |
| mustRun(t, ctx, |
| chromedp.ActionFunc(func(c context.Context) error { |
| @@ -347,7 +345,7 @@ func TestTheGalleryHasNoHorizontalScrollAt320(t *testing.T) { |
| // on a window before the submits is still there after only if no |
| // navigation replaced it. |
| const submitJS = `(async () => { |
| - const frames = [...document.querySelectorAll(".gallery-frame")]; |
| + const frames = [...document.querySelectorAll(".ds-view__frame")]; |
| let events = 0, forms = 0; |
| for (const f of frames) { |
| const w = f.contentWindow, d = f.contentDocument; |
| @@ -379,10 +377,10 @@ func TestAFrameSubmitsNothing(t *testing.T) { |
| for _, name := range []string{"confirm-form", "field-select", "list-bar-search"} { |
| mustRun(t, ctx, |
| chromedp.EmulateViewport(1280, 900), |
| - chromedp.Navigate(rig.Origin+href(theme+"/"+name+".html")), |
| + chromedp.Navigate(rig.Origin+href(theme+"/en/"+name+".html")), |
| chromedp.WaitReady("body"), |
| ) |
| - eagerly(t, ctx, name) |
| + painted(t, ctx, name) |
| before := len(tr.seen()) |
| var got struct{ Frames, Forms, Events, Kept int } |
| at(t, ctx, submitJS, &got, awaitPromise) |
| @@ -413,7 +411,7 @@ const schemeJS = `(() => { |
| const b = getComputedStyle(d.body).backgroundColor, r = getComputedStyle(d.documentElement).backgroundColor; |
| return b === "rgba(0, 0, 0, 0)" ? r : b; |
| }; |
| - const frames = [...document.querySelectorAll(".gallery-frame")].map(f => ({ |
| + const frames = [...document.querySelectorAll(".ds-view__frame")].map(f => ({ |
| theme: f.contentDocument.documentElement.getAttribute("data-theme") || "", |
| bg: paint(f.contentDocument), |
| })); |
| @@ -465,10 +463,10 @@ func TestTheSchemeMenuRepaintsTheFrames(t *testing.T) { |
| mustRun(t, ctx, |
| chromedp.EmulateViewport(1280, 900), |
| - chromedp.Navigate(rig.Origin+href(theme+"/field.html")), |
| + chromedp.Navigate(rig.Origin+href(theme+"/en/field.html")), |
| chromedp.WaitReady("body"), |
| ) |
| - n := eagerly(t, ctx, "field") |
| + n := painted(t, ctx, "field") |
| type reading struct { |
| Theme, Stored string |
| @@ -533,7 +531,7 @@ func TestTheSchemeMenuRepaintsTheFrames(t *testing.T) { |
| const d = f.contentDocument; |
| const b = getComputedStyle(d.body).backgroundColor, r = getComputedStyle(d.documentElement).backgroundColor; |
| return JSON.stringify({theme: d.documentElement.getAttribute("data-theme") || "", bg: b === "rgba(0, 0, 0, 0)" ? r : b}); |
| - })()`, href(frameFile(theme, false, "badge", mustDefault(t, "badge")))) |
| + })()`, href(frameFile(theme, "en", "badge", mustDefault(t, "badge")))) |
| var fresh struct{ Theme, Bg string } |
| at(t, ctx, later, &fresh, awaitPromise) |
| if fresh.Theme != "dark" { |
| @@ -549,7 +547,7 @@ func TestTheSchemeMenuRepaintsTheFrames(t *testing.T) { |
| // gallery page, and how many of its frames hold a field that asked for |
| // autofocus. |
| const placeJS = `(() => { |
| - const asked = [...document.querySelectorAll(".gallery-frame")].filter(f => f.contentDocument.querySelector("[autofocus]")).length; |
| + const asked = [...document.querySelectorAll(".ds-view__frame")].filter(f => f.contentDocument.querySelector("[autofocus]")).length; |
| const a = document.activeElement; |
| return JSON.stringify({active: a ? a.tagName.toLowerCase() : "", scrollY: Math.round(scrollY), asked}); |
| })()` |
| @@ -567,10 +565,10 @@ func TestFramesKeepTheReadersPlace(t *testing.T) { |
| mustRun(t, ctx, |
| chromedp.EmulateViewport(1280, 900), |
| - chromedp.Navigate(rig.Origin+href(theme+"/signin.html")), |
| + chromedp.Navigate(rig.Origin+href(theme+"/en/signin.html")), |
| chromedp.WaitReady("body"), |
| ) |
| - eagerly(t, ctx, "signin") |
| + painted(t, ctx, "signin") |
| // Autofocus lands at a rendering update after load, so the reading |
| // waits two frames. |
| mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) |
diff --git a/internal/gallery/copy.go b/internal/gallery/copy.go| index 6479942..2665c88 100644 |
| --- a/internal/gallery/copy.go |
| +++ b/internal/gallery/copy.go |
| @@ -9,38 +9,133 @@ import "strings" |
| // review; TestTheGalleryCarriesNoPlaceholderCopy fails while any |
| // remains, so a placeholder cannot reach a page by accident. The ids |
| // are the ones the review is filed under. |
| +// |
| +// Most of what follows is Rastrillo's approved English, carried word |
| +// for word from its gallery's prose table and its base catalog, with |
| +// the id written beside it so the translation exporter can match the |
| +// English against Rastrillo's rows. Fiesta's own strings are the ones |
| +// part 3's review passed. |
| var text = map[string]string{ |
| // The <title> of every page: the page first, because a tab strip |
| // truncates from the end, then the site, then the theme. |
| "gallery.title": "{page} · Fiesta · {theme}", |
| - // The brand link at the start of the header, back to the index. |
| + // The brand link at the start of the header, back to the Overview. |
| "gallery.brand": "Fiesta", |
| // The skip link, first in every page. |
| "gallery.skip": "Skip to content", |
| // The theme switcher's accessible name. |
| "gallery.theme.label": "Theme", |
| - // The link to the page's right-to-left twin, and back. |
| - "gallery.direction.rtl": "Right to left", |
| - "gallery.direction.ltr": "Left to right", |
| - // The page navigation: its accessible name and its three links. |
| - "gallery.nav.label": "Pages", |
| - "gallery.nav.examples": "Examples", |
| - "gallery.nav.tokens": "Tokens", |
| - "gallery.nav.icons": "Icons", |
| - // The index: its heading and its lead. The families' headings |
| - // and sentences are the prose's, from examples/prose.json. |
| - "gallery.index.heading": "Examples", |
| + // The page navigation's accessible name. |
| + "gallery.nav.label": "Pages", |
| + |
| + // The page kinds' titles, which name the pages, the rail's sections |
| + // and the routes off the Overview. The families' titles are the |
| + // prose's. |
| + "gallery.kind.overview": "Overview", |
| + "gallery.kind.tokens": "Tokens", |
| + "gallery.kind.icons": "Icons", |
| + "gallery.kind.getting-started": "Getting started", |
| + // The Overview's route into the Tokens page. |
| + "gallery.route.tokens": "Every custom property the system is built out of: the theme's colour and type, and the scales for size, spacing and radius.", |
| + // The Overview: its lead, and the heading over its routes. |
| "gallery.index.lead": "Every state of every example in the {theme} theme, each as a page of its own. Open an example to see its states live, with the markup beside each one.", |
| - // An example's page: the sentence under its heading, the HTML tab, |
| - // the frame's accessible name, and the note naming the routes a |
| - // sample stands for. |
| - "gallery.example.lead": "Each state is live, but its links go nowhere. HTML shows the markup to copy.", |
| + "gallery.overview.next": "Where to go next", |
| + |
| + // The previous and next pair at the foot of every page. |
| + "gallery.step.prev": "Previous: {page}", |
| + "gallery.step.next": "Next: {page}", |
| + "gallery.steps.label": "Previous and next", |
| + |
| + // The rail: its accessible name, the Demos group, each section's |
| + // overview item and the mark on a link that leaves the page. |
| + "gallery.nav.sections": "Sections and demos", |
| + "gallery.nav.demos": "Demos", |
| + "gallery.nav.overview": "Overview", |
| + "gallery.nav.overview.aria": "{section} overview", |
| + "gallery.demos.shell": "The {shell} shell", |
| + "gallery.demos.modal": "The modal route", |
| + "gallery.demos.app": "The demo application", |
| + "gallery.newtab": "opens in a new tab", |
| + // The rail's filter. |
| + "gallery.filter": "Filter", |
| + "gallery.filter.empty": "No matches", |
| + |
| + // The display settings menu on a phone, and the controls in it and |
| + // in the bar: the scheme menu's name and the language menu. |
| + "gallery.prefs": "Display settings", |
| + "gallery.scheme.label": "Colour scheme", |
| + "gallery.language": "Language", |
| + "gallery.language.current": ", currently {language}", |
| + "gallery.locale.name": "English", |
| + // The way back from a content page on a phone: the shell's own |
| + // words, from Rastrillo's base catalog. |
| + "gallery.up": "Back to {name}", |
| + "gallery.up.label": "Sections", |
| + |
| + // The preview widget: the tabs' legend and names, the HTML tab, |
| + // the frame's accessible name, the Copy button and its live region, |
| + // the wrapper line, and the note naming the routes a sample stands |
| + // for. |
| + "gallery.view.legend": "Preview", |
| + "gallery.view.desktop": "Desktop", |
| + "gallery.view.mobile": "Mobile", |
| "gallery.html": "HTML", |
| "gallery.frame.title": "{example}: {state}", |
| + "gallery.copy": "Copy", |
| + "gallery.copied": "Copied", |
| + "gallery.copy.failed": "Copy failed. Select the code and copy it yourself.", |
| + "gallery.wrap": "Put this inside {wrapper}.", |
| "gallery.routes": "In an app this would reach {routes}. Here its links and forms go nowhere.", |
| - // The tokens page: heading, lead, the note on the palette, and a |
| - // heading per group of tokens.Group.Key. |
| - "gallery.tokens.heading": "Tokens", |
| + // An example's page: the two sentences under its heading. |
| + "gallery.example.live": "Each example is live, but its links go nowhere.", |
| + "gallery.example.html": "HTML shows the markup to copy.", |
| + |
| + // The demo application on the Overview, and the shells' open |
| + // buttons. |
| + "gallery.demo.heading": "The demo application", |
| + "gallery.demo.lead": "A working app built only from this system: a dashboard, a list, one record open. Each has its own address. No JavaScript.", |
| + "gallery.demo.open": "Open the demo application", |
| + "gallery.shell.open": "Open the {shell} shell", |
| + "gallery.back": "Back to the design system", |
| + // The primitives' live links. |
| + "gallery.primitives.modal.live": "See it live at the URL it belongs to", |
| + "gallery.primitives.sidebar.live": "Open the sidebar shell demo, a standalone preview.", |
| + "gallery.primitives.topbar.live": "Open the topbar shell demo, a dedicated preview.", |
| + |
| + // The modal route: the page under it and the panel over it. |
| + "gallery.modal.close": "Close settings", |
| + "gallery.modal.own": "Modals get their own URL.", |
| + "gallery.modal.settings": "Settings", |
| + "gallery.modal.sub": "The page the modal opened over. It is marked inert, so nothing in here takes focus or reaches a screen reader while the panel is up.", |
| + "gallery.modal.account": "Account", |
| + "gallery.modal.profile": "Profile", |
| + "gallery.modal.billing": "Billing", |
| + "gallery.modal.notifications": "Notifications", |
| + "gallery.modal.body": "Update the name and photo shown across the account.", |
| + "gallery.modal.nojs": "Close link designed to work without JS.", |
| + "gallery.modal.inapp": "In an application the ✕ would return you to the screen in the backdrop.", |
| + |
| + // Getting started: its headings, the weights table's note and line, |
| + // and the per-file blurbs Rastrillo wrote for the files Fiesta |
| + // ships too. |
| + "gallery.started.stylesheets": "The stylesheets", |
| + "gallery.started.scripts": "The scripts", |
| + "gallery.started.weights": "What each file weighs", |
| + "gallery.started.weights.note": "Sizes in KiB. Gzipped is what a visitor downloads.", |
| + "gallery.started.weight": "{raw} as written · {gzip} gzipped · {stripped} gzipped without comments", |
| + "gallery.started.upgrading": "Upgrading", |
| + "gallery.started.file.tokens": "Structure: every component class, every scale, and no colour literal anywhere. The same file under every theme.", |
| + "gallery.started.file.theme": "Colour, type family and shape for the {theme} theme: one :root block where every colour is declared once as a light-dark() pair.", |
| + "gallery.started.file.shell-css": "The slide between pages on a phone, for the sidebar and console shells. Deletable with shell.js.", |
| + "gallery.started.file.light-js": "The light the depth styles read: shadows lit by the hour, and a sheen on the button under the pointer. Delete it for the still design.", |
| + "gallery.started.file.anchor-js": "Keeps a dropdown's menu on the screen in browsers without CSS anchor positioning. It does nothing where they have it: delete it once every browser you support does.", |
| + "gallery.started.file.shell-js": "Phone navigation for the sidebar and console shells: the slide between pages, Back that reuses history, and focus returned to the section you left. Deletable on its own.", |
| + "gallery.started.file.select-js": "field-select's searchable combobox. Inert until a select opts in with data-rst-select, and deletable on its own.", |
| + "gallery.started.file.datetime-js": "The date fields' natural-language input. Inert until a field opts in with data-rst-date or data-rst-time, and deletable on its own.", |
| + "gallery.started.file.calendar-js": "The month grid that field's calendar button opens. Inert until datetime.js asks it for a panel, and deletable on its own: without it the button falls back to the browser's own picker.", |
| + |
| + // The tokens page: lead, the note on the palette, and a heading per |
| + // group of tokens.Group.Key. |
| "gallery.tokens.lead": "The custom properties every component reads. Colour, type family and shape come from the theme, css/themes/{theme}.css. The type scale and the spacing steps are structure, the same in every theme, and come from css/tokens.css.", |
| "gallery.tokens.note": "Light values shown. Every colour is declared once as a light-dark() pair, and the chips follow the scheme menu above. WCAG 2.2 AA floors: 4.5:1 for text, 3:1 for control borders.", |
| "gallery.tokens.group.surfaces-and-lines": "Surfaces and lines", |
| @@ -55,20 +150,32 @@ var text = map[string]string{ |
| "gallery.tokens.group.type-family": "Type family", |
| "gallery.tokens.group.type-scale": "Type scale", |
| "gallery.tokens.group.spacing": "Spacing", |
| - // The icons page: heading and lead. |
| - "gallery.icons.heading": "Icons", |
| - "gallery.icons.lead": "{total} icons from Lucide, each an inline SVG sized from the text beside it by tokens.css's own .icon rule, which is the size shown here.", |
| + |
| + // The icons page: its lead, and where the glyphs come from. |
| + "gallery.icons.lead": "{total} icons from Lucide, each an inline SVG sized from the text beside it by tokens.css's own .icon rule, which is the size shown here.", |
| + "gallery.icons.source": "Where the glyphs come from", |
| + "gallery.icons.lucide": "The whole set is at {link}.", |
| + "gallery.icons.kebab": "kebab and menu are the pair worth keeping straight: kebab is the three dots that mean more actions on this row, menu the three lines that mean navigation. The shells use menu when they collapse.", |
| } |
| -// say returns the text filed under id, with each {name} replaced by |
| +// say returns the English filed under id, with each {name} replaced by |
| // the value following it in args. An id nothing is filed under is a |
| // programming error, not a page to render, so it panics: every id |
| -// written in a template is one of the keys above. |
| +// written in a template is one of the keys above. The pages read |
| +// their words through site.say, which is this in the page's locale. |
| func say(id string, args ...string) string { |
| s, ok := text[id] |
| if !ok { |
| panic("gallery: no text for " + id) |
| } |
| + return interpolate(s, args) |
| +} |
| + |
| +// interpolate replaces each {name} in s with the value following it in |
| +// args. An unmatched placeholder is left on the page rather than |
| +// deleted, so a typo shows up as "{shel}" instead of half a sentence |
| +// vanishing. |
| +func interpolate(s string, args []string) string { |
| for i := 0; i+1 < len(args); i += 2 { |
| s = strings.ReplaceAll(s, "{"+args[i]+"}", args[i+1]) |
| } |
diff --git a/internal/gallery/example_page.go b/internal/gallery/example_page.go| new file mode 100644 |
| index 0000000..c36ac2e |
| --- /dev/null |
| +++ b/internal/gallery/example_page.go |
| @@ -0,0 +1,109 @@ |
| +package gallery |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| + |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// An example's page and its frame documents. frameFiles names the |
| +// documents and example writes them, under exactly those names, which |
| +// is how the tree gate, the frame count and the accessibility scan |
| +// learn of a new document the day a later task adds one here. |
| + |
| +// frameFiles is the paths, under the theme and locale directory, of |
| +// one example's frame documents: one per state. |
| +func (s *site) frameFiles(e example.Entry) []string { |
| + var out []string |
| + for _, st := range e.States { |
| + out = append(out, "frames/"+e.Name+"/"+st.ID+".html") |
| + } |
| + return out |
| +} |
| + |
| +// stateView is one state on an example's page. |
| +type stateView struct { |
| + ID string |
| + Label string |
| + Note string |
| + Warning *warning |
| + Preview previewView |
| + Routes string |
| +} |
| + |
| +// exampleBody is one example's page: its family as a kicker, its own |
| +// sentence, the two sentences every example page says, then every |
| +// state as a frame of its own document with the note on the state |
| +// under its label, the warning where the prose has one, the widget, |
| +// and the routes it stands for. |
| +const exampleBody = `{{define "ds-body-example"}}<p class="ds-kicker"><b>{{.Family.Title}}</b> {{.Family.Blurb}}</p> |
| +{{if .Blurb}}<p rst-page-header-sub>{{.Blurb}}</p> |
| +{{end}}<p class="ds-lead">{{say "gallery.example.live"}} {{say "gallery.example.html"}}</p> |
| +{{range .States}}<section class="ds-sample" id="{{.ID}}" data-ds-anchor data-gallery-state="{{$.Entry.Name}}/{{.ID}}"> |
| +<h2 class="ds-state">{{.Label}}</h2> |
| +{{if .Note}}<p class="ds-note">{{.Note}}</p> |
| +{{end}}{{with .Warning}}<div rst-callout rst-tone="warning"><span rst-callout-ic aria-hidden="true">{{icon "alert-triangle"}}</span><div rst-callout-body><strong>{{.Title}}</strong><p>{{.Body}}</p></div></div> |
| +{{end}}{{template "ds-view" .Preview}} |
| +{{if .Routes}}<p class="ds-routes-note">{{.Routes}}</p> |
| +{{end}}</section> |
| +{{end}}{{end}}` |
| + |
| +// example renders one example's page body and its frame documents, |
| +// keyed by the paths frameFiles names. |
| +func (s *site) example(p page, e example.Entry) (body []byte, frames map[string][]byte, err error) { |
| + ex := s.prose.Examples[e.Name] |
| + frames = map[string][]byte{} |
| + var states []stateView |
| + for _, st := range e.States { |
| + doc, err := example.Document(s.src, e.Name, st.ID) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + fr, err := s.frame(doc, p.Theme, p.Locale, e) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("%s/%s: %w", e.Name, st.ID, err) |
| + } |
| + rel := "frames/" + e.Name + "/" + st.ID + ".html" |
| + frames[rel] = []byte(fr) |
| + code := doc |
| + if !e.Document { |
| + if code, err = example.Markup(s.src, e.Name, st.ID); err != nil { |
| + return nil, nil, err |
| + } |
| + } |
| + view, err := s.preview(p, e, st, s.href(p.Theme, p.Locale, rel), code) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("%s/%s: %w", e.Name, st.ID, err) |
| + } |
| + sv := stateView{ |
| + ID: st.ID, |
| + Label: s.label(p.Locale, e, st), |
| + Note: s.proseIn(p.Locale, "examples."+e.Name+".states."+st.ID, ex.States[st.ID]), |
| + Preview: view, |
| + } |
| + if len(st.SampleURLs) > 0 { |
| + sv.Routes = s.say(p.Locale, "gallery.routes", "routes", strings.Join(st.SampleURLs, ", ")) |
| + } |
| + if w, ok := ex.Warnings[st.ID]; ok { |
| + slot := "examples." + e.Name + ".warnings." + st.ID |
| + sv.Warning = &warning{ |
| + Title: s.proseIn(p.Locale, slot+".title", w.Title), |
| + Body: s.proseIn(p.Locale, slot+".body", w.Body), |
| + } |
| + } |
| + states = append(states, sv) |
| + } |
| + f, _ := s.familyByKey(e.Family) |
| + out, err := s.body(p.Locale, "ds-body-example", struct { |
| + page |
| + Family family |
| + Entry example.Entry |
| + Blurb string |
| + States []stateView |
| + }{p, s.familyIn(p.Locale, f), e, s.proseIn(p.Locale, "examples."+e.Name+".blurb", ex.Blurb), states}) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + return []byte(out), frames, nil |
| +} |
diff --git a/internal/gallery/family_page.go b/internal/gallery/family_page.go| new file mode 100644 |
| index 0000000..adcef50 |
| --- /dev/null |
| +++ b/internal/gallery/family_page.go |
| @@ -0,0 +1,61 @@ |
| +package gallery |
| + |
| +import ( |
| + "fmt" |
| + "html/template" |
| +) |
| + |
| +// A family page: the family's title is the page's heading; under it |
| +// the family's sentence, the idiom rules where the family is the |
| +// primitives, and its examples listed by name with each one's blurb, |
| +// so every section of the rail has a page of its own and the sequence |
| +// a step to stand on. |
| +const familyBody = `{{define "ds-body-family"}}{{with .Family}}{{if .Blurb}}<p class="ds-lead">{{.Blurb}}</p> |
| +{{end}}{{range .Rules}}<div rst-callout rst-tone="info"><span rst-callout-ic aria-hidden="true">{{icon "info"}}</span><div rst-callout-body><strong>{{.Title}}</strong> |
| +<p>{{.Body}}</p></div></div> |
| +{{end}}<ul class="ds-routes">{{range .Examples}} |
| +<li><a class="rst-mono" data-gallery-nav href="{{$.Href (print .Name ".html")}}">{{.Name}}</a><span>{{index $.Blurbs .Name}}</span></li>{{end}} |
| +</ul>{{end}}{{end}}` |
| + |
| +// familyBody renders the body of the family page p is. |
| +func (s *site) familyBody(p page) (template.HTML, error) { |
| + f, ok := s.familyByKey(p.Kind) |
| + if !ok { |
| + return "", fmt.Errorf("no family %s", p.Kind) |
| + } |
| + blurbs := map[string]string{} |
| + for _, e := range f.Examples { |
| + blurbs[e.Name] = s.proseIn(p.Locale, "examples."+e.Name+".blurb", s.prose.Examples[e.Name].Blurb) |
| + } |
| + return s.body(p.Locale, "ds-body-family", struct { |
| + page |
| + Family family |
| + Blurbs map[string]string |
| + }{p, s.familyIn(p.Locale, f), blurbs}) |
| +} |
| + |
| +// familyByKey is the family with key, if there is one. |
| +func (s *site) familyByKey(key string) (family, bool) { |
| + for _, f := range s.families { |
| + if f.Key == key { |
| + return f, true |
| + } |
| + } |
| + return family{}, false |
| +} |
| + |
| +// familyIn is f with its title, blurb and rules in locale. |
| +func (s *site) familyIn(locale string, f family) family { |
| + out := f |
| + out.Title = s.proseIn(locale, "families."+f.Key+".title", f.Title) |
| + out.Blurb = s.proseIn(locale, "families."+f.Key+".blurb", f.Blurb) |
| + out.Rules = nil |
| + for _, r := range f.Rules { |
| + out.Rules = append(out.Rules, idiomRule{ |
| + Idiom: r.Idiom, |
| + Title: s.proseIn(locale, "pages.idioms."+r.Idiom+".title", r.Title), |
| + Body: s.proseIn(locale, "pages.idioms."+r.Idiom+".body", r.Body), |
| + }) |
| + } |
| + return out |
| +} |
diff --git a/internal/gallery/frame.go b/internal/gallery/frame.go| new file mode 100644 |
| index 0000000..c496baf |
| --- /dev/null |
| +++ b/internal/gallery/frame.go |
| @@ -0,0 +1,100 @@ |
| +package gallery |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "regexp" |
| + "strings" |
| + |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// The frame document: one example document made a page the gallery |
| +// can frame, with the gallery's own assets, the page's theme and |
| +// locale, and no link that goes anywhere. |
| + |
| +var ( |
| + headTag = regexp.MustCompile(`<head\b[^>]*>`) |
| + firstLink = regexp.MustCompile(`<(link|script)\b`) |
| + bodyTag = regexp.MustCompile(`<body\b[^>]*>`) |
| + assetLink = regexp.MustCompile(`(href|src)="\.\./\.\./(css|js)/([^"]+)"`) |
| + picture = regexp.MustCompile(`src="\.\./assets/([^"]+)"`) |
| + sampleRef = regexp.MustCompile(`\b(href|action)="([^"]*)"`) |
| + ltrHTML = `<html lang="en" dir="ltr"` |
| +) |
| + |
| +// frame turns one example document into a frame document: the |
| +// gallery's hashed assets in place of the relative links, the page's |
| +// theme in place of the example's, frame.js first in the head, every |
| +// href and action in the body pointed at "#", and the page's language |
| +// and direction on <html> when it is not English. The example's title |
| +// and everything else in it are kept. e is the example the document |
| +// belongs to; the padding, the stage and a layout's live links read it |
| +// in a later task. |
| +func (s *site) frame(doc, theme, locale string, e example.Entry) (string, error) { |
| + head := headTag.FindStringIndex(doc) |
| + body := bodyTag.FindStringIndex(doc) |
| + if head == nil || body == nil || body[0] < head[1] { |
| + return "", errors.New("no <head> and <body> to rewrite") |
| + } |
| + // frame.js goes before the first stylesheet or script of the head, |
| + // after the charset and viewport metas, which stay first. |
| + at := firstLink.FindStringIndex(doc[head[1]:body[0]]) |
| + if at == nil { |
| + return "", errors.New("no stylesheet in the head to put frame.js before") |
| + } |
| + at[0] += head[1] |
| + top := doc[:at[0]] + `<script src="` + s.asset("frame.js") + `"></script>` + "\n" + doc[at[0]:body[1]] |
| + top = assetLink.ReplaceAllStringFunc(top, func(m string) string { |
| + sub := assetLink.FindStringSubmatch(m) |
| + file := sub[2] + "/" + sub[3] |
| + if strings.HasPrefix(file, "css/themes/") { |
| + file = "css/themes/" + theme + ".css" |
| + } |
| + return sub[1] + `="` + s.asset(file) + `"` |
| + }) |
| + top = s.pictures(top) |
| + if locale != "en" { |
| + if !strings.Contains(top, ltrHTML) { |
| + return "", fmt.Errorf("no %s to localise", ltrHTML) |
| + } |
| + top = strings.Replace(top, ltrHTML, `<html lang="`+locale+`" dir="`+Dir(locale)+`"`, 1) |
| + } |
| + rest := s.pictures(doc[body[1]:]) |
| + rest = sampleRef.ReplaceAllStringFunc(rest, func(m string) string { |
| + sub := sampleRef.FindStringSubmatch(m) |
| + if strings.HasPrefix(sub[2], "#") { |
| + return m |
| + } |
| + return sub[1] + `="#"` |
| + }) |
| + return top + rest, nil |
| +} |
| + |
| +// pictures points every sample picture at its hashed copy under |
| +// assets/images/. |
| +func (s *site) pictures(html string) string { |
| + return picture.ReplaceAllStringFunc(html, func(m string) string { |
| + name := picture.FindStringSubmatch(m)[1] |
| + return `src="` + s.asset("images/"+name) + `"` |
| + }) |
| +} |
| + |
| +// exampleDocument builds a document shaped like a seeded example, with |
| +// the relative asset links the seeds carry, so frame treats it like |
| +// any example: a title, the two stylesheets, the scripts by file name, |
| +// one <style> where style is not empty, and the body. |
| +func exampleDocument(title string, scripts []string, style, body string) string { |
| + var b strings.Builder |
| + b.WriteString("<!doctype html>\n<html lang=\"en\" dir=\"ltr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width\">\n") |
| + b.WriteString("<title>" + title + "</title>\n") |
| + b.WriteString("<link rel=\"stylesheet\" href=\"../../css/tokens.css\">\n<link rel=\"stylesheet\" href=\"../../css/themes/day.css\">\n") |
| + for _, sc := range scripts { |
| + b.WriteString(`<script defer src="../../js/` + sc + `"></script>` + "\n") |
| + } |
| + if style != "" { |
| + b.WriteString("<style>" + style + "</style>\n") |
| + } |
| + b.WriteString("</head>\n<body>\n" + body + "\n</body>\n</html>\n") |
| + return b.String() |
| +} |
diff --git a/internal/gallery/frame.js b/internal/gallery/frame.js| index 347a191..1035972 100644 |
| --- a/internal/gallery/frame.js |
| +++ b/internal/gallery/frame.js |
| @@ -49,7 +49,7 @@ |
| event.preventDefault(); |
| }, true); |
| - if (host && host.classList.contains("gallery-frame")) { |
| + if (host && host.classList.contains("ds-view__frame")) { |
| var asked = []; |
| var hold = function (el) { |
| if (el.hasAttribute("autofocus") && !el.inert) { |
diff --git a/internal/gallery/gallery.css b/internal/gallery/gallery.css| index 673d030..84d6952 100644 |
| --- a/internal/gallery/gallery.css |
| +++ b/internal/gallery/gallery.css |
| @@ -1,84 +1,61 @@ |
| /* gallery.css: the gallery's own chrome, and nothing an application |
| - receives. The header, the index's lists, a state's frame and its |
| - source, the token grid and the icon grid. Everything is drawn from |
| - the design system's own tokens, so the chrome follows the theme and |
| - the scheme like the examples inside it do. |
| + receives. The header, the routes, a state's label, the previous and |
| + next pair, the token grid and the icon grid. Everything is drawn |
| + from the design system's own tokens, so the chrome follows the theme |
| + and the scheme like the examples inside it do. The preview widget, |
| + the code block and the highlight colours are view.css's; the two are |
| + served joined as one gallery.css. */ |
| - The highlight colours at the foot are the five custom elements |
| - codeview.Highlight writes; TestHighlightColoursPassAgainstTheCodeBackground |
| - reads them off this file by their exact lines. */ |
| - |
| -/* The header: the brand, the theme tabs, the scheme menu, the link to |
| - the other direction and the page links, wrapping as they need to at |
| - any width. The seg-tabs' own margin is for a page body, not a bar. */ |
| -.gallery-top { align-items: center; border-block-end: 1px solid var(--rst-line); display: flex; flex-wrap: wrap; gap: var(--rst-sp-3) var(--rst-sp-4); padding: var(--rst-sp-3) var(--rst-sp-4); } |
| -.gallery-top [rst-seg-tabs] { margin-block-end: 0; } |
| -.gallery-brand { color: var(--rst-text); font-weight: 650; text-decoration: none; } |
| -.gallery-pages { display: flex; flex-wrap: wrap; gap: var(--rst-sp-3); } |
| -.gallery-pages a[aria-current] { font-weight: 650; } |
| +/* The header: the brand, the theme tabs, the scheme menu, the language |
| + menu and the page links, wrapping as they need to at any width. The |
| + seg-tabs' own margin is for a page body, not a bar. */ |
| +.ds-top { align-items: center; border-block-end: 1px solid var(--rst-line); display: flex; flex-wrap: wrap; gap: var(--rst-sp-3) var(--rst-sp-4); padding: var(--rst-sp-3) var(--rst-sp-4); } |
| +.ds-top [rst-seg-tabs] { margin-block-end: 0; } |
| +.ds-top__controls { align-items: center; display: flex; flex-wrap: wrap; gap: var(--rst-sp-3) var(--rst-sp-4); } |
| +.ds-top__brand { color: var(--rst-text); font-weight: 650; text-decoration: none; } |
| +.ds-pages { display: flex; flex-wrap: wrap; gap: var(--rst-sp-3); } |
| +.ds-pages a[aria-current] { font-weight: 650; } |
| /* Every link in the bar is a target like any control: the floor in |
| force, centred on its line. */ |
| -.gallery-brand, .gallery-twin, .gallery-pages a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); } |
| +.ds-top__brand, .ds-pages a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); } |
| /* A heading is one word on the index, the tokens and the icons pages, |
| and a word has to be able to break at 320px: the page may not scroll |
| sideways for anything. */ |
| main[rst-page] h1, main[rst-page] h2 { overflow-wrap: anywhere; } |
| -.gallery-lead { color: var(--rst-text-muted); margin: 0 0 var(--rst-sp-4); max-width: 62ch; } |
| -.gallery-note { border-inline-start: 2px solid var(--rst-line-strong); color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-4); max-width: 62ch; padding-inline-start: var(--rst-sp-3); } |
| -.gallery-family h2 { font-size: var(--rst-fs-base); margin: var(--rst-sp-5) 0 var(--rst-sp-2); } |
| -.gallery-family .gallery-lead { font-size: var(--rst-fs-sm); } |
| -/* An example page's family, above its heading: the family's name and |
| - its sentence, as a kicker back to where the index files it. */ |
| -.gallery-family-head { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-2); max-width: 62ch; } |
| -.gallery-family-head b { color: var(--rst-text); font-weight: 600; } |
| -.gallery-list { display: grid; gap: var(--rst-sp-2); grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); list-style: none; margin: 0; padding: 0; } |
| -.gallery-list a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); } |
| - |
| -/* One state: its label, its frame, its source. */ |
| -.gallery-state { margin-block: var(--rst-sp-5); } |
| -.gallery-label { font-size: var(--rst-fs-base); margin: 0 0 var(--rst-sp-2); } |
| -.gallery-view { border: 1px solid var(--rst-line); border-radius: var(--rst-radius); overflow: hidden; } |
| -/* A frame shows nothing of its own until gallery.js marks it, once its |
| - document has loaded and has the page's scheme written on it. Before |
| - that it holds about:blank, or a document that has painted in the |
| - OS's scheme while the page is on Light or Dark. Hidden, the box |
| - behind it shows this page's background instead. data-gallery-js |
| - because gallery.js is what shows it: with scripts off nothing |
| - would, and the frames show as they come. */ |
| -.gallery-frame { block-size: 20rem; border: 0; color-scheme: normal; display: block; inline-size: 100%; } |
| -.gallery-frame--page { block-size: 40rem; resize: vertical; } |
| -:root[data-gallery-js] .gallery-frame:not([data-gallery-painted]) { visibility: hidden; } |
| +.ds-lead { color: var(--rst-text-muted); margin: 0 0 var(--rst-sp-4); max-width: 62ch; } |
| +.ds-note { border-inline-start: 2px solid var(--rst-line-strong); color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-4); max-width: 62ch; padding-inline-start: var(--rst-sp-3); } |
| +.ds-sub { font-size: var(--rst-fs-base); margin: var(--rst-sp-5) 0 var(--rst-sp-2); } |
| +/* An example page's family, under its heading: the family's name and |
| + its sentence, as a kicker back to the page that files it. */ |
| +.ds-kicker { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-2); max-width: 62ch; } |
| +.ds-kicker b { color: var(--rst-text); font-weight: 600; } |
| +/* The routes off the Overview and a family page's examples: a link |
| + and the sentence beside it. */ |
| +.ds-routes { display: grid; gap: var(--rst-sp-3); grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); list-style: none; margin: 0 0 var(--rst-sp-5); padding: 0; } |
| +.ds-routes a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); } |
| +.ds-routes span { color: var(--rst-text-muted); display: block; font-size: var(--rst-fs-sm); } |
| +/* The previous and next pair at the foot of a page, one at each end |
| + of its row. */ |
| +.ds-updown { display: grid; gap: var(--rst-sp-3); grid-template-columns: 1fr 1fr; margin-block-start: var(--rst-sp-5); } |
| +.ds-updown a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); } |
| +.ds-updown__prev { grid-column: 1; justify-self: start; } |
| +.ds-updown__next { grid-column: 2; justify-self: end; text-align: end; } |
| -/* The HTML tab is a disclosure: no script, and the browser's own |
| - control. Its summary is a target like the links above. */ |
| -.gallery-code > summary { align-items: center; cursor: pointer; display: inline-flex; font-weight: 600; min-block-size: var(--rst-target); } |
| -/* Source soft-wraps rather than scrolling sideways: a sample's row |
| - of attributes is one line, and the page is what has to stay inside |
| - 320px. overflow-x stays for the single token wider than the column. |
| - Code is left to right in every language. */ |
| -.gallery-src { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); direction: ltr; margin: var(--rst-sp-2) 0 0; overflow-x: auto; padding: var(--rst-sp-4); text-align: start; unicode-bidi: isolate; } |
| -.gallery-src code { overflow-wrap: anywhere; white-space: pre-wrap; } |
| -.gallery-routes { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: var(--rst-sp-3) 0 0; max-width: 62ch; } |
| +/* One state: its label, then the widget view.css draws. */ |
| +.ds-sample { margin-block: var(--rst-sp-5); } |
| +.ds-state { font-size: var(--rst-fs-base); margin: 0 0 var(--rst-sp-2); } |
| +.ds-routes-note { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: var(--rst-sp-3) 0 0; max-width: 62ch; } |
| /* The token grid, and the icon grid that reuses it: one chip per |
| token, its name and value beside it. */ |
| -.gallery-toks { display: grid; gap: var(--rst-sp-3); grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); list-style: none; margin: 0 0 var(--rst-sp-5); padding: 0; } |
| -.gallery-tok { align-items: center; display: flex; gap: var(--rst-sp-3); min-inline-size: 0; } |
| -.gallery-tok__text { min-inline-size: 0; } |
| -.gallery-tok__name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| -.gallery-tok__value { color: var(--rst-text-muted); display: block; overflow-wrap: anywhere; } |
| -.gallery-chip { background: var(--rst-surface); block-size: 2.25rem; border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius-sm); flex: none; inline-size: 2.25rem; } |
| -.gallery-chip--fill { background: var(--rst-accent); border-color: var(--rst-accent); } |
| -.gallery-chip--icon { align-items: center; color: var(--rst-text); display: flex; justify-content: center; } |
| -.gallery-bar { background: var(--rst-accent); block-size: 1.25rem; border-radius: 2px; flex: none; } |
| -.gallery-type { display: block; flex: none; inline-size: 3.25rem; line-height: 1.15; text-align: center; } |
| - |
| -/* The five token colours, from theme tokens so they follow theme and |
| - scheme, each at 4.5:1 or better against --rst-surface in every |
| - theme and both schemes. */ |
| -ds-t { color: var(--rst-accent); } |
| -ds-a { color: var(--rst-tone-negative-fg); } |
| -ds-v { color: var(--rst-tone-positive-fg); } |
| -ds-x { color: var(--rst-text-muted); } |
| -ds-s { color: var(--rst-tone-warning-fg); } |
| +.ds-toks { display: grid; gap: var(--rst-sp-3); grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); list-style: none; margin: 0 0 var(--rst-sp-5); padding: 0; } |
| +.ds-tok { align-items: center; display: flex; gap: var(--rst-sp-3); min-inline-size: 0; } |
| +.ds-tok__text { min-inline-size: 0; } |
| +.ds-tok__name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| +.ds-tok__value { color: var(--rst-text-muted); display: block; overflow-wrap: anywhere; } |
| +.ds-chip { background: var(--rst-surface); block-size: 2.25rem; border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius-sm); flex: none; inline-size: 2.25rem; } |
| +.ds-chip--fill { background: var(--rst-accent); border-color: var(--rst-accent); } |
| +.ds-chip--icon { align-items: center; color: var(--rst-text); display: flex; justify-content: center; } |
| +.ds-bar { background: var(--rst-accent); block-size: 1.25rem; border-radius: 2px; flex: none; } |
| +.ds-type { display: block; flex: none; inline-size: 3.25rem; line-height: 1.15; text-align: center; } |
diff --git a/internal/gallery/gallery.go b/internal/gallery/gallery.go| index 198e4c3..ea74507 100644 |
| --- a/internal/gallery/gallery.go |
| +++ b/internal/gallery/gallery.go |
| @@ -1,29 +1,34 @@ |
| -// Package gallery renders Fiesta's own gallery: every state of every |
| -// example, in every theme, each in a frame of its own, with its markup |
| -// beside it; a tokens page and an icons page per theme; and a |
| -// right-to-left twin of all of it. It is a development and test |
| -// artefact: the accessibility drives scan what it renders, and |
| -// fiesta gallery writes it out for looking at. |
| +// Package gallery renders Fiesta's own gallery: the design system in |
| +// every theme and twelve languages, with an Overview, a Tokens page, |
| +// an Icons page, a page per family and a page per example, every |
| +// state of every example in a frame of its own with its markup beside |
| +// it. It is a development and test artefact: the accessibility drives |
| +// scan what it renders, fiesta gallery writes it out for looking at, |
| +// and internal/www mounts it on the site. |
| // |
| // Render returns the whole tree in memory, keyed by path relative to |
| // the tree root, and it is deterministic: the same tree from the same |
| // source every time, so two renders can be compared. |
| // |
| -// index.html the first theme's index again |
| -// <theme>/index.html the examples, by family |
| -// <theme>/<name>.html one page per example: its |
| -// states, each a frame, its HTML |
| -// and the routes it stands for |
| -// <theme>/frames/<name>/<state>.html one document per state, the |
| -// example's own with the |
| -// gallery's assets and dead links |
| -// <theme>/tokens.html the theme's palette and the |
| -// structure every theme shares |
| -// <theme>/icons.html every icon |
| -// <theme>/rtl/... the same, lang="ar" dir="rtl" |
| -// assets/css/... assets/js/... the source files, as they are |
| -// assets/images/... the examples' sample pictures |
| -// assets/gallery.css gallery.js frame.js |
| +// index.html day/en/index.html again |
| +// <theme>/<locale>/index.html the Overview |
| +// <theme>/<locale>/tokens.html the theme's palette and |
| +// the structure every |
| +// theme shares |
| +// <theme>/<locale>/icons.html every icon |
| +// <theme>/<locale>/<family>.html one page per family |
| +// <theme>/<locale>/<example>.html one page per example |
| +// <theme>/<locale>/frames/<example>/<state>.html |
| +// one document per state, |
| +// the example's own with |
| +// the gallery's assets |
| +// and dead links |
| +// assets/<name>.<hash>.<ext>, assets/<name>.<ext> |
| +// every asset hashed and |
| +// plain: css/, js/, the |
| +// examples' pictures under |
| +// images/, and gallery.css, |
| +// gallery.js and frame.js |
| // |
| // Two kinds of URL. The gallery owns every stylesheet, script, image |
| // and frame URL and every link marked data-gallery-nav: each is an |
| @@ -49,7 +54,6 @@ import ( |
| "amadan.net/rastrillo/fiesta" |
| "amadan.net/rastrillo/fiesta/example" |
| - "amadan.net/rastrillo/fiesta/internal/codeview" |
| "amadan.net/rastrillo/fiesta/internal/tokens" |
| ) |
| @@ -63,20 +67,25 @@ type Options struct { |
| // The gallery's own furniture, embedded beside this file so each is |
| // edited as what it is. None is part of the design system: no page |
| -// but the gallery's own links them. |
| +// but the gallery's own links them. The stylesheet and the script are |
| +// each two source files, the chrome's and the preview widget's, joined |
| +// at render into the one gallery.css and gallery.js every page loads. |
| // |
| //go:embed gallery.css |
| var galleryCSS []byte |
| +//go:embed view.css |
| +var viewCSS []byte |
| + |
| //go:embed gallery.js |
| var galleryJS []byte |
| +//go:embed view.js |
| +var viewJS []byte |
| + |
| //go:embed frame.js |
| var frameJS []byte |
| -// rtlDir is where a theme's right-to-left twin lives under the theme. |
| -const rtlDir = "rtl/" |
| - |
| // cleanMount normalises the mount to "" for the root or a rooted, |
| // cleaned path with no slash at the end, so URLs are mount + "/" + rel |
| // in either case. |
| @@ -114,10 +123,17 @@ type site struct { |
| prose *prose |
| families []family |
| icons []icon |
| + // tokens is each theme's token groups, palette then structure. |
| + tokens map[string][]tokens.Group |
| + // hashed maps each asset's plain name under assets/ to the hashed |
| + // name it is linked by. |
| + hashed map[string]string |
| + // tmpl is the parsed templates, one tree per locale. |
| + tmpl map[string]*template.Template |
| } |
| -// family is one run of examples on the index: its heading and the |
| -// sentence under it, the rules quoted at its head, and its examples. |
| +// family is one family of examples: its heading and the sentence |
| +// under it, the rules quoted at its head, and its examples. |
| type family struct { |
| Key string |
| Title string |
| @@ -135,11 +151,38 @@ type icon struct { |
| // root to file content. src is fiesta.FS() or any tree laid out the |
| // same way. |
| func Render(src fs.FS, opts Options) (map[string][]byte, error) { |
| + s, err := newSite(src, opts.Mount) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, theme := range s.themes() { |
| + for _, locale := range Locales { |
| + if err := s.theme(theme, locale); err != nil { |
| + return nil, fmt.Errorf("gallery: %s/%s: %w", theme, locale, err) |
| + } |
| + } |
| + } |
| + // The tree root is the default theme's English Overview again, |
| + // copied rather than aliased so callers get one slice per path. |
| + root, ok := s.out[s.themes()[0]+"/en/index.html"] |
| + if !ok { |
| + return nil, errors.New("gallery: no index to serve as the tree root") |
| + } |
| + if err := s.put("index.html", append([]byte(nil), root...)); err != nil { |
| + return nil, err |
| + } |
| + return s.out, nil |
| +} |
| + |
| +// newSite reads everything a render needs before a page is written: |
| +// the manifest, the prose, the assets, the scheme menu, the families, |
| +// the icons and the tokens. |
| +func newSite(src fs.FS, mount string) (*site, error) { |
| m, err := example.Load(src) |
| if err != nil { |
| return nil, fmt.Errorf("gallery: %w", err) |
| } |
| - s := &site{src: src, m: m, mount: cleanMount(opts.Mount), out: map[string][]byte{}} |
| + s := &site{src: src, m: m, mount: cleanMount(mount), out: map[string][]byte{}, hashed: map[string]string{}} |
| if s.prose, err = loadProse(src); err != nil { |
| return nil, fmt.Errorf("gallery: %w", err) |
| } |
| @@ -156,25 +199,15 @@ func Render(src fs.FS, opts Options) (map[string][]byte, error) { |
| if err := s.readIcons(); err != nil { |
| return nil, fmt.Errorf("gallery: %w", err) |
| } |
| - for _, theme := range fiesta.ThemeNames() { |
| - for _, rtl := range []bool{false, true} { |
| - if err := s.theme(theme, rtl); err != nil { |
| - return nil, fmt.Errorf("gallery: %s: %w", theme, err) |
| - } |
| - } |
| - } |
| - // The tree root is the first theme's index again, copied rather |
| - // than aliased so callers get one slice per path. |
| - root, ok := s.out[fiesta.ThemeNames()[0]+"/index.html"] |
| - if !ok { |
| - return nil, errors.New("gallery: no index to serve as the tree root") |
| - } |
| - if err := s.put("index.html", append([]byte(nil), root...)); err != nil { |
| - return nil, err |
| + if err := s.readTokens(); err != nil { |
| + return nil, fmt.Errorf("gallery: %w", err) |
| } |
| - return s.out, nil |
| + return s, nil |
| } |
| +// themes are the theme names, the default first. |
| +func (s *site) themes() []string { return fiesta.ThemeNames() } |
| + |
| // put adds one file to the tree and refuses a second writer: a name |
| // one page took from another would replace a document with no test |
| // noticing. |
| @@ -189,9 +222,10 @@ func (s *site) put(name string, body []byte) error { |
| // url is the absolute URL of one file of the tree. |
| func (s *site) url(rel string) string { return s.mount + "/" + rel } |
| -// assets copies the source files the frames and pages link, as they |
| -// are: css/ and js/ under assets/, the examples' pictures under |
| -// assets/images/, and the gallery's own three files beside them. |
| +// assets writes the files the frames and pages link, each hashed and |
| +// plain: css/ and js/ under assets/, the examples' pictures under |
| +// assets/images/, and the gallery's own three files beside them, the |
| +// stylesheet and the script each the join of its two halves. |
| func (s *site) assets() error { |
| for _, dir := range []string{"css", "js"} { |
| err := fs.WalkDir(s.src, dir, func(p string, d fs.DirEntry, err error) error { |
| @@ -205,14 +239,14 @@ func (s *site) assets() error { |
| if err != nil { |
| return err |
| } |
| - return s.put("assets/"+p, b) |
| + return s.putAsset(p, b) |
| }) |
| if err != nil { |
| return err |
| } |
| } |
| - for _, theme := range fiesta.ThemeNames() { |
| - if _, ok := s.out["assets/css/themes/"+theme+".css"]; !ok { |
| + for _, theme := range s.themes() { |
| + if _, ok := s.hashed["css/themes/"+theme+".css"]; !ok { |
| return fmt.Errorf("no css/themes/%s.css in the source", theme) |
| } |
| } |
| @@ -228,18 +262,34 @@ func (s *site) assets() error { |
| if err != nil { |
| return err |
| } |
| - if err := s.put("assets/images/"+e.Name(), b); err != nil { |
| + if err := s.putAsset("images/"+e.Name(), b); err != nil { |
| return err |
| } |
| } |
| - for name, body := range map[string][]byte{"gallery.css": galleryCSS, "gallery.js": galleryJS, "frame.js": frameJS} { |
| - if err := s.put("assets/"+name, body); err != nil { |
| + own := []struct { |
| + name string |
| + body []byte |
| + }{ |
| + {"gallery.css", joined(galleryCSS, viewCSS)}, |
| + {"gallery.js", joined(galleryJS, viewJS)}, |
| + {"frame.js", frameJS}, |
| + } |
| + for _, a := range own { |
| + if err := s.putAsset(a.name, a.body); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| +// joined is two source files as one, a newline between them. |
| +func joined(a, b []byte) []byte { |
| + out := make([]byte, 0, len(a)+1+len(b)) |
| + out = append(out, a...) |
| + out = append(out, '\n') |
| + return append(out, b...) |
| +} |
| + |
| var ( |
| htmlTag = regexp.MustCompile(`<html\b[^>]*>`) |
| schemeStore = regexp.MustCompile(`\sdata-rst-scheme-store="[^"]*"`) |
| @@ -318,7 +368,7 @@ func groupFamilies(entries []example.Entry) []family { |
| // explain gives each family its heading and blurb from the prose, and |
| // puts each idiom rule at the head of the family that holds the idiom |
| // it names, which is the primitives. A family the prose has no heading |
| -// for is an error: the index would show a family with no name. |
| +// for is an error: the page would have no name. |
| func (s *site) explain() error { |
| at := map[string]int{} |
| for i := range s.families { |
| @@ -365,385 +415,170 @@ func (s *site) readIcons() error { |
| return nil |
| } |
| -// page is what every gallery page's template is executed with. |
| +// page is what every gallery page's template is executed with: every |
| +// field any page renders, so the template and the bodies read one |
| +// value. |
| type page struct { |
| - s *site |
| - Theme string |
| - RTL bool |
| - // File is the page's name within its theme directory, and Lang |
| - // and Dir what its <html> says. |
| - File string |
| - Lang string |
| - Dir string |
| - // Name is the page's own name for its <title> and the brand's |
| - // aria-current; Body its content. |
| - Name string |
| + s *site |
| + // Theme and Locale place the page in the tree; Lang and Dir are |
| + // what its <html> says. |
| + Theme, Locale, Lang, Dir string |
| + // File is the page's name within its directory; Kind the page kind |
| + // it is, or the family of the example it is; Name its own name for |
| + // its <title> and <h1>; TitleID the id the <h1> carries, if any. |
| + File, Kind, Name, TitleID string |
| + // Home is the Overview's address and Up a content page's way back |
| + // on a phone: the Overview with this page's row as the fragment. |
| + Home, Up string |
| + // Body is the page's own markup. |
| Body template.HTML |
| - // Current is the navigation entry this page is: "examples", |
| - // "tokens" or "icons". |
| - Current string |
| + // Prev and Next are the pair at the foot of the page; either is nil |
| + // at the ends of the sequence. |
| + Prev, Next *link |
| + // Routes is the Overview's way into every other page. |
| + Routes []route |
| + // Nav is the rail. |
| + Nav []navSection |
| + // Rows and Demos are the phone index, the Overview's alone. |
| + Rows []indexRow |
| + Demos navSection |
| + // Bar is the theme, scheme and language controls. |
| + Bar controls |
| } |
| // Title is the document's <title>. |
| -func (p page) Title() string { return say("gallery.title", "page", p.Name, "theme", p.Theme) } |
| - |
| -// Scheme is the header's scheme menu. |
| -func (p page) Scheme() template.HTML { return p.s.scheme } |
| +func (p page) Title() string { |
| + return p.s.say(p.Locale, "gallery.title", "page", p.Name, "theme", p.Theme) |
| +} |
| // HTMLAttrs are the attributes every gallery page's <html> carries |
| // beyond lang and dir. |
| func (p page) HTMLAttrs() template.HTMLAttr { return p.s.htmlAttrs } |
| -// Asset is the URL of one of the gallery's assets. |
| -func (p page) Asset(name string) string { return p.s.url("assets/" + name) } |
| - |
| // Icon is one of Fiesta's icons, inline, for the gallery's own |
| // furniture. |
| -func (p page) Icon(slug string) template.HTML { |
| - for _, ic := range p.s.icons { |
| - if ic.Slug == slug { |
| - return ic.Markup |
| - } |
| - } |
| - return "" |
| -} |
| +func (p page) Icon(slug string) template.HTML { return p.s.iconMarkup(slug) } |
| // Scripts are the scripts every gallery page loads, for the scheme |
| // menu. |
| func (p page) Scripts() []string { return p.s.schemeScripts } |
| -// dir is the theme directory this page's twin in the given direction |
| -// sits in. |
| -func (p page) dir(theme string, rtl bool) string { |
| - if rtl { |
| - return theme + "/" + rtlDir |
| - } |
| - return theme + "/" |
| -} |
| +// Href is the URL of file in this page's theme and locale. |
| +func (p page) Href(file string) string { return p.s.href(p.Theme, p.Locale, file) } |
| -// Href is the URL of file in this page's theme and direction. |
| -func (p page) Href(file string) string { return p.s.url(p.dir(p.Theme, p.RTL) + file) } |
| +// Mono says whether the page's heading is an identifier, drawn in the |
| +// mono face: an example's page is, named by the example. |
| +func (p page) Mono() bool { |
| + _, ok := p.s.m.Entry(strings.TrimSuffix(p.File, ".html")) |
| + return ok |
| +} |
| -// link is one entry of a switcher. |
| +// link is one entry of a switcher, or one step of the sequence. |
| type link struct { |
| Href string |
| Label string |
| Current bool |
| } |
| -// Themes are the theme links: this page in every theme. |
| -func (p page) Themes() []link { |
| - var out []link |
| - for _, theme := range fiesta.ThemeNames() { |
| - out = append(out, link{Href: p.s.url(p.dir(theme, p.RTL) + p.File), Label: theme, Current: theme == p.Theme}) |
| - } |
| - return out |
| -} |
| - |
| -// Twin is the link to this page in the other direction. |
| -func (p page) Twin() link { |
| - if p.RTL { |
| - return link{Href: p.s.url(p.dir(p.Theme, false) + p.File), Label: say("gallery.direction.ltr")} |
| - } |
| - return link{Href: p.s.url(p.dir(p.Theme, true) + p.File), Label: say("gallery.direction.rtl")} |
| -} |
| - |
| -// Nav is the page navigation: the index, the tokens and the icons. |
| -func (p page) Nav() []link { |
| - return []link{ |
| - {Href: p.Href("index.html"), Label: say("gallery.nav.examples"), Current: p.Current == "examples"}, |
| - {Href: p.Href("tokens.html"), Label: say("gallery.nav.tokens"), Current: p.Current == "tokens"}, |
| - {Href: p.Href("icons.html"), Label: say("gallery.nav.icons"), Current: p.Current == "icons"}, |
| - } |
| -} |
| - |
| -var funcs = template.FuncMap{"say": say} |
| - |
| -// pageTemplate is the frame every gallery page is drawn in: the head, |
| -// the header with the theme links, the scheme menu, the direction link |
| -// and the page navigation, then the page's own body. |
| -// |
| -// gallery.js blocks in <head>, as rastrillo's does, so that a frame is |
| -// hidden before it can paint in the wrong scheme; the scheme menu's own |
| -// scripts are deferred, as the example that ships them has them. |
| -var pageTemplate = template.Must(template.New("page").Funcs(funcs).Parse(`<!doctype html> |
| -<html lang="{{.Lang}}" dir="{{.Dir}}" {{.HTMLAttrs}}> |
| -<head> |
| -<meta charset="utf-8"> |
| -<meta name="viewport" content="width=device-width"> |
| -<title>{{.Title}}</title> |
| -<link rel="stylesheet" href="{{.Asset "css/tokens.css"}}"> |
| -<link rel="stylesheet" href="{{.Asset (print "css/themes/" .Theme ".css")}}"> |
| -<link rel="stylesheet" href="{{.Asset "gallery.css"}}"> |
| -<script src="{{.Asset "gallery.js"}}"></script> |
| -{{range .Scripts}}<script defer src="{{$.Asset (print "js/" .)}}"></script> |
| -{{end}}</head> |
| -<body> |
| -<a rst-skip href="#main">{{say "gallery.skip"}}</a> |
| -<header class="gallery-top"> |
| -<a class="gallery-brand" data-gallery-nav href="{{.Href "index.html"}}">{{say "gallery.brand"}}</a> |
| -<nav rst-seg-tabs aria-label="{{say "gallery.theme.label"}}">{{range .Themes}}<a data-gallery-nav href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}</nav> |
| -{{.Scheme}} |
| -{{with .Twin}}<a class="gallery-twin" data-gallery-nav href="{{.Href}}">{{.Label}}</a>{{end}} |
| -<nav class="gallery-pages" aria-label="{{say "gallery.nav.label"}}">{{range .Nav}}<a data-gallery-nav href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}</nav> |
| -</header> |
| -<main rst-page id="main"> |
| -{{.Body}} |
| -</main> |
| -</body> |
| -</html> |
| -`)) |
| - |
| -// indexTemplate is the index's body: the examples, by family, each |
| -// family headed by its sentence and, for the primitives, the two rules |
| -// about cards and columns. |
| -var indexTemplate = template.Must(template.New("index").Funcs(funcs).Parse(`<h1>{{say "gallery.index.heading"}}</h1> |
| -<p class="gallery-lead">{{say "gallery.index.lead" "theme" .Theme}}</p> |
| -{{range .Families}}<section class="gallery-family"> |
| -<h2 id="{{.Key}}">{{.Title}}</h2> |
| -{{if .Blurb}}<p class="gallery-lead">{{.Blurb}}</p> |
| -{{end}}{{range .Rules}}<div rst-callout rst-tone="info"><span rst-callout-ic aria-hidden="true">{{$.Icon "info"}}</span><div rst-callout-body><strong>{{.Title}}</strong> |
| -<p>{{.Body}}</p></div></div> |
| -{{end}}<ul class="gallery-list">{{range .Examples}} |
| -<li><a class="rst-mono" data-gallery-nav href="{{$.Href (print .Name ".html")}}">{{.Name}}</a></li>{{end}} |
| -</ul> |
| -</section> |
| -{{end}}`)) |
| - |
| -// state is one state on an example's page. |
| -type state struct { |
| - ID string |
| - Label string |
| - Note string |
| - Frame string |
| - Title string |
| - Page bool |
| - Source template.HTML |
| - Routes string |
| +// newPage is a page of one theme and locale with everything the |
| +// template reads filled in but its body. |
| +func (s *site) newPage(theme, locale, kind, file, name string) page { |
| + p := page{s: s, Theme: theme, Locale: locale, Lang: locale, Dir: Dir(locale), File: file, Kind: kind, Name: name} |
| + p.Home = s.href(theme, locale, "index.html") |
| + p.Prev, p.Next = s.steps(theme, locale, file) |
| + p.Routes = s.routes(theme, locale, kind) |
| + p.Nav = s.galleryNav(p) |
| + p.Bar = s.controls(p) |
| + return p |
| } |
| -// exampleTemplate is one example's page: its family's heading and |
| -// sentence, its own heading with its sentence under it, then every |
| -// state as a frame of its own document, with the note on the state |
| -// under its label, its HTML, and the routes it stands for. |
| -var exampleTemplate = template.Must(template.New("example").Funcs(funcs).Parse(`<p class="gallery-family-head"><b>{{.Family.Title}}</b> {{.Family.Blurb}}</p> |
| -<h1 class="rst-mono">{{.Entry.Name}}</h1> |
| -{{if .Blurb}}<p rst-page-header-sub>{{.Blurb}}</p> |
| -{{end}}<p class="gallery-lead">{{say "gallery.example.lead"}}</p> |
| -{{range .States}}<section class="gallery-state" id="{{.ID}}" data-gallery-state="{{$.Entry.Name}}/{{.ID}}"> |
| -<h2 class="gallery-label">{{.Label}}</h2> |
| -{{if .Note}}<p class="gallery-note">{{.Note}}</p> |
| -{{end}}<div class="gallery-view"><iframe class="gallery-frame{{if .Page}} gallery-frame--page{{end}}" title="{{.Title}}" src="{{.Frame}}" loading="lazy"></iframe></div> |
| -<details class="gallery-code"><summary>{{say "gallery.html"}}</summary> |
| -<pre class="gallery-src rst-mono" tabindex="0"><code>{{.Source}}</code></pre> |
| -</details> |
| -{{if .Routes}}<p class="gallery-routes">{{.Routes}}</p> |
| -{{end}}</section> |
| -{{end}}`)) |
| - |
| -// tokensTemplate is the tokens page: the theme's palette, then the |
| -// structure every theme shares. |
| -var tokensTemplate = template.Must(template.New("tokens").Funcs(funcs).Parse(`<h1>{{say "gallery.tokens.heading"}}</h1> |
| -<p class="gallery-lead">{{say "gallery.tokens.lead" "theme" .Theme}}</p> |
| -<p class="gallery-note">{{say "gallery.tokens.note"}}</p> |
| -{{range .Groups}}<h2 id="tokens-{{.Key}}">{{say (print "gallery.tokens.group." .Key)}}</h2> |
| -<ul class="gallery-toks">{{$kind := .Kind}}{{range .Rows}}<li class="gallery-tok">{{if eq $kind "type"}}<span class="gallery-type" style="{{.Preview}}">Ag</span>{{else if eq $kind "space"}}<span class="gallery-bar" style="{{.Preview}}"></span>{{else if eq $kind "radius"}}<span class="gallery-chip gallery-chip--fill" style="{{.Preview}}"></span>{{else if .Preview}}<span class="gallery-chip" style="{{.Preview}}"></span>{{end}}<span class="gallery-tok__text rst-mono"><span class="gallery-tok__name">{{.Name}}</span><span class="gallery-tok__value">{{.Value}}</span></span></li>{{end}}</ul> |
| -{{end}}`)) |
| - |
| -// iconsTemplate is the icons page: one chip per icon, its slug beside |
| -// it. |
| -var iconsTemplate = template.Must(template.New("icons").Funcs(funcs).Parse(`<h1>{{say "gallery.icons.heading"}}</h1> |
| -<p class="gallery-lead">{{say "gallery.icons.lead" "total" .Total}}</p> |
| -<ul class="gallery-toks gallery-icons">{{range .Icons}} |
| -<li class="gallery-tok" id="icon-{{.Slug}}"><span class="gallery-chip gallery-chip--icon">{{.Markup}}</span><span class="gallery-tok__text rst-mono"><span class="gallery-tok__name">{{.Slug}}</span><span class="gallery-tok__value">icons/{{.Slug}}.svg</span></span></li>{{end}} |
| -</ul>`)) |
| - |
| -// theme renders one theme in one direction: its index, tokens and |
| -// icons pages, and a page and a set of frames per example. |
| -func (s *site) theme(theme string, rtl bool) error { |
| - p := page{s: s, Theme: theme, RTL: rtl, Lang: "en", Dir: "ltr"} |
| - if rtl { |
| - p.Lang, p.Dir = "ar", "rtl" |
| - } |
| - dir := p.dir(theme, rtl) |
| - |
| - var b bytes.Buffer |
| - if err := indexTemplate.Execute(&b, struct { |
| - page |
| - Families []family |
| - }{p, s.families}); err != nil { |
| - return err |
| - } |
| - if err := s.write(p, "index.html", say("gallery.nav.examples"), "examples", b.String()); err != nil { |
| - return err |
| - } |
| - |
| - groups, err := tokens.Palette(theme) |
| - if err != nil { |
| - return err |
| - } |
| - structure, err := tokens.Structural() |
| - if err != nil { |
| - return err |
| - } |
| - b.Reset() |
| - if err := tokensTemplate.Execute(&b, struct { |
| - page |
| - Groups []tokens.Group |
| - }{p, append(groups, structure...)}); err != nil { |
| - return err |
| - } |
| - if err := s.write(p, "tokens.html", say("gallery.nav.tokens"), "tokens", b.String()); err != nil { |
| - return err |
| - } |
| - |
| - b.Reset() |
| - if err := iconsTemplate.Execute(&b, struct { |
| - page |
| - Icons []icon |
| - Total string |
| - }{p, s.icons, fmt.Sprint(len(s.icons))}); err != nil { |
| - return err |
| - } |
| - if err := s.write(p, "icons.html", say("gallery.nav.icons"), "icons", b.String()); err != nil { |
| - return err |
| +// theme renders one theme in one locale: a page per page kind, then a |
| +// page and its frame documents per example, then the documents that |
| +// are neither. It is the one loop that writes to the tree; everything |
| +// else hands its output back through the maps it returns. |
| +func (s *site) theme(theme, locale string) error { |
| + dir := theme + "/" + locale + "/" |
| + for _, pk := range s.pageKinds() { |
| + p := s.newPage(theme, locale, pk.Kind, pk.File, s.titleOf(locale, pk)) |
| + p.TitleID = pk.Kind |
| + if pk.Kind != "overview" { |
| + p.Up = p.Home + "#nav-" + pk.Kind |
| + } |
| + s.phoneIndex(&p) |
| + body, err := pk.Body(p) |
| + if err != nil { |
| + return fmt.Errorf("%s: %w", pk.File, err) |
| + } |
| + p.Body = body |
| + if err := s.write(dir, p); err != nil { |
| + return err |
| + } |
| } |
| - |
| for _, e := range s.m.Examples { |
| - ex := s.prose.Examples[e.Name] |
| - var states []state |
| - for _, st := range e.States { |
| - doc, err := example.Document(s.src, e.Name, st.ID) |
| - if err != nil { |
| - return err |
| - } |
| - frame, err := s.frame(doc, theme, rtl) |
| - if err != nil { |
| - return fmt.Errorf("%s/%s: %w", e.Name, st.ID, err) |
| + p := s.newPage(theme, locale, e.Family, e.Name+".html", e.Name) |
| + p.Up = p.Home + "#nav-" + e.Family |
| + body, frames, err := s.example(p, e) |
| + if err != nil { |
| + return err |
| + } |
| + want := s.frameFiles(e) |
| + if len(frames) != len(want) { |
| + return fmt.Errorf("%s: example wrote %d frame documents, frameFiles names %d", e.Name, len(frames), len(want)) |
| + } |
| + for _, f := range want { |
| + doc, ok := frames[f] |
| + if !ok { |
| + return fmt.Errorf("%s: frameFiles names %s, which example did not write", e.Name, f) |
| } |
| - rel := dir + "frames/" + e.Name + "/" + st.ID + ".html" |
| - if err := s.put(rel, []byte(frame)); err != nil { |
| + if err := s.put(dir+f, doc); err != nil { |
| return err |
| } |
| - src := doc |
| - if !e.Document { |
| - if src, err = example.Markup(s.src, e.Name, st.ID); err != nil { |
| - return err |
| - } |
| - } |
| - routes := "" |
| - if len(st.SampleURLs) > 0 { |
| - routes = say("gallery.routes", "routes", strings.Join(st.SampleURLs, ", ")) |
| - } |
| - states = append(states, state{ |
| - ID: st.ID, |
| - Label: st.Label, |
| - Note: ex.States[st.ID], |
| - Frame: s.url(rel), |
| - Title: say("gallery.frame.title", "example", e.Name, "state", st.Label), |
| - Page: tall(e), |
| - Source: codeview.Highlight(codeview.Format(src)), |
| - Routes: routes, |
| - }) |
| } |
| - b.Reset() |
| - if err := exampleTemplate.Execute(&b, struct { |
| - page |
| - Family family |
| - Entry example.Entry |
| - Blurb string |
| - States []state |
| - }{p, s.familyOf(e), e, ex.Blurb, states}); err != nil { |
| + p.Body = template.HTML(body) |
| + if err := s.write(dir, p); err != nil { |
| return err |
| } |
| - if err := s.write(p, e.Name+".html", e.Name, "examples", b.String()); err != nil { |
| + } |
| + extra, err := s.extraDocuments(theme, locale) |
| + if err != nil { |
| + return err |
| + } |
| + names := s.extraDocumentNames() |
| + if len(extra) != len(names) { |
| + return fmt.Errorf("extraDocuments wrote %d documents, extraDocumentNames lists %d", len(extra), len(names)) |
| + } |
| + for _, n := range names { |
| + doc, ok := extra[n] |
| + if !ok { |
| + return fmt.Errorf("extraDocumentNames lists %s, which extraDocuments did not write", n) |
| + } |
| + if err := s.put(dir+n, doc); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| -// familyOf is the family e sits in. |
| -func (s *site) familyOf(e example.Entry) family { |
| - for _, f := range s.families { |
| - if f.Key == e.Family { |
| - return f |
| - } |
| - } |
| - return family{Key: e.Family} |
| -} |
| +// extraDocuments is every document of one theme and locale that is |
| +// neither a page kind's, an example's nor a frame's, keyed by path |
| +// under the directory. Nothing yet: the demo application and the modal |
| +// route land here. |
| +func (s *site) extraDocuments(theme, locale string) (map[string][]byte, error) { return nil, nil } |
| -// tall says whether an example's frames are drawn as pages rather |
| -// than fitted to their content: a layout, a screen, a shell idiom, and |
| -// the modal, whose overlay covers whatever viewport it gets. |
| -func tall(e example.Entry) bool { |
| - return e.Document || e.Kind == "screen" || strings.HasPrefix(e.Name, "shell-") || e.Name == "modal" |
| -} |
| +// extraDocumentNames lists the paths extraDocuments writes, so the |
| +// tree gate expects them. |
| +func (s *site) extraDocumentNames() []string { return nil } |
| -// write executes the page template around body and puts the result at |
| -// file within p's theme directory. |
| -func (s *site) write(p page, file, name, current, body string) error { |
| - p.File, p.Name, p.Current, p.Body = file, name, current, template.HTML(body) |
| +// write executes the page template around p and puts the result at |
| +// p.File within dir. |
| +func (s *site) write(dir string, p page) error { |
| + t, err := s.templates(p.Locale) |
| + if err != nil { |
| + return err |
| + } |
| var b bytes.Buffer |
| - if err := pageTemplate.Execute(&b, p); err != nil { |
| + if err := t.ExecuteTemplate(&b, "ds-page", p); err != nil { |
| return err |
| } |
| - return s.put(p.dir(p.Theme, p.RTL)+file, b.Bytes()) |
| -} |
| - |
| -var ( |
| - headTag = regexp.MustCompile(`<head\b[^>]*>`) |
| - firstLink = regexp.MustCompile(`<(link|script)\b`) |
| - bodyTag = regexp.MustCompile(`<body\b[^>]*>`) |
| - assetLink = regexp.MustCompile(`(href|src)="\.\./\.\./(css|js)/([^"]+)"`) |
| - picture = regexp.MustCompile(`src="\.\./assets/([^"]+)"`) |
| - sampleRef = regexp.MustCompile(`\b(href|action)="([^"]*)"`) |
| - ltrHTML = `<html lang="en" dir="ltr"` |
| -) |
| - |
| -// frame turns one example document into a frame document: the |
| -// gallery's assets in place of the relative links, the page's theme in |
| -// place of the example's, frame.js first in the head, every href and |
| -// action in the body pointed at "#", and the right-to-left attributes |
| -// when the twin is being rendered. The example's title and everything |
| -// else in it are kept. |
| -func (s *site) frame(doc, theme string, rtl bool) (string, error) { |
| - head := headTag.FindStringIndex(doc) |
| - body := bodyTag.FindStringIndex(doc) |
| - if head == nil || body == nil || body[0] < head[1] { |
| - return "", errors.New("no <head> and <body> to rewrite") |
| - } |
| - // frame.js goes before the first stylesheet or script of the head, |
| - // after the charset and viewport metas, which stay first. |
| - at := firstLink.FindStringIndex(doc[head[1]:body[0]]) |
| - if at == nil { |
| - return "", errors.New("no stylesheet in the head to put frame.js before") |
| - } |
| - at[0] += head[1] |
| - top := doc[:at[0]] + `<script src="` + s.url("assets/frame.js") + `"></script>` + "\n" + doc[at[0]:body[1]] |
| - top = assetLink.ReplaceAllStringFunc(top, func(m string) string { |
| - sub := assetLink.FindStringSubmatch(m) |
| - file := sub[2] + "/" + sub[3] |
| - if strings.HasPrefix(file, "css/themes/") { |
| - file = "css/themes/" + theme + ".css" |
| - } |
| - return sub[1] + `="` + s.url("assets/"+file) + `"` |
| - }) |
| - top = picture.ReplaceAllString(top, `src="`+s.url("assets/images/")+`$1"`) |
| - if rtl { |
| - if !strings.Contains(top, ltrHTML) { |
| - return "", fmt.Errorf("no %s to turn around", ltrHTML) |
| - } |
| - top = strings.Replace(top, ltrHTML, `<html lang="ar" dir="rtl"`, 1) |
| - } |
| - rest := doc[body[1]:] |
| - rest = picture.ReplaceAllString(rest, `src="`+s.url("assets/images/")+`$1"`) |
| - rest = sampleRef.ReplaceAllStringFunc(rest, func(m string) string { |
| - sub := sampleRef.FindStringSubmatch(m) |
| - if strings.HasPrefix(sub[2], "#") { |
| - return m |
| - } |
| - return sub[1] + `="#"` |
| - }) |
| - return top + rest, nil |
| + return s.put(dir+p.File, b.Bytes()) |
| } |
diff --git a/internal/gallery/gallery.js b/internal/gallery/gallery.js| index 8db462f..6097003 100644 |
| --- a/internal/gallery/gallery.js |
| +++ b/internal/gallery/gallery.js |
| @@ -1,115 +1,7 @@ |
| -/* gallery.js: the gallery page's own script, and no part of the design |
| - system. It is the frame-painting half of rastrillo's gallery script, |
| - and nothing else: the scheme itself is fiesta.js's, from the scheme |
| - menu in the header, and this file only carries what fiesta.js |
| - decided into every preview frame. |
| - |
| - Every example on the page is a document of its own inside an |
| - iframe, and an iframe does not inherit the reader's choice: a colour |
| - scheme is not propagated into an embedded document that declares |
| - one, and every preview links a theme that does. The frames are |
| - same-origin, so the fix is the attribute fiesta.js wrote on this |
| - page's <html>, written again on each of them: at the frame's load, |
| - and on every rst-scheme event after. frame.js, first in each frame's |
| - head, does the same for itself before its first paint, so a frame |
| - loaded after the choice starts in it. |
| - |
| - A frame is hidden by gallery.css until this file marks it painted, |
| - which is why this is a blocking script in <head>: the marker that |
| - turns the hiding rule on has to be there before the first frame can |
| - paint in the wrong scheme. With scripts off the marker is never |
| - set, nothing is hidden, and the frames show as they come, in the |
| - OS's scheme, which is what System means anyway. */ |
| -(function () { |
| - "use strict"; |
| - |
| - var root = document.documentElement; |
| - root.setAttribute("data-gallery-js", "on"); |
| - |
| - // The scheme in force on this page: fiesta.js keeps it on <html>, |
| - // and no attribute is System. |
| - function scheme() { |
| - return root.getAttribute("data-theme"); |
| - } |
| - |
| - function readable(frame) { |
| - try { |
| - var doc = frame.contentDocument; |
| - return doc && doc.documentElement && doc.URL !== "about:blank" ? doc : null; |
| - } catch (e) { |
| - return null; |
| - } |
| - } |
| - |
| - function paint(frame, s) { |
| - var doc = readable(frame); |
| - if (!doc) return; |
| - if (s === "light" || s === "dark") doc.documentElement.setAttribute("data-theme", s); |
| - else doc.documentElement.removeAttribute("data-theme"); |
| - } |
| - |
| - // A component's frame is as tall as its document, between a floor |
| - // and a ceiling; a page's frame keeps the stylesheet's height, since |
| - // a page lays itself out to whatever viewport it gets. Measured off |
| - // the body's box rather than scrollHeight, which is never less than |
| - // the frame it is measured in. |
| - function fit(frame) { |
| - var doc = readable(frame); |
| - if (!doc || !doc.body || frame.classList.contains("gallery-frame--page")) return; |
| - var body = doc.body, style = doc.defaultView.getComputedStyle(body); |
| - var h = body.getBoundingClientRect().bottom + (parseFloat(style.marginBottom) || 0) + 16; |
| - frame.style.blockSize = Math.min(Math.max(Math.round(h), 160), 720) + "px"; |
| - } |
| - |
| - // Shown even if it could not be painted: a frame in the wrong scheme |
| - // beats one that never appears. |
| - function shown(frame) { |
| - paint(frame, scheme()); |
| - fit(frame); |
| - frame.setAttribute("data-gallery-painted", ""); |
| - } |
| - |
| - function frames() { |
| - return document.querySelectorAll(".gallery-frame"); |
| - } |
| - |
| - function all(s) { |
| - var f = frames(); |
| - for (var i = 0; i < f.length; i++) paint(f[i], s); |
| - } |
| - |
| - // A frame's document is painted and shown at its load, or at once |
| - // when it was already complete before this ran (a frame served from |
| - // cache, or a page whose frames loaded before DOMContentLoaded). |
| - function watch(frame) { |
| - frame.addEventListener("load", function () { |
| - shown(frame); |
| - }); |
| - var doc = readable(frame); |
| - if (doc && doc.readyState === "complete") shown(frame); |
| - } |
| - |
| - // fiesta.js dispatches rst-scheme from the form after it has written |
| - // the attribute, so the detail and the root agree; the detail is |
| - // read all the same, so this file never depends on the order. |
| - document.addEventListener("rst-scheme", function (event) { |
| - var s = event.detail && event.detail.scheme; |
| - all(s === "light" || s === "dark" ? s : null); |
| - }); |
| - |
| - // A page back from the back/forward cache is reactivated, not |
| - // re-run, and fiesta.js may have restored a stored choice since. |
| - addEventListener("pageshow", function (event) { |
| - if (event.persisted) all(scheme()); |
| - }); |
| - |
| - function ready(fn) { |
| - if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); |
| - else fn(); |
| - } |
| - |
| - ready(function () { |
| - var f = frames(); |
| - for (var i = 0; i < f.length; i++) watch(f[i]); |
| - }); |
| -})(); |
| +/* gallery.js: the gallery page's own script, the chrome's half, and no |
| + part of the design system. It is served joined with view.js, the |
| + preview widget's half, as the one gallery.js every page loads |
| + blocking in <head>. The rail filter, keeping your place across a |
| + theme or language switch, following a moved section's old link and |
| + the scheme bootstrap belong here; today the file is this comment, |
| + and the frame painting is view.js's. */ |
diff --git a/internal/gallery/gallery_test.go b/internal/gallery/gallery_test.go| index 510c1d4..d3b8c62 100644 |
| --- a/internal/gallery/gallery_test.go |
| +++ b/internal/gallery/gallery_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "regexp" |
| "sort" |
| "strings" |
| + "sync" |
| "testing" |
| xhtml "golang.org/x/net/html" |
| @@ -14,16 +15,35 @@ import ( |
| "amadan.net/rastrillo/fiesta/example" |
| ) |
| -// render is one tree at one mount, failed loudly. |
| +// render is one tree at one mount, failed loudly. Render is |
| +// deterministic and a tree is thirty-six directories, so one render |
| +// per mount serves every test in the package; each gets its own copy |
| +// of the map, so a test that adds files beside the tree changes |
| +// nothing another reads. |
| func render(t *testing.T, mount string) map[string][]byte { |
| t.Helper() |
| - files, err := Render(fiesta.FS(), Options{Mount: mount}) |
| - if err != nil { |
| - t.Fatalf("Render: %v", err) |
| + renderMu.Lock() |
| + defer renderMu.Unlock() |
| + files, ok := rendered[mount] |
| + if !ok { |
| + var err error |
| + if files, err = Render(fiesta.FS(), Options{Mount: mount}); err != nil { |
| + t.Fatalf("Render: %v", err) |
| + } |
| + rendered[mount] = files |
| + } |
| + out := make(map[string][]byte, len(files)) |
| + for name, body := range files { |
| + out[name] = body |
| } |
| - return files |
| + return out |
| } |
| +var ( |
| + renderMu sync.Mutex |
| + rendered = map[string]map[string][]byte{} |
| +) |
| + |
| func manifest(t *testing.T) *example.Manifest { |
| t.Helper() |
| m, err := example.Load(fiesta.FS()) |
| @@ -33,11 +53,6 @@ func manifest(t *testing.T) *example.Manifest { |
| return m |
| } |
| -// directions are the two trees every theme is rendered as: the |
| -// left-to-right one at the theme's root, and its right-to-left twin |
| -// under rtl/. |
| -var directions = []string{"", "rtl/"} |
| - |
| // attrsOf walks doc and calls fn for every element with its lower-case |
| // tag name and attributes, so a test reads URLs the way a browser |
| // would rather than with a regexp over the bytes. |
| @@ -84,38 +99,42 @@ func galleryURLs(t *testing.T, doc []byte) []string { |
| return out |
| } |
| -// TestRenderWritesEveryStateInEveryTheme: the tree holds one frame |
| -// document per manifest state, per theme, in both directions, and no |
| -// other frame; and the gallery page, the index, the tokens and the |
| -// icons pages beside them. |
| +// TestRenderWritesEveryStateInEveryTheme: the tree holds every frame |
| +// document frameFiles names for every example, per theme and locale, |
| +// and no other frame; and the gallery page, the index, the tokens and |
| +// the icons pages beside them. Preservation: part 3's test, walking |
| +// Locales in place of the two directions and counting frames through |
| +// the frameFiles seam. |
| func TestRenderWritesEveryStateInEveryTheme(t *testing.T) { |
| files := render(t, "") |
| - m := manifest(t) |
| - states := 0 |
| + s := newTestSite(t, fiesta.FS(), "") |
| + m := s.m |
| + perDir := 0 |
| for _, theme := range fiesta.ThemeNames() { |
| - for _, dir := range directions { |
| + for _, locale := range Locales { |
| + dir := theme + "/" + locale + "/" |
| for _, page := range []string{"index.html", "tokens.html", "icons.html"} { |
| - if _, ok := files[theme+"/"+dir+page]; !ok { |
| - t.Errorf("no %s/%s%s", theme, dir, page) |
| + if _, ok := files[dir+page]; !ok { |
| + t.Errorf("no %s%s", dir, page) |
| } |
| } |
| for _, e := range m.Examples { |
| - if _, ok := files[theme+"/"+dir+e.Name+".html"]; !ok { |
| - t.Errorf("no gallery page %s/%s%s.html", theme, dir, e.Name) |
| + if _, ok := files[dir+e.Name+".html"]; !ok { |
| + t.Errorf("no gallery page %s%s.html", dir, e.Name) |
| } |
| - for _, s := range e.States { |
| - if theme == fiesta.ThemeNames()[0] && dir == "" { |
| - states++ |
| + for _, f := range s.frameFiles(e) { |
| + if theme == fiesta.ThemeNames()[0] && locale == "en" { |
| + perDir++ |
| } |
| - if _, ok := files[theme+"/"+dir+"frames/"+e.Name+"/"+s.ID+".html"]; !ok { |
| - t.Errorf("no frame %s/%sframes/%s/%s.html", theme, dir, e.Name, s.ID) |
| + if _, ok := files[dir+f]; !ok { |
| + t.Errorf("no frame %s%s", dir, f) |
| } |
| } |
| } |
| } |
| } |
| - if states == 0 { |
| - t.Fatal("the manifest lists no states; the loop above checked nothing") |
| + if perDir == 0 { |
| + t.Fatal("frameFiles names no document; the loop above checked nothing") |
| } |
| frames := 0 |
| for name := range files { |
| @@ -123,8 +142,8 @@ func TestRenderWritesEveryStateInEveryTheme(t *testing.T) { |
| frames++ |
| } |
| } |
| - if want := states * len(fiesta.ThemeNames()) * len(directions); frames != want { |
| - t.Errorf("the tree holds %d frame documents, want %d (%d states x %d themes x %d directions)", frames, want, states, len(fiesta.ThemeNames()), len(directions)) |
| + if want := perDir * len(fiesta.ThemeNames()) * len(Locales); frames != want { |
| + t.Errorf("the tree holds %d frame documents, want %d (%d per directory x %d themes x %d locales)", frames, want, perDir, len(fiesta.ThemeNames()), len(Locales)) |
| } |
| if _, ok := files["index.html"]; !ok { |
| t.Error("no index.html at the tree root") |
| @@ -189,10 +208,14 @@ func htmlTab(t *testing.T, page []byte, name, state string) string { |
| // TestSampleURLsStayInTheMarkup: the routes a sample stands for are |
| // shown as written in its HTML tab, where a reader copies them from, |
| // and none of them is the href or action of anything in a frame |
| -// document, where a click or a submit would otherwise request it. A |
| -// frame's body carries no link of the gallery's own, so every href and |
| -// action in one is a fragment; rendered at a mount, so an absolute |
| -// route could not pass as the gallery's. |
| +// document, where a click or a submit would otherwise request it. In a |
| +// frame's body an href or action is either a fragment or a link the |
| +// gallery owns, marked data-gallery-nav and under the mount, which |
| +// TestEveryGalleryURLResolves then resolves; never a sample route. |
| +// Rendered at a mount, so an absolute route could not pass as the |
| +// gallery's. Preservation: part 3's test on day/en, with the one rule |
| +// the live shell demos need learnt; its control stays the sample-route |
| +// case. |
| func TestSampleURLsStayInTheMarkup(t *testing.T) { |
| const mount = "/x" |
| files := render(t, mount) |
| @@ -201,7 +224,7 @@ func TestSampleURLsStayInTheMarkup(t *testing.T) { |
| routes := map[string]bool{} |
| tabs := 0 |
| for _, e := range m.Examples { |
| - page, ok := files[theme+"/"+e.Name+".html"] |
| + page, ok := files[theme+"/en/"+e.Name+".html"] |
| if !ok { |
| t.Fatalf("no gallery page for %s", e.Name) |
| } |
| @@ -246,7 +269,11 @@ func TestSampleURLsStayInTheMarkup(t *testing.T) { |
| if routes[v] { |
| t.Errorf("%s: <%s %s=%q> would request a sample route", name, tag, attr, v) |
| } |
| - if !strings.HasPrefix(v, "#") { |
| + if _, nav := attrs["data-gallery-nav"]; nav { |
| + if !strings.HasPrefix(v, mount+"/") { |
| + t.Errorf("%s: <%s %s=%q> is the gallery's and not under the mount", name, tag, attr, v) |
| + } |
| + } else if !strings.HasPrefix(v, "#") { |
| t.Errorf("%s: <%s %s=%q> is not a fragment", name, tag, attr, v) |
| } |
| } |
| @@ -296,10 +323,12 @@ func TestTheGalleryCarriesNoPlaceholderCopy(t *testing.T) { |
| } |
| } |
| -// TestRTLDocumentsReadRightToLeft: every document under a theme's rtl/ |
| +// TestRTLDocumentsReadRightToLeft: every document under a theme's ar/ |
| // says lang="ar" dir="rtl" on its <html>, gallery pages and frames |
| -// alike, and every other document says lang="en" dir="ltr". The axe |
| -// leg over the twin would otherwise scan left-to-right copies. |
| +// alike, and every document under en/ says lang="en" dir="ltr". The |
| +// axe leg over the Arabic tree would otherwise scan left-to-right |
| +// copies. Preservation: part 3's test reading day/ar/ in place of the |
| +// rtl/ twin. |
| func TestRTLDocumentsReadRightToLeft(t *testing.T) { |
| files := render(t, "") |
| rtl, ltr := 0, 0 |
| @@ -312,12 +341,16 @@ func TestRTLDocumentsReadRightToLeft(t *testing.T) { |
| t.Errorf("%s has no <html> tag", name) |
| continue |
| } |
| - want := `lang="en" dir="ltr"` |
| - if strings.Contains(name, "/"+rtlDir) { |
| + var want string |
| + switch { |
| + case strings.Contains(name, "/ar/"): |
| want = `lang="ar" dir="rtl"` |
| rtl++ |
| - } else { |
| + case strings.Contains(name, "/en/"): |
| + want = `lang="en" dir="ltr"` |
| ltr++ |
| + default: |
| + continue |
| } |
| if !strings.Contains(string(tag), want) { |
| t.Errorf("%s: %s does not say %s", name, tag, want) |
| @@ -360,14 +393,16 @@ var declPattern = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:\s*([^;]+);`) |
| // --rst-surface, in every theme and both schemes, resolved from the |
| // themes' own light-dark() pairs. The axe scan checks the rendered |
| // cascade; this checks every pair, including the ones a page with no |
| -// such token on it would never show axe. |
| +// such token on it would never show axe. Preservation: the five lines |
| +// are read from view.css, the widget's half of the stylesheet, where |
| +// the code-block rules now live. |
| func TestHighlightColoursPassAgainstTheCodeBackground(t *testing.T) { |
| pairs := map[string]string{} |
| - for _, m := range highlightRule.FindAllStringSubmatch(string(galleryCSS), -1) { |
| + for _, m := range highlightRule.FindAllStringSubmatch(string(viewCSS), -1) { |
| pairs[m[1]] = m[2] |
| } |
| if len(pairs) != 5 { |
| - t.Fatalf("gallery.css colours %d highlight elements, want 5: %v", len(pairs), pairs) |
| + t.Fatalf("view.css colours %d highlight elements, want 5: %v", len(pairs), pairs) |
| } |
| for _, theme := range fiesta.ThemeNames() { |
| raw, _ := fiesta.ThemeCSS(theme) |
diff --git a/internal/gallery/galleryrig/rig.go b/internal/gallery/galleryrig/rig.go| new file mode 100644 |
| index 0000000..6b97597 |
| --- /dev/null |
| +++ b/internal/gallery/galleryrig/rig.go |
| @@ -0,0 +1,437 @@ |
| +//go:build browser |
| + |
| +// Package galleryrig is the gallery's shared drive equipment: the |
| +// rendered tree served at its mount, frames waited on until each |
| +// shows its own document, and the other conditions the gallery's |
| +// browser tests and the history package both need. A package rather |
| +// than test files because two test packages use it; browser-tagged |
| +// like harness, so chromedp stays out of the ordinary build graph. It |
| +// imports nothing of the gallery's: the gallery's own tests import it, |
| +// and an import back would be a cycle, so the rendered files are |
| +// handed in. It is Rastrillo's galleryrig over Fiesta's harness, the |
| +// names kept; where the two harnesses differ (Fiesta's Rig flags every |
| +// response short of a 2xx and every console error itself, and takes |
| +// Allow and AllowText for the expected ones) the harness's own |
| +// facilities are used. |
| +package galleryrig |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "path" |
| + "sort" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/cdproto/page" |
| + "github.com/chromedp/cdproto/runtime" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// Tree serves a rendered tree at the mount its pages expect, and fails |
| +// the test that uses it if anything asked for a file the tree does not |
| +// have. Every URL in a page is absolute under the mount, so serving the |
| +// tree anywhere else would 404 the stylesheet and a drive would measure |
| +// an unstyled page; and a preview frame naming a missing file loads a |
| +// 404 page that settles, measures and scans like any other document, so |
| +// the miss has to be counted here. |
| +// |
| +// The failure is reported from t.Cleanup rather than from the request: |
| +// lazy frames can still be loading after the test function returns, |
| +// and t.Errorf from a server goroutine after that panics. |
| +func Tree(t *testing.T, files map[string][]byte, mountPrefix string) http.Handler { |
| + t.Helper() |
| + var mu sync.Mutex |
| + var missing []string |
| + t.Cleanup(func() { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if len(missing) > 0 { |
| + t.Errorf("the tree was asked for %d file(s) it does not have: %v", len(missing), missing) |
| + } |
| + }) |
| + return TreeRecording(t, files, mountPrefix, func(p string) { |
| + mu.Lock() |
| + missing = append(missing, p) |
| + mu.Unlock() |
| + }) |
| +} |
| + |
| +// TreeRecording is Tree with each miss handed to the caller, so the |
| +// recording itself can be tested. |
| +func TreeRecording(t *testing.T, files map[string][]byte, mountPrefix string, miss func(path string)) http.Handler { |
| + t.Helper() |
| + types := map[string]string{ |
| + ".html": "text/html; charset=utf-8", |
| + ".css": "text/css; charset=utf-8", |
| + ".js": "text/javascript; charset=utf-8", |
| + ".svg": "image/svg+xml", |
| + } |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + // The browser asks for /favicon.ico on its own; nothing outside |
| + // the mount is the tree's to answer. |
| + if !strings.HasPrefix(r.URL.Path, mountPrefix) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + name := strings.TrimPrefix(r.URL.Path, mountPrefix) |
| + body, ok := files[name] |
| + if !ok { |
| + miss(r.URL.Path) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if ct, ok := types[path.Ext(name)]; ok { |
| + w.Header().Set("Content-Type", ct) |
| + } |
| + w.Write(body) |
| + }) |
| +} |
| + |
| +// SettleFrames turns every preview frame eager and reports, for each |
| +// one, the address its src names (a fresh frame is about:blank for a |
| +// moment before its file replaces it) and, once that document is |
| +// complete and populated, its height, or null while it is not there |
| +// yet. It also reports how many frame documents the page requested, |
| +// which is the cost a reader pays for reading the whole page. |
| +// |
| +// One synchronous read rather than an in-page wait loop: the "scripts |
| +// off" leg proves the widget needs no script by disabling script |
| +// execution for the whole page, and Blink still runs a |
| +// debugger-injected evaluate under that, but stops servicing the |
| +// timers an async/await loop would need. Eagerly polls by calling this |
| +// repeatedly from Go instead, which needs nothing running inside the |
| +// page between calls. |
| +const SettleFrames = `(() => { |
| + const frames = [...document.querySelectorAll(".ds-view__frame")]; |
| + frames.forEach(f => { f.loading = "eager"; }); |
| + const heights = frames.map(f => { |
| + let d = null; |
| + try { d = f.contentDocument; } catch (e) { return null; } |
| + if (!d || d.URL !== f.src || d.readyState !== "complete" || !d.body || !d.body.children.length) return null; |
| + return d.body.scrollHeight; |
| + }); |
| + const requested = performance.getEntriesByType("resource").filter(e => e.initiatorType === "iframe").length; |
| + return JSON.stringify({Titles: frames.map(f => f.title), Heights: heights, Requested: requested}); |
| +})()` |
| + |
| +// Eagerly loads every frame on the page and waits until each settles: |
| +// complete, populated, and the same height across 150ms. Measuring |
| +// what a reader sees means reaching inside each frame, and a frame |
| +// that never loaded reports zero height, which reads exactly like a |
| +// collapsed preview: so a frame that does not settle within 30s fails |
| +// the leg by name rather than being measured. |
| +func Eagerly(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + type reading struct { |
| + Titles []string |
| + Heights []*int |
| + Requested int |
| + } |
| + read := func() reading { |
| + t.Helper() |
| + var raw string |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(SettleFrames, &raw)); err != nil { |
| + t.Fatalf("%s: loading the framed documents: %v", where, err) |
| + } |
| + var got reading |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("%s: reading the frame settle (%q): %v", where, raw, err) |
| + } |
| + return got |
| + } |
| + |
| + before := read() |
| + pending := make([]bool, len(before.Titles)) |
| + for i := range pending { |
| + pending[i] = true |
| + } |
| + deadline := time.Now().Add(30 * time.Second) |
| + for { |
| + still := false |
| + for _, p := range pending { |
| + still = still || p |
| + } |
| + if !still { |
| + t.Logf("%s: %d frames settled; the page requested %d frame documents", where, len(before.Titles), before.Requested) |
| + return |
| + } |
| + if !time.Now().Before(deadline) { |
| + var names []string |
| + for i, p := range pending { |
| + if p { |
| + names = append(names, before.Titles[i]) |
| + } |
| + } |
| + t.Fatalf("%s: %d of %d frames never settled within 30s: %v", where, len(names), len(before.Titles), names) |
| + } |
| + time.Sleep(150 * time.Millisecond) |
| + after := read() |
| + for i, p := range pending { |
| + if !p { |
| + continue |
| + } |
| + if before.Heights[i] == nil || after.Heights[i] == nil || *after.Heights[i] != *before.Heights[i] { |
| + continue |
| + } |
| + pending[i] = false |
| + } |
| + before = after |
| + } |
| +} |
| + |
| +// SettleMobile waits after every Mobile radio has been clicked: every |
| +// frame laid out no wider than a phone, and every frame's width and |
| +// content height the same across 150ms. |
| +const SettleMobile = `(async () => { |
| + const frames = [...document.querySelectorAll(".ds-view__frame")]; |
| + const read = () => frames.map(f => { |
| + let h = -1; |
| + try { h = f.contentDocument.body.scrollHeight; } catch (e) {} |
| + return f.offsetWidth + ":" + h; |
| + }).join(","); |
| + const deadline = Date.now() + 30000; |
| + while (Date.now() < deadline) { |
| + const a = read(); |
| + await new Promise(r => setTimeout(r, 150)); |
| + if (a === read() && frames.every(f => f.offsetWidth <= 390)) return "ok"; |
| + } |
| + return "never settled: " + frames.filter(f => f.offsetWidth > 390).map(f => f.title).slice(0, 5).join("; "); |
| +})()` |
| + |
| +// MobileSettle runs SettleMobile and fails the leg if it times out. |
| +func MobileSettle(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + var state string |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(SettleMobile, &state, awaitPromise)); err != nil { |
| + t.Fatalf("%s: waiting for the Mobile layout: %v", where, err) |
| + } |
| + if state != "ok" { |
| + t.Fatalf("%s: the frames %s", where, state) |
| + } |
| +} |
| + |
| +func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) } |
| + |
| +// AddInit runs js in every document the tab loads, before the page's |
| +// own scripts: the only way to stand in for an API a script reads at |
| +// DOMContentLoaded, such as the clipboard or a storage that throws. |
| +func AddInit(js string) chromedp.Action { |
| + return chromedp.ActionFunc(func(ctx context.Context) error { |
| + _, err := page.AddScriptToEvaluateOnNewDocument(js).Do(ctx) |
| + return err |
| + }) |
| +} |
| + |
| +// Until polls expr until it is true, for ten seconds, and fails the leg |
| +// naming it otherwise. Polling rather than waiting on an event, because |
| +// what these legs wait for (a promise settling, a timer, a page restored |
| +// from the back/forward cache) raises nothing chromedp can wait on. An |
| +// evaluation that lands while one document is being swapped for the |
| +// next fails, and the next poll reads the new page; so an error is |
| +// retried, and one that lasts to the deadline fails the leg by name, as |
| +// a false would. |
| +func Until(t *testing.T, ctx context.Context, where, expr string) { |
| + t.Helper() |
| + deadline := time.Now().Add(10 * time.Second) |
| + var last error |
| + for { |
| + var ok bool |
| + err := chromedp.Run(ctx, chromedp.Evaluate(expr, &ok)) |
| + if err == nil && ok { |
| + return |
| + } |
| + if err != nil { |
| + last = err |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatalf("%s: never true within 10s (last error: %v): %s", where, last, expr) |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| + } |
| +} |
| + |
| +// ClickEvery is the script that clicks every radio of one tab ("d", |
| +// "m" or "h") and reports how many clicks moved something. It is the |
| +// instrument's own control: a reading taken after a click that did not |
| +// land is a reading of the state before it. |
| +func ClickEvery(mod string) string { |
| + return `(() => { |
| + let clicked = 0, moved = 0; |
| + document.querySelectorAll(".ds-view__tab--` + mod + ` input").forEach(i => { |
| + const before = i.checked; |
| + i.click(); |
| + clicked++; |
| + if (i.checked && !before) moved++; |
| + }); |
| + return JSON.stringify({clicked: clicked, moved: moved}); |
| +})()` |
| +} |
| + |
| +// NoScripts is a tab with the page's scripts switched off at the |
| +// engine. Evaluate still runs, through the debugger, so a leg can read |
| +// a scriptless page without the page having run anything. |
| +func NoScripts(t *testing.T, parent context.Context) context.Context { |
| + t.Helper() |
| + ctx, cancel := chromedp.NewContext(parent) |
| + t.Cleanup(cancel) |
| + if err := chromedp.Run(ctx, emulation.SetScriptExecutionDisabled(true)); err != nil { |
| + t.Fatalf("switching scripts off: %v", err) |
| + } |
| + return ctx |
| +} |
| + |
| +// RequireScriptsOff is NoScripts's own control, run once a page has |
| +// loaded on its tab: data-gallery-js is the one attribute the gallery's |
| +// script sets, and only after it runs, so finding it would mean this |
| +// leg's "scripts off" page ran scripts after all, and every reading |
| +// taken under it is a reading of the scripted page. Fiesta's mark, |
| +// where Rastrillo's is data-rst-js. |
| +func RequireScriptsOff(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + var on bool |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`document.documentElement.hasAttribute("data-gallery-js")`, &on)); err != nil { |
| + t.Fatalf("%s: reading data-gallery-js: %v", where, err) |
| + } |
| + if on { |
| + t.Fatalf("%s: data-gallery-js is set; the gallery's script ran even though this leg switched scripts off", where) |
| + } |
| +} |
| + |
| +// PhoneRig is a browser whose primary pointer is a touch screen, |
| +// serving what tree builds. It is a launch flag: CDP's touch emulation |
| +// leaves (pointer: coarse) false in this engine. A leg that checks |
| +// anything against the page's width passes harness.WithScrollbars too: |
| +// see RequireDrawnScrollbar. |
| +func PhoneRig(t *testing.T, tree func() http.Handler, opts ...harness.Option) *harness.Rig { |
| + t.Helper() |
| + return harness.New(t, func(string) http.Handler { return tree() }, append([]harness.Option{harness.WithCoarsePointer()}, opts...)...) |
| +} |
| + |
| +// RequireDrawnScrollbar is the control for any leg that measures a box |
| +// against the document's width, and it belongs on a rig built with |
| +// harness.WithScrollbars. Under chromedp's default --hide-scrollbars, |
| +// Chromium still reserves the root's stable gutter (15px) in layout but |
| +// leaves it out of documentElement.clientWidth, and innerWidth includes |
| +// it either way. A box that really spans the page then reads 15px short |
| +// of a "document" it can never reach, and a sideways check reads -15, |
| +// so it would miss an overflow of up to 15px. With the scrollbar drawn, |
| +// clientWidth and layout agree. |
| +func RequireDrawnScrollbar(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + var gutter float64 |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`innerWidth - document.documentElement.clientWidth`, &gutter)); err != nil { |
| + t.Fatalf("%s: measuring the scrollbar: %v", where, err) |
| + } |
| + if gutter <= 0 { |
| + t.Fatalf("%s: the root's scrollbar takes %.0fpx, so this browser draws none and documentElement.clientWidth is not the width the page lays out in. Build the rig with harness.WithScrollbars", where, gutter) |
| + } |
| +} |
| + |
| +// RequireCoarse is every touch leg's control: a leg running on a fine |
| +// pointer would measure desktop sizes and pass. |
| +func RequireCoarse(t *testing.T, ctx context.Context) { |
| + t.Helper() |
| + var coarse bool |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`matchMedia("(pointer: coarse)").matches`, &coarse)); err != nil { |
| + t.Fatalf("reading the pointer: %v", err) |
| + } |
| + if !coarse { |
| + t.Fatal("(pointer: coarse) is false; this rig is not the phone this leg claims to measure") |
| + } |
| +} |
| + |
| +// SettleMotion waits out every finite animation and any view |
| +// transition: the shell slides between the index and a page, and the |
| +// row the reader came back to flashes. axe reading either mid-flight |
| +// measures colours at partial opacity that no reader sees once it has |
| +// landed. |
| +func SettleMotion(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`(async () => { |
| + if (document.activeViewTransition) await document.activeViewTransition.finished.catch(() => null); |
| + await Promise.all(document.getAnimations() |
| + .filter(a => a.effect && a.effect.getComputedTiming().endTime !== Infinity) |
| + .map(a => a.finished.catch(() => null))); |
| + return true; |
| + })()`, nil, awaitPromise)); err != nil { |
| + t.Fatalf("%s: waiting for motion to end: %v", where, err) |
| + } |
| +} |
| + |
| +// MeasureFrames reads every frame on the page: section id → [what its |
| +// document needs, what its box gives it, 1 if it frames a sidebar-shell |
| +// page]. The tallest state of a section is the one recorded, so one |
| +// number per section keeps the boxes down a column the same size. |
| +const MeasureFrames = `(() => { |
| + const out = {}; |
| + for (const f of document.querySelectorAll(".ds-view__frame")) { |
| + const section = f.closest("article, section"); |
| + const id = section ? section.id : "?"; |
| + const d = f.contentDocument; |
| + const need = d ? Math.ceil(Math.max(d.body.getBoundingClientRect().height, d.body.scrollHeight)) : -1; |
| + const box = Math.round(parseFloat(getComputedStyle(f).height)); |
| + const shell = d && d.querySelector("[rst-shell-sidebar]") ? 1 : 0; |
| + const was = out[id]; |
| + if (!was || need > was[0]) out[id] = [need, box, shell]; |
| + } |
| + return JSON.stringify(out); |
| +})()` |
| + |
| +// ReadFrames decodes one MeasureFrames reading: section id → [what its |
| +// document needs, its box, 1 for a sidebar-shell page]. It judges no |
| +// height, so a sweep can gather every language's readings before it |
| +// asserts anything; it fails only on what makes a reading worthless: a |
| +// reading that does not decode, a page with no rendered example, or a |
| +// frame with no document in it. |
| +func ReadFrames(t *testing.T, tab, raw string) map[string][3]int { |
| + t.Helper() |
| + var got map[string][3]int |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("%s: reading the measurements: %v", tab, err) |
| + } |
| + if len(got) == 0 { |
| + t.Fatalf("%s: no section on this page has a rendered example at all; either the page rendered none, or no frame on it loaded", tab) |
| + } |
| + for id, r := range got { |
| + if r[0] < 0 { |
| + t.Errorf("%s %s: the frame has no document in it", tab, id) |
| + } |
| + } |
| + return got |
| +} |
| + |
| +// Measured is ReadFrames with each section held to its box. shellOnly |
| +// decides who gets 48px of grace: the sidebar shell's rail is 100dvh |
| +// tall, so a page framing one is always the frame's height plus the |
| +// margin under its content, and no box can fit it. The wide drive |
| +// grants the grace to every frame, as it always has. |
| +func Measured(t *testing.T, tab, raw string, shellOnly bool) map[string][3]int { |
| + t.Helper() |
| + got := ReadFrames(t, tab, raw) |
| + names := make([]string, 0, len(got)) |
| + for name := range got { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + for _, id := range names { |
| + need, box, shell := got[id][0], got[id][1], got[id][2] |
| + grace := 48 |
| + if shellOnly && shell == 0 { |
| + grace = 0 |
| + } |
| + if need >= 0 && need > box+grace { |
| + t.Errorf("%s %s: its document needs %dpx and its frame is %dpx; raise its height (previewHeights for Desktop, previewMobileHeights or previewMobileLocaleHeights for Mobile) to at least %d", tab, id, need, box, need+20) |
| + } |
| + if need >= 0 && box > need*4 && box-need > 120 { |
| + t.Logf("%s %s: %dpx of frame for %dpx of document; deliberate headroom, or a number to bring down", tab, id, box, need) |
| + } |
| + } |
| + return got |
| +} |
diff --git a/internal/gallery/hashed.go b/internal/gallery/hashed.go| new file mode 100644 |
| index 0000000..aedf100 |
| --- /dev/null |
| +++ b/internal/gallery/hashed.go |
| @@ -0,0 +1,51 @@ |
| +package gallery |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "path" |
| + "strings" |
| +) |
| + |
| +// Every asset is written twice: under name.<16 hex of its sha256>.ext, |
| +// which every page and frame links, and under its plain name, which |
| +// nothing links, for hotlinks and the site's own checks. The tree is |
| +// served by a static edge that caches assets, and with plain names a |
| +// deploy could pair a new page with yesterday's cached tokens.css, a |
| +// page of new markup drawn by old rules. A name that changes whenever |
| +// the bytes do cannot be served stale. |
| + |
| +// hashedName is the name an asset is linked by: its stem, sixteen hex |
| +// digits of the sha256 of its bytes, and its extension. |
| +func hashedName(name string, body []byte) string { |
| + sum := sha256.Sum256(body) |
| + ext := path.Ext(name) |
| + return strings.TrimSuffix(name, ext) + "." + hex.EncodeToString(sum[:8]) + ext |
| +} |
| + |
| +// putAsset writes one asset under assets/, plain and hashed, and |
| +// records the hashed name for the pages to link. |
| +func (s *site) putAsset(name string, body []byte) error { |
| + hashed := hashedName(name, body) |
| + s.hashed[name] = hashed |
| + if err := s.put("assets/"+name, body); err != nil { |
| + return err |
| + } |
| + return s.put("assets/"+hashed, append([]byte(nil), body...)) |
| +} |
| + |
| +// asset is the address a page links an asset by: the hashed name under |
| +// the mount. A name the tree does not ship panics, because it is a typo |
| +// in this package's own templates, and a page linking a file that is |
| +// not there renders unstyled with no other sign of it. |
| +func (s *site) asset(name string) string { |
| + hashed, ok := s.hashed[name] |
| + if !ok { |
| + panic(fmt.Sprintf("gallery: no asset %q in the tree", name)) |
| + } |
| + return s.url("assets/" + hashed) |
| +} |
| + |
| +// Asset is the URL of one of the gallery's assets, by its plain name. |
| +func (p page) Asset(name string) string { return p.s.asset(name) } |
diff --git a/internal/gallery/i18n.go b/internal/gallery/i18n.go| new file mode 100644 |
| index 0000000..41f29e9 |
| --- /dev/null |
| +++ b/internal/gallery/i18n.go |
| @@ -0,0 +1,103 @@ |
| +package gallery |
| + |
| +import ( |
| + "html/template" |
| + "sync" |
| + |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// The words a page says, in the page's language. English alone for |
| +// now: say reads the text table, proseIn returns the prose's English |
| +// and label the manifest's, and every lookup is traced, so the parity |
| +// gates have their hook from the first render. The catalogs that |
| +// answer for the other eleven locales are a later task's; the |
| +// signatures they fill are these. |
| + |
| +// say is the text filed under id, in locale, with each {name} replaced |
| +// by the value following it in args. |
| +func (s *site) say(locale, id string, args ...string) string { |
| + trace(id) |
| + return say(id, args...) |
| +} |
| + |
| +// proseIn is one prose string, identified by its slot id, in locale. |
| +// en is the English the prose holds for that slot; it is what the page |
| +// says until a catalog answers for the locale. The empty string in is |
| +// the empty string out: a state with no note stays without one. |
| +func (s *site) proseIn(locale, id, en string) string { |
| + if en == "" { |
| + return "" |
| + } |
| + trace(id) |
| + return en |
| +} |
| + |
| +// label is one state's label on the page, in locale: the manifest's |
| +// English until a catalog answers. |
| +func (s *site) label(locale string, e example.Entry, st example.State) string { |
| + trace("labels." + e.Name + "." + st.ID) |
| + return st.Label |
| +} |
| + |
| +// textOf resolves one page kind's title or blurb id: a gallery.* id |
| +// through say, any other through the prose. |
| +func (s *site) textOf(locale, id string, args ...string) string { |
| + if len(id) > len("gallery.") && id[:len("gallery.")] == "gallery." { |
| + return s.say(locale, id, args...) |
| + } |
| + return s.proseIn(locale, id, s.prose.english(id)) |
| +} |
| + |
| +// templates is every template constant parsed into one tree with say |
| +// bound to locale, built once per locale and kept for the render. |
| +func (s *site) templates(locale string) (*template.Template, error) { |
| + if t, ok := s.tmpl[locale]; ok { |
| + return t, nil |
| + } |
| + funcs := template.FuncMap{ |
| + "say": func(id string, args ...string) string { return s.say(locale, id, args...) }, |
| + "icon": s.iconMarkup, |
| + "anchor": anchorID, |
| + } |
| + t := template.New("gallery").Funcs(funcs) |
| + for _, src := range []string{pageTemplate, viewTemplate, overviewBody, familyBody, exampleBody, tokensBody, iconsBody} { |
| + if _, err := t.Parse(src); err != nil { |
| + return nil, err |
| + } |
| + } |
| + if s.tmpl == nil { |
| + s.tmpl = map[string]*template.Template{} |
| + } |
| + s.tmpl[locale] = t |
| + return t, nil |
| +} |
| + |
| +// The tracer. Every lookup the renderer makes is announced to traceFn |
| +// when one is installed, which is how a gate learns what the pages |
| +// asked for rather than what a list says they should. |
| +var ( |
| + traceMu sync.Mutex |
| + traceFn func(string) |
| +) |
| + |
| +func trace(id string) { |
| + traceMu.Lock() |
| + fn := traceFn |
| + traceMu.Unlock() |
| + if fn != nil { |
| + fn(id) |
| + } |
| +} |
| + |
| +// setTrace installs a tracer and returns the function that removes it. |
| +func setTrace(fn func(string)) func() { |
| + traceMu.Lock() |
| + traceFn = fn |
| + traceMu.Unlock() |
| + return func() { |
| + traceMu.Lock() |
| + traceFn = nil |
| + traceMu.Unlock() |
| + } |
| +} |
diff --git a/internal/gallery/icons_page.go b/internal/gallery/icons_page.go| new file mode 100644 |
| index 0000000..3780b74 |
| --- /dev/null |
| +++ b/internal/gallery/icons_page.go |
| @@ -0,0 +1,35 @@ |
| +package gallery |
| + |
| +import ( |
| + "fmt" |
| + "html/template" |
| +) |
| + |
| +// The Icons page: one chip per icon, its slug beside it, each the |
| +// rail links. The provenance under each chip, the renamed count and |
| +// the link to the set are a later task's. |
| +const iconsBody = `{{define "ds-body-icons"}}<p class="ds-lead">{{say "gallery.icons.lead" "total" .Total}}</p> |
| +<ul class="ds-toks ds-icons">{{range .Icons}} |
| +<li class="ds-tok" id="{{anchor "icon" .Slug}}" data-ds-anchor><span class="ds-chip ds-chip--icon">{{.Markup}}</span><span class="ds-tok__text rst-mono"><span class="ds-tok__name">{{.Slug}}</span><span class="ds-tok__value">icons/{{.Slug}}.svg</span></span></li>{{end}} |
| +</ul> |
| +{{end}}` |
| + |
| +// iconsBody renders the Icons page's body for p. |
| +func (s *site) iconsBody(p page) (template.HTML, error) { |
| + return s.body(p.Locale, "ds-body-icons", struct { |
| + page |
| + Icons []icon |
| + Total string |
| + }{p, s.icons, fmt.Sprint(len(s.icons))}) |
| +} |
| + |
| +// iconMarkup is one of Fiesta's icons, inline, for the gallery's own |
| +// furniture; the icon template func. |
| +func (s *site) iconMarkup(slug string) template.HTML { |
| + for _, ic := range s.icons { |
| + if ic.Slug == slug { |
| + return ic.Markup |
| + } |
| + } |
| + return "" |
| +} |
diff --git a/internal/gallery/keyboard_browser_test.go b/internal/gallery/keyboard_browser_test.go| index b7907d1..1ce7026 100644 |
| --- a/internal/gallery/keyboard_browser_test.go |
| +++ b/internal/gallery/keyboard_browser_test.go |
| @@ -212,7 +212,7 @@ func TestA11yWalksTheKeyboard(t *testing.T) { |
| var count int |
| mustRun(t, ctx, |
| chromedp.EmulateViewport(1280, 900), |
| - chromedp.Navigate(rig.Origin+href(frameFile(theme, false, d[0], d[1]))), |
| + chromedp.Navigate(rig.Origin+href(frameFile(theme, "en", d[0], d[1]))), |
| chromedp.WaitReady("body"), |
| chromedp.Evaluate(`document.querySelectorAll(`+focusablesJS+`).length`, &count), |
| ) |
| @@ -262,7 +262,7 @@ func TestA11yWalksTheKeyboard(t *testing.T) { |
| // The full circuit, on the document small enough to walk all of. |
| var modalCount int |
| - modal := frameFile(theme, false, "modal", mustDefault(t, "modal")) |
| + modal := frameFile(theme, "en", "modal", mustDefault(t, "modal")) |
| mustRun(t, ctx, |
| chromedp.Navigate(rig.Origin+href(modal)), |
| chromedp.WaitReady("body"), |
| @@ -317,11 +317,12 @@ func stageDocument(t *testing.T, m *example.Manifest) (name string, doc []byte) |
| t.Logf("the stage layout's default state already carries the signin default in its slot") |
| } |
| theme := fiesta.ThemeNames()[0] |
| - framed, err := (&site{mount: mountPath}).frame(slotted, theme, false) |
| + stage, _ := m.Entry("layout-stage") |
| + framed, err := newTestSite(t, fiesta.FS(), mountPath).frame(slotted, theme, "en", stage) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - return theme + "/frames/layout-stage/with-signin.html", []byte(framed) |
| + return theme + "/en/frames/layout-stage/with-signin.html", []byte(framed) |
| } |
| // TestA11yWalksTheStageShell is the keyboard walk on the page every |
diff --git a/internal/gallery/locale.go b/internal/gallery/locale.go| new file mode 100644 |
| index 0000000..2248121 |
| --- /dev/null |
| +++ b/internal/gallery/locale.go |
| @@ -0,0 +1,17 @@ |
| +package gallery |
| + |
| +// Locales are the languages the design system renders in: Fiesta's |
| +// own twelve, the ones its i18n/ string tables ship, with English |
| +// first because the tree root is the English Day rendering. Pages |
| +// live at <theme>/<locale>/; the Arabic tree is the right-to-left |
| +// rendering, and there is no twin directory beside it. |
| +var Locales = []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"} |
| + |
| +// Dir is the writing direction a locale's <html> declares: "rtl" for |
| +// Arabic, "ltr" for the rest. |
| +func Dir(locale string) string { |
| + if locale == "ar" { |
| + return "rtl" |
| + } |
| + return "ltr" |
| +} |
diff --git a/internal/gallery/nav.go b/internal/gallery/nav.go| new file mode 100644 |
| index 0000000..c66b40b |
| --- /dev/null |
| +++ b/internal/gallery/nav.go |
| @@ -0,0 +1,141 @@ |
| +package gallery |
| + |
| +import ( |
| + "html/template" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| +) |
| + |
| +// The rail's model: one section per page kind with the entries the |
| +// page anchors, read off the same data the page renders from, so an |
| +// entry pointing at nothing and a section with no way to reach it are |
| +// both impossible. The types and the three readers are here; the shell |
| +// that draws them, the section overview items, the current marks, the |
| +// search terms, the Demos and the phone index are the shell task's. |
| + |
| +// navItem is one line in the rail. Code marks an entry whose label is |
| +// an identifier (an example's name, an icon's slug), drawn in the mono |
| +// face. Blank marks an entry that leaves the page and opens in a new |
| +// tab. Aria is the accessible name where it has to differ from the |
| +// visible label; Terms the synonyms the filter matches beside it. |
| +type navItem struct { |
| + Label, Href string |
| + Code, Blank bool |
| + Aria, Terms string |
| +} |
| + |
| +// navSection is one group in the rail: a page of the directory, or |
| +// the Demos. Href is the section's own page and Current says whether |
| +// that page is the one being rendered. |
| +type navSection struct { |
| + Title, Href, Kind string |
| + Current bool |
| + Items []navItem |
| +} |
| + |
| +// indexRow is one row of the phone index: a section's page, by title, |
| +// carrying the id its pages' back controls name. |
| +type indexRow struct{ ID, Title, Href string } |
| + |
| +// localeLink is one entry of the language menu: the locale's code, its |
| +// name in its own language, its direction, this page in that locale, |
| +// and whether it is the page's own. |
| +type localeLink struct { |
| + Code, Name, Dir, Href string |
| + Current bool |
| +} |
| + |
| +// controls is one copy of the switchers: the theme tabs, the scheme |
| +// menu (Fiesta's, lifted from the scheme-menu example, so fiesta.js |
| +// owns the scheme) and the language menu. Every page writes them for |
| +// the bar and, on a phone, for the display settings menu, from this |
| +// one value, so the two cannot differ. |
| +type controls struct { |
| + Themes []link |
| + Scheme template.HTML |
| + Locales []localeLink |
| + LocaleName string |
| +} |
| + |
| +// tokenNav is one entry per token group on the Tokens page, in page |
| +// order: the theme's palette groups, then the structure every theme |
| +// shares. |
| +func (s *site) tokenNav(p page) []navItem { |
| + file := s.href(p.Theme, p.Locale, s.fileOf("tokens")) |
| + var items []navItem |
| + for _, g := range s.tokenGroups(p.Theme) { |
| + items = append(items, navItem{Label: s.say(p.Locale, "gallery.tokens.group."+g.Key), Href: file + "#" + anchorID("tokens", g.Key)}) |
| + } |
| + return items |
| +} |
| + |
| +// iconNav is one entry per icon, by slug, in the order the page draws |
| +// them. |
| +func (s *site) iconNav(p page) []navItem { |
| + file := s.href(p.Theme, p.Locale, s.fileOf("icons")) |
| + var items []navItem |
| + for _, ic := range s.icons { |
| + items = append(items, navItem{Label: ic.Slug, Href: file + "#" + anchorID("icon", ic.Slug), Code: true}) |
| + } |
| + return items |
| +} |
| + |
| +// familyNav is one family's entries: its examples, by name, each the |
| +// link to its own page. It closes over the key because the rail is the |
| +// same on every page: every family's section lists that family's |
| +// examples wherever the reader is standing. |
| +func (s *site) familyNav(key string) func(p page) []navItem { |
| + return func(p page) []navItem { |
| + var items []navItem |
| + for _, f := range s.families { |
| + if f.Key != key { |
| + continue |
| + } |
| + for _, e := range f.Examples { |
| + items = append(items, navItem{Label: e.Name, Href: s.href(p.Theme, p.Locale, e.Name+".html"), Code: true}) |
| + } |
| + } |
| + return items |
| + } |
| +} |
| + |
| +// controls builds the switchers for one page: this page in every |
| +// theme, the scheme menu, and this page in every locale, each language |
| +// named in itself. The links carry no fragment; the script adds the |
| +// reader's place at the click. |
| +func (s *site) controls(p page) controls { |
| + c := controls{Scheme: s.scheme, LocaleName: s.say(p.Locale, "gallery.locale.name")} |
| + for _, theme := range fiesta.ThemeNames() { |
| + c.Themes = append(c.Themes, link{Href: s.href(theme, p.Locale, p.File), Label: theme, Current: theme == p.Theme}) |
| + } |
| + for _, code := range Locales { |
| + c.Locales = append(c.Locales, localeLink{ |
| + Code: code, |
| + Name: s.say(code, "gallery.locale.name"), |
| + Dir: Dir(code), |
| + Href: s.href(p.Theme, code, p.File), |
| + Current: code == p.Locale, |
| + }) |
| + } |
| + return c |
| +} |
| + |
| +// galleryNav is the rail for one page: one section per page kind, in |
| +// table order, each with the entries its page anchors. The section |
| +// overview items, the current marks, the search terms and the Demos |
| +// are the shell task's to add. |
| +func (s *site) galleryNav(p page) []navSection { |
| + var out []navSection |
| + for _, pk := range s.pageKinds() { |
| + section := navSection{Title: s.titleOf(p.Locale, pk), Href: s.href(p.Theme, p.Locale, pk.File), Kind: pk.Kind} |
| + if pk.Nav != nil { |
| + section.Items = pk.Nav(p) |
| + } |
| + out = append(out, section) |
| + } |
| + return out |
| +} |
| + |
| +// phoneIndex sets the Overview's phone index, Rows and Demos, on the |
| +// page being built. Nothing yet: the shell task fills it. |
| +func (s *site) phoneIndex(p *page) {} |
diff --git a/internal/gallery/overview.go b/internal/gallery/overview.go| new file mode 100644 |
| index 0000000..7526476 |
| --- /dev/null |
| +++ b/internal/gallery/overview.go |
| @@ -0,0 +1,35 @@ |
| +package gallery |
| + |
| +import ( |
| + "bytes" |
| + "html/template" |
| +) |
| + |
| +// The Overview: the address every visitor lands on first. Its lead, |
| +// then a route into each of the other pages, read off pageKinds() with |
| +// each row's own blurb, so a page kind appears here the day its row |
| +// lands. The intro and the demo application are later tasks'. |
| +const overviewBody = `{{define "ds-body-overview"}}<p class="ds-lead">{{say "gallery.index.lead" "theme" .Theme}}</p> |
| +<h2 class="ds-sub">{{say "gallery.overview.next"}}</h2> |
| +<ul class="ds-routes">{{range .Routes}} |
| +<li><a data-gallery-nav href="{{.Href}}">{{.Label}}</a><span>{{.Blurb}}</span></li>{{end}} |
| +</ul> |
| +{{end}}` |
| + |
| +// overviewBody renders the Overview's body for p. |
| +func (s *site) overviewBody(p page) (template.HTML, error) { |
| + return s.body(p.Locale, "ds-body-overview", p) |
| +} |
| + |
| +// body executes one named body template against data. |
| +func (s *site) body(locale, name string, data any) (template.HTML, error) { |
| + t, err := s.templates(locale) |
| + if err != nil { |
| + return "", err |
| + } |
| + var b bytes.Buffer |
| + if err := t.ExecuteTemplate(&b, name, data); err != nil { |
| + return "", err |
| + } |
| + return template.HTML(b.String()), nil |
| +} |
diff --git a/internal/gallery/page_tmpl.go b/internal/gallery/page_tmpl.go| new file mode 100644 |
| index 0000000..378d091 |
| --- /dev/null |
| +++ b/internal/gallery/page_tmpl.go |
| @@ -0,0 +1,50 @@ |
| +package gallery |
| + |
| +// pageTemplate is the frame every gallery page is drawn in: the head, |
| +// the header with the brand, the theme tabs, the scheme menu, the |
| +// language menu and the page navigation, the page's own heading and |
| +// body, the previous/next pair and the Copy live region. It is part |
| +// 3's header carried over with the language menu in place of the |
| +// direction link; the shell task rewrites it as the sidebar shell. |
| +// |
| +// gallery.js blocks in <head>, as rastrillo's does, so that a frame is |
| +// hidden before it can paint in the wrong scheme; the scheme menu's own |
| +// scripts are deferred, as the example that ships them has them. |
| +// |
| +// The live region at the foot carries the Copy button's three words |
| +// and the join as data attributes, so the script writes no words of |
| +// its own. |
| +const pageTemplate = `{{define "ds-controls"}}<nav rst-seg-tabs aria-label="{{say "gallery.theme.label"}}">{{range .Themes}}<a data-gallery-nav href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}</nav> |
| +{{.Scheme}} |
| +<details rst-dropdown rst-locale name="rst-menus"> |
| +<summary>{{say "gallery.language"}}<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span><span class="rst-sr-only">{{say "gallery.language.current" "language" .LocaleName}}</span></summary> |
| +<div rst-dropdown-menu>{{range .Locales}}<a data-gallery-nav href="{{.Href}}" lang="{{.Code}}" dir="{{.Dir}}"{{if .Current}} aria-current="true"{{end}}>{{.Name}}</a>{{end}}</div> |
| +</details>{{end}} |
| +{{define "ds-page"}}<!doctype html> |
| +<html lang="{{.Lang}}" dir="{{.Dir}}" {{.HTMLAttrs}}> |
| +<head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width"> |
| +<title>{{.Title}}</title> |
| +<link rel="stylesheet" href="{{.Asset "css/tokens.css"}}"> |
| +<link rel="stylesheet" href="{{.Asset (print "css/themes/" .Theme ".css")}}"> |
| +<link rel="stylesheet" href="{{.Asset "gallery.css"}}"> |
| +<script src="{{.Asset "gallery.js"}}"></script> |
| +{{range .Scripts}}<script defer src="{{$.Asset (print "js/" .)}}"></script> |
| +{{end}}</head> |
| +<body> |
| +<a rst-skip href="#main">{{say "gallery.skip"}}</a> |
| +<header class="ds-top"> |
| +<a class="ds-top__brand" data-gallery-nav href="{{.Home}}">{{say "gallery.brand"}}</a> |
| +<div class="ds-top__controls">{{template "ds-controls" .Bar}}</div> |
| +<nav class="ds-pages" aria-label="{{say "gallery.nav.label"}}">{{range .Nav}}<a data-gallery-nav href="{{.Href}}"{{if eq .Kind $.Kind}} aria-current="page"{{end}}>{{.Title}}</a>{{end}}</nav> |
| +</header> |
| +<main rst-page id="main"> |
| +<header rst-page-header><div rst-page-header-titles><h1{{with .TitleID}} id="{{.}}"{{end}}{{if .Mono}} class="rst-mono"{{end}}>{{.Name}}</h1></div></header> |
| +{{.Body}} |
| +<nav class="ds-updown" aria-label="{{say "gallery.steps.label"}}">{{with .Prev}}<a class="ds-updown__prev" data-gallery-nav href="{{.Href}}">{{.Label}}</a>{{end}}{{with .Next}}<a class="ds-updown__next" data-gallery-nav href="{{.Href}}">{{.Label}}</a>{{end}}</nav> |
| +<p class="rst-sr-only" role="status" data-ds-copy-status data-copy="{{say "gallery.copy"}}" data-copied="{{say "gallery.copied"}}" data-failed="{{say "gallery.copy.failed"}}" data-join=", "></p> |
| +</main> |
| +</body> |
| +</html> |
| +{{end}}` |
diff --git a/internal/gallery/pages.go b/internal/gallery/pages.go| new file mode 100644 |
| index 0000000..cf0b08e |
| --- /dev/null |
| +++ b/internal/gallery/pages.go |
| @@ -0,0 +1,176 @@ |
| +package gallery |
| + |
| +import ( |
| + "fmt" |
| + "html/template" |
| + "strings" |
| +) |
| + |
| +// The page kinds: one page per section, each at its own URL under |
| +// <theme>/<locale>/. The table pageKinds returns is the whole of that |
| +// seam: a row in it is a page in the tree, a section of the rail, a |
| +// row on the phone index, a step in the previous/next sequence, a |
| +// route off the Overview and a file TestTreeShapeIsComplete expects, |
| +// with nothing else to remember. The families come after the three |
| +// fixed rows, read off the manifest, so a family added to examples/ |
| +// is a page the day it lands. |
| + |
| +// ReleaseBase is where the site serves the newest 1.x release's files. |
| +// A link to a shipped file starts with it and carries data-gallery-site; |
| +// internal/www holds it to its own origin. |
| +const ReleaseBase = "https://fiesta.rastrillo.org/1/" |
| + |
| +// pageKind is one row of the table. Title and Blurb are ids: a |
| +// gallery.* id into text, or a prose slot id (families.<key>.title); |
| +// textOf resolves either. Blurb is the one sentence the Overview says |
| +// when it routes a reader to the page, and is empty on the Overview, |
| +// which never routes to itself. Nav reads the page's rail entries off |
| +// the finished page, nil where it anchors nothing. Body renders the |
| +// page's own markup. |
| +type pageKind struct { |
| + Kind, File, Title, Blurb string |
| + Nav func(p page) []navItem |
| + Body func(p page) (template.HTML, error) |
| +} |
| + |
| +// pageKinds is the table, in page order: the Overview, Tokens, Icons, |
| +// then one row per family in manifest order. The Icons route reads |
| +// the page's own lead, with the icon count, until the reviewed route |
| +// sentence lands. |
| +func (s *site) pageKinds() []pageKind { |
| + kinds := []pageKind{ |
| + {Kind: "overview", File: "index.html", Title: "gallery.kind.overview", Body: s.overviewBody}, |
| + {Kind: "tokens", File: "tokens.html", Title: "gallery.kind.tokens", Blurb: "gallery.route.tokens", Nav: s.tokenNav, Body: s.tokensBody}, |
| + {Kind: "icons", File: "icons.html", Title: "gallery.kind.icons", Blurb: "gallery.icons.lead", Nav: s.iconNav, Body: s.iconsBody}, |
| + } |
| + for _, f := range s.families { |
| + kinds = append(kinds, pageKind{ |
| + Kind: f.Key, |
| + File: f.Key + ".html", |
| + Title: "families." + f.Key + ".title", |
| + Blurb: "families." + f.Key + ".blurb", |
| + Nav: s.familyNav(f.Key), |
| + Body: s.familyBody, |
| + }) |
| + } |
| + return kinds |
| +} |
| + |
| +// fileOf is one page kind's file. It panics on a kind that is not in |
| +// the table, since every caller passes a Kind read out of the table. |
| +func (s *site) fileOf(kind string) string { |
| + for _, pk := range s.pageKinds() { |
| + if pk.Kind == kind { |
| + return pk.File |
| + } |
| + } |
| + panic("gallery: no page kind " + kind) |
| +} |
| + |
| +// href is the address of one file of one theme and locale directory. |
| +// Every link the renderer writes into the tree is built here, so the |
| +// mount is spelled once. |
| +func (s *site) href(theme, locale, file string) string { |
| + return s.url(theme + "/" + locale + "/" + file) |
| +} |
| + |
| +// titleOf is one page kind's title in locale. |
| +func (s *site) titleOf(locale string, pk pageKind) string { return s.textOf(locale, pk.Title) } |
| + |
| +// blurbOf is one page kind's route blurb in locale, or "" where it has |
| +// none. |
| +func (s *site) blurbOf(locale string, pk pageKind) string { |
| + if pk.Blurb == "" { |
| + return "" |
| + } |
| + return s.textOf(locale, pk.Blurb, "total", fmt.Sprint(len(s.icons))) |
| +} |
| + |
| +// sequence is the order the pages are read in: the table's rows, and |
| +// inside each family its examples in manifest order after the family's |
| +// own page. Each step is the page's address and the label the |
| +// previous/next pair names it by: a kind's title, an example's name. |
| +func (s *site) sequence(theme, locale string) []link { |
| + var out []link |
| + for _, pk := range s.pageKinds() { |
| + out = append(out, link{Href: s.href(theme, locale, pk.File), Label: s.titleOf(locale, pk)}) |
| + for _, f := range s.families { |
| + if f.Key != pk.Kind { |
| + continue |
| + } |
| + for _, e := range f.Examples { |
| + out = append(out, link{Href: s.href(theme, locale, e.Name+".html"), Label: e.Name}) |
| + } |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// steps is the pair of links at the foot of one page: the page before |
| +// it and the page after it in the sequence. Either is nil at the ends. |
| +func (s *site) steps(theme, locale, file string) (prev, next *link) { |
| + seq := s.sequence(theme, locale) |
| + at := -1 |
| + for i, step := range seq { |
| + if step.Href == s.href(theme, locale, file) { |
| + at = i |
| + break |
| + } |
| + } |
| + if at < 0 { |
| + panic("gallery: " + file + " is not in the sequence") |
| + } |
| + step := func(i int, id string) *link { |
| + if i < 0 || i >= len(seq) { |
| + return nil |
| + } |
| + return &link{Href: seq[i].Href, Label: s.say(locale, id, "page", seq[i].Label)} |
| + } |
| + return step(at-1, "gallery.step.prev"), step(at+1, "gallery.step.next") |
| +} |
| + |
| +// route is one entry in the Overview's route list: another page of the |
| +// gallery, its name, and the one sentence saying what is on it. |
| +type route struct{ Label, Blurb, Href string } |
| + |
| +// routes is the Overview's way into the rest of the gallery: one per |
| +// page kind that is not kind, in table order, each with its row's |
| +// blurb. |
| +func (s *site) routes(theme, locale, kind string) []route { |
| + var out []route |
| + for _, pk := range s.pageKinds() { |
| + if pk.Kind == kind { |
| + continue |
| + } |
| + out = append(out, route{Label: s.titleOf(locale, pk), Blurb: s.blurbOf(locale, pk), Href: s.href(theme, locale, pk.File)}) |
| + } |
| + return out |
| +} |
| + |
| +// anchorID is the id one anchorable thing on a page wears, and the |
| +// fragment the rail links it by: built from the English name, so the |
| +// twelve translations of a page carry the same fragments, and prefixed |
| +// by kind, so a token group and an icon of the same name are two ids. |
| +func anchorID(kind, name string) string { return kind + "-" + slug(name) } |
| + |
| +// slug lowercases and hyphenates: ASCII letters and digits survive, |
| +// everything else becomes one hyphen, and the ends are trimmed. The |
| +// inputs are English headings and identifiers already in this |
| +// alphabet, so there is no transliteration to do. |
| +func slug(s string) string { |
| + var b strings.Builder |
| + dash := false |
| + for _, r := range strings.ToLower(s) { |
| + switch { |
| + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': |
| + b.WriteRune(r) |
| + dash = false |
| + default: |
| + if b.Len() > 0 && !dash { |
| + b.WriteByte('-') |
| + dash = true |
| + } |
| + } |
| + } |
| + return strings.TrimSuffix(b.String(), "-") |
| +} |
diff --git a/internal/gallery/pages_a11y_browser_test.go b/internal/gallery/pages_a11y_browser_test.go| index 7ead6cd..fe52546 100644 |
| --- a/internal/gallery/pages_a11y_browser_test.go |
| +++ b/internal/gallery/pages_a11y_browser_test.go |
| @@ -32,7 +32,7 @@ var prosePages = []string{"index.html", "tokens.html", "icons.html", "button.htm |
| // TestTheGalleryPagesPassAxe: the gallery pages that carry prose, |
| // scanned with the WCAG 2.2 AA tag set in day, plain and signal with |
| -// data-theme light and dark, and the right-to-left index in day. |
| +// data-theme light and dark, and the Arabic index in day. |
| // Expected: no violations. The rules axe could not decide are logged |
| // per leg, never silently dropped. |
| // |
| @@ -46,29 +46,23 @@ func TestTheGalleryPagesPassAxe(t *testing.T) { |
| defer cancel() |
| type leg struct { |
| - theme string |
| - rtl bool |
| - pages []string |
| + theme string |
| + locale string |
| + pages []string |
| } |
| var legs []leg |
| for _, theme := range fiesta.ThemeNames() { |
| - legs = append(legs, leg{theme, false, prosePages}) |
| + legs = append(legs, leg{theme, "en", prosePages}) |
| } |
| - legs = append(legs, leg{fiesta.ThemeNames()[0], true, []string{"index.html"}}) |
| + legs = append(legs, leg{fiesta.ThemeNames()[0], "ar", []string{"index.html"}}) |
| scans, failed := 0, 0 |
| for _, leg := range legs { |
| - name := leg.theme |
| - if leg.rtl { |
| - name += " rtl" |
| - } |
| + name := leg.theme + " " + leg.locale |
| incomplete := map[string]int{} |
| start := time.Now() |
| for _, file := range leg.pages { |
| - dir := leg.theme + "/" |
| - if leg.rtl { |
| - dir += rtlDir |
| - } |
| + dir := leg.theme + "/" + leg.locale + "/" |
| where := name + " " + file |
| mustRun(t, ctx, |
| chromedp.EmulateViewport(1280, 900), |
diff --git a/internal/gallery/prose.go b/internal/gallery/prose.go| index ecde811..3c06d24 100644 |
| --- a/internal/gallery/prose.go |
| +++ b/internal/gallery/prose.go |
| @@ -5,6 +5,7 @@ import ( |
| "fmt" |
| "io/fs" |
| "sort" |
| + "strings" |
| "amadan.net/rastrillo/fiesta/example" |
| ) |
| @@ -34,11 +35,19 @@ type familyProse struct { |
| } |
| // exampleProse is an example's sentence and, by state id, the note |
| -// under a state's label. A state with nothing to say has no entry; |
| -// an entry that is "" is a slot waiting for copy review. |
| +// under a state's label and, where a state has one, the warning |
| +// callout after it. A state with nothing to say has no entry; an |
| +// entry that is "" is a slot waiting for copy review. |
| type exampleProse struct { |
| - Blurb string `json:"blurb"` |
| - States map[string]string `json:"states,omitempty"` |
| + Blurb string `json:"blurb"` |
| + States map[string]string `json:"states,omitempty"` |
| + Warnings map[string]warning `json:"warnings,omitempty"` |
| +} |
| + |
| +// warning is a callout under one state's note: its title and body. |
| +type warning struct { |
| + Title string `json:"title"` |
| + Body string `json:"body"` |
| } |
| // pagesProse is the prose that belongs to a page rather than to an |
| @@ -89,14 +98,15 @@ func (p *prose) strays(m *example.Manifest) []string { |
| continue |
| } |
| for id := range ex.States { |
| - found := false |
| - for _, st := range e.States { |
| - found = found || st.ID == id |
| - } |
| - if !found { |
| + if !hasState(e, id) { |
| out = append(out, "examples."+name+".states."+id+": no such state") |
| } |
| } |
| + for id := range ex.Warnings { |
| + if !hasState(e, id) { |
| + out = append(out, "examples."+name+".warnings."+id+": no such state") |
| + } |
| + } |
| } |
| for _, rule := range p.Pages.Idioms { |
| if _, _, ok := m.Idiom(rule.Idiom); !ok { |
| @@ -107,6 +117,43 @@ func (p *prose) strays(m *example.Manifest) []string { |
| return out |
| } |
| +// hasState says whether e has a state with id. |
| +func hasState(e example.Entry, id string) bool { |
| + for _, st := range e.States { |
| + if st.ID == id { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// english is the English the prose holds in the slot id names, as each |
| +// names the slots, or "" for a slot it has not got. Resolved from the |
| +// id's parts rather than by walking each: a render asks for a title |
| +// per page per rail entry, and the walk was most of a render. |
| +func (p *prose) english(id string) string { |
| + parts := strings.Split(id, ".") |
| + switch { |
| + case len(parts) == 3 && parts[0] == "families": |
| + f := p.Families[parts[1]] |
| + return map[string]string{"title": f.Title, "blurb": f.Blurb}[parts[2]] |
| + case len(parts) == 3 && parts[0] == "examples" && parts[2] == "blurb": |
| + return p.Examples[parts[1]].Blurb |
| + case len(parts) == 4 && parts[0] == "examples" && parts[2] == "states": |
| + return p.Examples[parts[1]].States[parts[3]] |
| + case len(parts) == 5 && parts[0] == "examples" && parts[2] == "warnings": |
| + w := p.Examples[parts[1]].Warnings[parts[3]] |
| + return map[string]string{"title": w.Title, "body": w.Body}[parts[4]] |
| + case len(parts) == 4 && parts[0] == "pages" && parts[1] == "idioms": |
| + for _, r := range p.Pages.Idioms { |
| + if r.Idiom == parts[2] { |
| + return map[string]string{"title": r.Title, "body": r.Body}[parts[3]] |
| + } |
| + } |
| + } |
| + return "" |
| +} |
| + |
| // each calls fn with every string in the prose and the slot it sits |
| // in, in a fixed order, so a check over the words reads all of them. |
| func (p *prose) each(fn func(id, text string)) { |
| @@ -135,6 +182,15 @@ func (p *prose) each(fn func(id, text string)) { |
| for _, id := range ids { |
| fn("examples."+n+".states."+id, ex.States[id]) |
| } |
| + warned := make([]string, 0, len(ex.Warnings)) |
| + for id := range ex.Warnings { |
| + warned = append(warned, id) |
| + } |
| + sort.Strings(warned) |
| + for _, id := range warned { |
| + fn("examples."+n+".warnings."+id+".title", ex.Warnings[id].Title) |
| + fn("examples."+n+".warnings."+id+".body", ex.Warnings[id].Body) |
| + } |
| } |
| for _, rule := range p.Pages.Idioms { |
| fn("pages.idioms."+rule.Idiom+".title", rule.Title) |
diff --git a/internal/gallery/prose_test.go b/internal/gallery/prose_test.go| index 33de4e6..6c18fb5 100644 |
| --- a/internal/gallery/prose_test.go |
| +++ b/internal/gallery/prose_test.go |
| @@ -114,8 +114,6 @@ func byAttr(name, value string) func(node) bool { |
| } |
| } |
| -func byID(id string) func(node) bool { return byAttr("id", id) } |
| - |
| // squash is a prose string the way text() reads it off the page. |
| func squash(s string) string { return strings.Join(strings.Fields(s), " ") } |
| @@ -160,9 +158,13 @@ func TestProseNamesOnlyRealExamplesAndStates(t *testing.T) { |
| t.Error(s) |
| } |
| // The control: the check sees a slot that names nothing. |
| - bad := &prose{Examples: map[string]exampleProse{"no-such-example": {Blurb: "x"}, "button": {States: map[string]string{"no-such-state": "x"}}}} |
| - if got := bad.strays(manifest(t)); len(got) != 2 { |
| - t.Fatalf("the control has two strays; strays found %d: %v", len(got), got) |
| + bad := &prose{Examples: map[string]exampleProse{ |
| + "no-such-example": {Blurb: "x"}, |
| + "button": {States: map[string]string{"no-such-state": "x"}}, |
| + "signin": {Warnings: map[string]warning{"no-such-state": {Title: "x", Body: "y"}}}, |
| + }} |
| + if got := bad.strays(manifest(t)); len(got) != 3 { |
| + t.Fatalf("the control has three strays; strays found %d: %v", len(got), got) |
| } |
| } |
| @@ -225,7 +227,7 @@ func stateSection(t *testing.T, page []byte, name, state string) node { |
| // noteOf is the note under a state's label, or "" when there is none. |
| func noteOf(sec node) string { |
| - note, ok := sec.first(byClass("gallery-note")) |
| + note, ok := sec.first(byClass("ds-note")) |
| if !ok { |
| return "" |
| } |
| @@ -253,7 +255,7 @@ func TestTheDropdownIdiomNoteSitsOnItsState(t *testing.T) { |
| if note == p.Examples[name].Blurb { |
| t.Errorf("examples.%s.blurb is the idiom's sentence; the component's should be", name) |
| } |
| - page := render(t, "")[fiesta.ThemeNames()[0]+"/"+name+".html"] |
| + page := render(t, "")[fiesta.ThemeNames()[0]+"/en/"+name+".html"] |
| if got := noteOf(stateSection(t, page, name, state)); got != squash(note) { |
| t.Errorf("%s/%s shows the note %q, want %q", name, state, got, note) |
| } |
| @@ -273,43 +275,38 @@ func TestTheStandaloneSchemeStateSaysNoServer(t *testing.T) { |
| if strings.Contains(note, seed) || strings.Contains(note, "/_theme") { |
| t.Errorf("examples.%s.states.%s is the rastrillo note: %q", name, state, note) |
| } |
| - page := render(t, "")[fiesta.ThemeNames()[0]+"/"+name+".html"] |
| + page := render(t, "")[fiesta.ThemeNames()[0]+"/en/"+name+".html"] |
| if got := noteOf(stateSection(t, page, name, state)); got != squash(note) { |
| t.Errorf("%s/%s shows the note %q, want %q", name, state, got, note) |
| } |
| } |
| -// familySection is one family's section on the index. |
| -func familySection(t *testing.T, index []byte, key string) node { |
| - t.Helper() |
| - for _, sec := range parse(t, index).find(byClass("gallery-family")) { |
| - if _, ok := sec.first(byID(key)); ok { |
| - return sec |
| - } |
| - } |
| - t.Fatalf("the index has no family section with a heading #%s", key) |
| - return node{} |
| -} |
| - |
| -// TestTheIndexShowsTheFamilyBlurbs: every family's section on the |
| -// index has the family's title as its heading and its blurb under it. |
| -func TestTheIndexShowsTheFamilyBlurbs(t *testing.T) { |
| +// TestTheFamilyPagesShowTheFamilyBlurbs: every family's page has the |
| +// family's title as its heading and its blurb under it, in English and |
| +// in Arabic. Preservation: part 3's TestTheIndexShowsTheFamilyBlurbs, |
| +// reading the family pages the index's sections became. |
| +func TestTheFamilyPagesShowTheFamilyBlurbs(t *testing.T) { |
| p := loadedProse(t) |
| - for _, dir := range directions { |
| - index := render(t, "")[fiesta.ThemeNames()[0]+"/"+dir+"index.html"] |
| + files := render(t, "") |
| + for _, locale := range []string{"en", "ar"} { |
| for _, f := range groupFamilies(manifest(t).Examples) { |
| - sec := familySection(t, index, f.Key) |
| - h, _ := sec.first(byID(f.Key)) |
| + name := fiesta.ThemeNames()[0] + "/" + locale + "/" + f.Key + ".html" |
| + doc := parse(t, files[name]) |
| + h, ok := doc.first(func(n node) bool { return n.n.Data == "h1" }) |
| + if !ok { |
| + t.Errorf("%s has no heading", name) |
| + continue |
| + } |
| if want := p.Families[f.Key].Title; h.text() != want { |
| - t.Errorf("%sindex.html: family %s is headed %q, want %q", dir, f.Key, h.text(), want) |
| + t.Errorf("%s is headed %q, want %q", name, h.text(), want) |
| } |
| - lead, ok := sec.first(byClass("gallery-lead")) |
| + lead, ok := doc.first(byClass("ds-lead")) |
| if !ok { |
| - t.Errorf("%sindex.html: family %s has no blurb under its heading", dir, f.Key) |
| + t.Errorf("%s has no blurb under its heading", name) |
| continue |
| } |
| if want := squash(p.Families[f.Key].Blurb); want == "" || lead.text() != want { |
| - t.Errorf("%sindex.html: family %s shows %q, want %q", dir, f.Key, lead.text(), want) |
| + t.Errorf("%s shows %q, want %q", name, lead.text(), want) |
| } |
| } |
| } |
| @@ -325,9 +322,9 @@ func TestAnExamplePageShowsItsBlurbAndNotes(t *testing.T) { |
| theme := fiesta.ThemeNames()[0] |
| blurbs, notes := 0, 0 |
| for _, e := range manifest(t).Examples { |
| - page := files[theme+"/"+e.Name+".html"] |
| + page := files[theme+"/en/"+e.Name+".html"] |
| doc := parse(t, page) |
| - head, ok := doc.first(byClass("gallery-family-head")) |
| + head, ok := doc.first(byClass("ds-kicker")) |
| if !ok { |
| t.Errorf("%s.html has no family heading", e.Name) |
| } else if want := squash(p.Families[e.Family].Title + " " + p.Families[e.Family].Blurb); head.text() != want { |
| @@ -358,37 +355,41 @@ func TestAnExamplePageShowsItsBlurbAndNotes(t *testing.T) { |
| } |
| // TestTheIdiomRulesHeadThePrimitivesFamily: the two idiom rules are |
| -// quoted on the index at the head of the primitives family, before |
| -// its examples, and nowhere else on it. |
| +// quoted on the primitives family's page, before its examples, and on |
| +// no other family's page. Preservation: part 3's test, reading the |
| +// family pages in place of the index's sections. |
| func TestTheIdiomRulesHeadThePrimitivesFamily(t *testing.T) { |
| p := loadedProse(t) |
| if len(p.Pages.Idioms) != 2 { |
| t.Fatalf("the prose has %d idiom rules, want 2", len(p.Pages.Idioms)) |
| } |
| - index := render(t, "")[fiesta.ThemeNames()[0]+"/index.html"] |
| - doc := parse(t, index) |
| + files := render(t, "") |
| + for _, f := range groupFamilies(manifest(t).Examples) { |
| + if f.Key == "primitives" { |
| + continue |
| + } |
| + if n := len(parse(t, files[fiesta.ThemeNames()[0]+"/en/"+f.Key+".html"]).find(byAttr("rst-callout", ""))); n != 0 { |
| + t.Errorf("%s.html has %d callouts, want none", f.Key, n) |
| + } |
| + } |
| + doc := parse(t, files[fiesta.ThemeNames()[0]+"/en/primitives.html"]) |
| callouts := doc.find(byAttr("rst-callout", "")) |
| if len(callouts) != len(p.Pages.Idioms) { |
| - t.Fatalf("the index has %d callouts, want %d", len(callouts), len(p.Pages.Idioms)) |
| - } |
| - sec := familySection(t, index, "primitives") |
| - inSec := sec.find(byAttr("rst-callout", "")) |
| - if len(inSec) != len(p.Pages.Idioms) { |
| - t.Fatalf("the primitives section has %d of the %d callouts", len(inSec), len(p.Pages.Idioms)) |
| + t.Fatalf("primitives.html has %d callouts, want %d", len(callouts), len(p.Pages.Idioms)) |
| } |
| for i, rule := range p.Pages.Idioms { |
| - if got, want := inSec[i].text(), squash(rule.Title+" "+rule.Body); got != want { |
| + if got, want := callouts[i].text(), squash(rule.Title+" "+rule.Body); got != want { |
| t.Errorf("rule %d reads %q, want %q", i, got, want) |
| } |
| } |
| // Before the examples: the list of links comes after the last |
| // callout in document order. |
| - order := sec.find(func(n node) bool { |
| + order := doc.find(func(n node) bool { |
| _, callout := n.attr("rst-callout") |
| - return callout || n.hasClass("gallery-list") |
| + return callout || n.hasClass("ds-routes") |
| }) |
| - if len(order) != len(p.Pages.Idioms)+1 || !order[len(order)-1].hasClass("gallery-list") { |
| - t.Errorf("the primitives section's order is not rules then examples") |
| + if len(order) != len(p.Pages.Idioms)+1 || !order[len(order)-1].hasClass("ds-routes") { |
| + t.Errorf("primitives.html's order is not rules then examples") |
| } |
| } |
| @@ -400,7 +401,7 @@ func TestTheSigninPageHasItsSummary(t *testing.T) { |
| if want == "" { |
| t.Fatal("examples.signin.blurb is empty") |
| } |
| - page := render(t, "")[fiesta.ThemeNames()[0]+"/signin.html"] |
| + page := render(t, "")[fiesta.ThemeNames()[0]+"/en/signin.html"] |
| sub, ok := parse(t, page).first(byAttr("rst-page-header-sub", "")) |
| if !ok { |
| t.Fatal("signin.html has no rst-page-header-sub under its title") |
diff --git a/internal/gallery/rig_browser_test.go b/internal/gallery/rig_browser_test.go| new file mode 100644 |
| index 0000000..a918561 |
| --- /dev/null |
| +++ b/internal/gallery/rig_browser_test.go |
| @@ -0,0 +1,142 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The shared drive helpers, under the names the ported drives are |
| +// written with. One line each: the implementation is galleryrig's, |
| +// which the history package uses too, so the two cannot drift. |
| +// Fiesta's own drives keep newTree in a11y_browser_test.go, which |
| +// records every request as well. |
| + |
| +func treeHandler(t *testing.T) http.Handler { |
| + t.Helper() |
| + return galleryrig.Tree(t, render(t, mountPath), mountPath+"/") |
| +} |
| + |
| +func eagerly(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + galleryrig.Eagerly(t, ctx, where) |
| +} |
| + |
| +func mobileSettle(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + galleryrig.MobileSettle(t, ctx, where) |
| +} |
| + |
| +func addInit(js string) chromedp.Action { return galleryrig.AddInit(js) } |
| + |
| +func until(t *testing.T, ctx context.Context, where, expr string) { |
| + t.Helper() |
| + galleryrig.Until(t, ctx, where, expr) |
| +} |
| + |
| +func phoneRig(t *testing.T, opts ...harness.Option) *harness.Rig { |
| + t.Helper() |
| + return galleryrig.PhoneRig(t, func() http.Handler { return treeHandler(t) }, opts...) |
| +} |
| + |
| +func requireDrawnScrollbar(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + galleryrig.RequireDrawnScrollbar(t, ctx, where) |
| +} |
| + |
| +func requireCoarse(t *testing.T, ctx context.Context) { |
| + t.Helper() |
| + galleryrig.RequireCoarse(t, ctx) |
| +} |
| + |
| +func settleMotion(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + galleryrig.SettleMotion(t, ctx, where) |
| +} |
| + |
| +func noScripts(t *testing.T, ctx context.Context) context.Context { |
| + t.Helper() |
| + return galleryrig.NoScripts(t, ctx) |
| +} |
| + |
| +func requireScriptsOff(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + galleryrig.RequireScriptsOff(t, ctx, where) |
| +} |
| + |
| +// TestTheRigServesTheTreeAtItsMount: the shared tree answers for a |
| +// file of the render under the mount and records a miss for a path |
| +// it has not got, so a drive that frames a missing document fails by |
| +// name. The bindings above are exercised by the drives that use them. |
| +func TestTheRigServesTheTreeAtItsMount(t *testing.T) { |
| + files := render(t, mountPath) |
| + var missed []string |
| + h := galleryrig.TreeRecording(t, files, mountPath+"/", func(p string) { missed = append(missed, p) }) |
| + rig := harness.New(t, func(string) http.Handler { return h }) |
| + rig.Allow(http.MethodGet, mountPath+"/no-such-file.html", http.StatusNotFound) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 30*time.Second) |
| + defer cancel() |
| + var title string |
| + mustRun(t, ctx, |
| + chromedp.Navigate(rig.Origin+href("day/en/index.html")), |
| + chromedp.WaitReady("body"), |
| + chromedp.Evaluate(`document.title`, &title), |
| + ) |
| + if title == "" { |
| + t.Error("the Overview served through the rig has no title") |
| + } |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+href("no-such-file.html")), chromedp.WaitReady("body")) |
| + if len(missed) != 1 || missed[0] != href("no-such-file.html") { |
| + t.Errorf("the rig recorded the misses %v, want the one path asked for", missed) |
| + } |
| +} |
| + |
| +// TestTheSharedRigDrivesAPhone exercises every binding above on one |
| +// page, so a helper that stopped matching the markup fails here rather |
| +// than in the first ported drive that leans on it: a phone rig with a |
| +// drawn scrollbar, an init script that runs before the page, the frames |
| +// settled at a phone's width, motion waited out, a condition polled, |
| +// and a second tab with scripts off that proves it. |
| +func TestTheSharedRigDrivesAPhone(t *testing.T) { |
| + rig := phoneRig(t, harness.WithScrollbars()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) |
| + defer cancel() |
| + mustRun(t, ctx, |
| + addInit(`window.__galleryInit = true`), |
| + chromedp.ActionFunc(func(c context.Context) error { |
| + return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c) |
| + }), |
| + chromedp.Navigate(rig.Origin+href("day/en/button.html")), |
| + chromedp.WaitReady("body"), |
| + ) |
| + requireCoarse(t, ctx) |
| + requireDrawnScrollbar(t, ctx, "button") |
| + var init bool |
| + mustRun(t, ctx, chromedp.Evaluate(`window.__galleryInit === true`, &init)) |
| + if !init { |
| + t.Error("the init script did not run before the page's own") |
| + } |
| + eagerly(t, ctx, "button") |
| + mobileSettle(t, ctx, "button") |
| + settleMotion(t, ctx, "button") |
| + until(t, ctx, "button", `document.querySelectorAll(".ds-view__frame[data-gallery-painted]").length > 0`) |
| + var on bool |
| + mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.hasAttribute("data-gallery-js")`, &on)) |
| + if !on { |
| + t.Error("the scripted tab did not run the gallery's script") |
| + } |
| + |
| + off := noScripts(t, rig.Context()) |
| + mustRun(t, off, chromedp.Navigate(rig.Origin+href("day/en/button.html")), chromedp.WaitReady("body")) |
| + requireScriptsOff(t, off, "button with scripts off") |
| + rig.Screen("main", "the button page on a phone") |
| +} |
diff --git a/internal/gallery/tokens_page.go b/internal/gallery/tokens_page.go| new file mode 100644 |
| index 0000000..7e2910d |
| --- /dev/null |
| +++ b/internal/gallery/tokens_page.go |
| @@ -0,0 +1,56 @@ |
| +package gallery |
| + |
| +import ( |
| + "html/template" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/tokens" |
| +) |
| + |
| +// The Tokens page: the theme's palette, then the structure every |
| +// theme shares, each group under a heading the rail links. |
| +const tokensBody = `{{define "ds-body-tokens"}}<p class="ds-lead">{{say "gallery.tokens.lead" "theme" .Theme}}</p> |
| +<p class="ds-note">{{say "gallery.tokens.note"}}</p> |
| +{{range .Groups}}<h2 id="{{.ID}}" data-ds-anchor>{{.Title}}</h2> |
| +<ul class="ds-toks">{{$kind := .Kind}}{{range .Rows}}<li class="ds-tok">{{if eq $kind "type"}}<span class="ds-type" style="{{.Preview}}">Ag</span>{{else if eq $kind "space"}}<span class="ds-bar" style="{{.Preview}}"></span>{{else if eq $kind "radius"}}<span class="ds-chip ds-chip--fill" style="{{.Preview}}"></span>{{else if .Preview}}<span class="ds-chip" style="{{.Preview}}"></span>{{end}}<span class="ds-tok__text rst-mono"><span class="ds-tok__name">{{.Name}}</span><span class="ds-tok__value">{{.Value}}</span></span></li>{{end}}</ul> |
| +{{end}}{{end}}` |
| + |
| +// tokenGroupView is one group with its anchor id and its title in the |
| +// page's language. |
| +type tokenGroupView struct { |
| + tokens.Group |
| + ID, Title string |
| +} |
| + |
| +// tokenGroups is one theme's groups: its palette, then the structure. |
| +// Read once per theme at setup, so a theme whose stylesheet does not |
| +// parse fails the render before a page is written. |
| +func (s *site) tokenGroups(theme string) []tokens.Group { return s.tokens[theme] } |
| + |
| +// readTokens parses every theme's palette and the shared structure. |
| +func (s *site) readTokens() error { |
| + structure, err := tokens.Structural() |
| + if err != nil { |
| + return err |
| + } |
| + s.tokens = map[string][]tokens.Group{} |
| + for _, theme := range s.themes() { |
| + palette, err := tokens.Palette(theme) |
| + if err != nil { |
| + return err |
| + } |
| + s.tokens[theme] = append(palette, structure...) |
| + } |
| + return nil |
| +} |
| + |
| +// tokensBody renders the Tokens page's body for p. |
| +func (s *site) tokensBody(p page) (template.HTML, error) { |
| + var groups []tokenGroupView |
| + for _, g := range s.tokenGroups(p.Theme) { |
| + groups = append(groups, tokenGroupView{Group: g, ID: anchorID("tokens", g.Key), Title: s.say(p.Locale, "gallery.tokens.group."+g.Key)}) |
| + } |
| + return s.body(p.Locale, "ds-body-tokens", struct { |
| + page |
| + Groups []tokenGroupView |
| + }{p, groups}) |
| +} |
diff --git a/internal/gallery/tree_test.go b/internal/gallery/tree_test.go| new file mode 100644 |
| index 0000000..4f79b85 |
| --- /dev/null |
| +++ b/internal/gallery/tree_test.go |
| @@ -0,0 +1,823 @@ |
| +package gallery |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "fmt" |
| + "io/fs" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "testing/fstest" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// The shape of the whole tree, and what every page in it carries: the |
| +// files the four declared-expectation seams name and no other, the |
| +// twelve locales, the sequence of pages, the Overview's routes, the |
| +// hashed assets, the titles and ids, and the password screen's |
| +// warning. These gates read their expectations through pageKinds(), |
| +// frameFiles(), extraDocumentNames() and the asset list, so a later |
| +// task that extends the tree through its own seam keeps them exact |
| +// without an edit here. |
| + |
| +// newTestSite is a site after its setup, with nothing rendered: the |
| +// seams a test reads expectations from live on it. |
| +func newTestSite(t *testing.T, src fs.FS, mount string) *site { |
| + t.Helper() |
| + s, err := newSite(src, mount) |
| + if err != nil { |
| + t.Fatalf("newSite: %v", err) |
| + } |
| + return s |
| +} |
| + |
| +// dirs lists every theme and locale directory, with its trailing |
| +// slash, in render order. |
| +func dirs() []string { |
| + var out []string |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + out = append(out, theme+"/"+locale+"/") |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// isFrame says whether a tree path is a frame document. |
| +func isFrame(name string) bool { return strings.Contains(name, "/frames/") } |
| + |
| +// titleOf is the document's <title> text. |
| +func titleOf(t *testing.T, doc node) string { |
| + t.Helper() |
| + titles := doc.find(func(n node) bool { return n.n.Data == "title" }) |
| + if len(titles) != 1 { |
| + t.Fatalf("the document has %d <title> elements, want 1", len(titles)) |
| + } |
| + return titles[0].text() |
| +} |
| + |
| +// TestTreeShapeIsComplete: for every theme and locale, the tree holds |
| +// every file the four seams name and the assets hashed and plain, with |
| +// index.html at the root, and nothing else. The names no seam may |
| +// lose are checked by fixed name as well, so a seam that forgot its |
| +// own files could not agree with an output that forgot them too. |
| +// |
| +// The named mutation that turns this red: a file written in theme() |
| +// that no seam names. |
| +func TestTreeShapeIsComplete(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + m := s.m |
| + |
| + want := map[string]bool{"index.html": true} |
| + for _, dir := range dirs() { |
| + for _, pk := range s.pageKinds() { |
| + want[dir+pk.File] = true |
| + } |
| + for _, e := range m.Examples { |
| + want[dir+e.Name+".html"] = true |
| + for _, f := range s.frameFiles(e) { |
| + want[dir+f] = true |
| + } |
| + } |
| + for _, n := range s.extraDocumentNames() { |
| + want[dir+n] = true |
| + } |
| + } |
| + if len(s.hashed) == 0 { |
| + t.Fatal("the site lists no assets; the check below would expect none") |
| + } |
| + for plain, hashed := range s.hashed { |
| + want["assets/"+plain] = true |
| + want["assets/"+hashed] = true |
| + } |
| + |
| + // By fixed name: what no seam may lose. |
| + var fixed []string |
| + for _, dir := range dirs() { |
| + fixed = append(fixed, dir+"index.html", dir+"tokens.html", dir+"icons.html") |
| + for _, f := range groupFamilies(m.Examples) { |
| + fixed = append(fixed, dir+f.Key+".html") |
| + } |
| + for _, e := range m.Examples { |
| + fixed = append(fixed, dir+e.Name+".html") |
| + for _, st := range e.States { |
| + fixed = append(fixed, dir+"frames/"+e.Name+"/"+st.ID+".html") |
| + } |
| + } |
| + } |
| + for _, name := range fixed { |
| + if !want[name] { |
| + t.Errorf("no seam names %s, which the tree may not lose", name) |
| + } |
| + if _, ok := files[name]; !ok { |
| + t.Errorf("the render wrote no %s", name) |
| + } |
| + } |
| + |
| + for name := range want { |
| + if _, ok := files[name]; !ok { |
| + t.Errorf("the seams name %s and the render wrote no such file", name) |
| + } |
| + } |
| + for name := range files { |
| + if !want[name] { |
| + t.Errorf("the render wrote %s, which no seam names", name) |
| + } |
| + } |
| + if len(files) != len(want) { |
| + t.Errorf("the tree holds %d files, the seams name %d", len(files), len(want)) |
| + } |
| + t.Logf("%d files: %d directories of %d pages, %d example pages and %d frames each; %d assets twice; the root index", |
| + len(files), len(dirs()), len(s.pageKinds()), len(m.Examples), len(fixed)/len(dirs())-3-len(groupFamilies(m.Examples))-len(m.Examples), len(s.hashed)) |
| +} |
| + |
| +// TestRootIndexIsTheDefaultThemeInEnglishAtTheTreeRoot: index.html at |
| +// the root is day/en/index.html byte for byte. |
| +func TestRootIndexIsTheDefaultThemeInEnglishAtTheTreeRoot(t *testing.T) { |
| + files := render(t, "") |
| + root, ok := files["index.html"] |
| + if !ok || len(root) == 0 { |
| + t.Fatal("no index.html at the tree root") |
| + } |
| + want := files[fiesta.ThemeNames()[0]+"/en/index.html"] |
| + if len(want) == 0 { |
| + t.Fatalf("no %s/en/index.html", fiesta.ThemeNames()[0]) |
| + } |
| + if !bytes.Equal(root, want) { |
| + t.Errorf("index.html differs from %s/en/index.html", fiesta.ThemeNames()[0]) |
| + } |
| +} |
| + |
| +// expectedSequence is the order the plan and the spec give the pages: |
| +// the page kinds in table order, and inside each family its examples |
| +// in manifest order after the family's own page. |
| +func expectedSequence(s *site, theme, locale string) []link { |
| + var out []link |
| + for _, pk := range s.pageKinds() { |
| + out = append(out, link{Href: s.href(theme, locale, pk.File), Label: s.titleOf(locale, pk)}) |
| + for _, f := range s.families { |
| + if f.Key != pk.Kind { |
| + continue |
| + } |
| + for _, e := range f.Examples { |
| + out = append(out, link{Href: s.href(theme, locale, e.Name+".html"), Label: e.Name}) |
| + } |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestEveryPageEndsWithItsPlaceInTheSequence: walking sequence() on |
| +// day/en, every page's .ds-updown holds the previous and next links |
| +// with the destination's label; the Overview has no previous, the last |
| +// page no next; a family page's next is its first example and the last |
| +// example of a family leads to the next family page. |
| +func TestEveryPageEndsWithItsPlaceInTheSequence(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + theme, locale := fiesta.ThemeNames()[0], "en" |
| + seq := s.sequence(theme, locale) |
| + want := expectedSequence(s, theme, locale) |
| + if len(seq) < 10 { |
| + t.Fatalf("the sequence has %d steps; the walk below would read too little", len(seq)) |
| + } |
| + if len(seq) != len(want) { |
| + t.Fatalf("sequence() has %d steps, the table and the manifest give %d", len(seq), len(want)) |
| + } |
| + for i := range seq { |
| + if seq[i] != want[i] { |
| + t.Errorf("step %d is %+v, want %+v", i, seq[i], want[i]) |
| + } |
| + } |
| + if seq[0].Href != s.href(theme, locale, "index.html") { |
| + t.Errorf("the sequence opens on %s, not the Overview", seq[0].Href) |
| + } |
| + for i, step := range seq { |
| + name := strings.TrimPrefix(step.Href, "/") |
| + page, ok := files[name] |
| + if !ok { |
| + t.Errorf("step %d, %s: no such page", i, name) |
| + continue |
| + } |
| + doc := parse(t, page) |
| + updown, ok := doc.first(byClass("ds-updown")) |
| + if !ok { |
| + t.Errorf("%s has no .ds-updown", name) |
| + continue |
| + } |
| + prev, hasPrev := updown.first(byClass("ds-updown__prev")) |
| + next, hasNext := updown.first(byClass("ds-updown__next")) |
| + if i == 0 { |
| + if hasPrev { |
| + t.Errorf("%s, the first page, has a previous link", name) |
| + } |
| + } else { |
| + if !hasPrev { |
| + t.Errorf("%s has no previous link", name) |
| + } else if href, _ := prev.attr("href"); href != seq[i-1].Href || prev.text() != s.say(locale, "gallery.step.prev", "page", seq[i-1].Label) { |
| + t.Errorf("%s: previous is %q to %s, want %q to %s", name, prev.text(), href, s.say(locale, "gallery.step.prev", "page", seq[i-1].Label), seq[i-1].Href) |
| + } |
| + } |
| + if i == len(seq)-1 { |
| + if hasNext { |
| + t.Errorf("%s, the last page, has a next link", name) |
| + } |
| + } else { |
| + if !hasNext { |
| + t.Errorf("%s has no next link", name) |
| + } else if href, _ := next.attr("href"); href != seq[i+1].Href || next.text() != s.say(locale, "gallery.step.next", "page", seq[i+1].Label) { |
| + t.Errorf("%s: next is %q to %s, want %q to %s", name, next.text(), href, s.say(locale, "gallery.step.next", "page", seq[i+1].Label), seq[i+1].Href) |
| + } |
| + } |
| + } |
| + // The shape the spec describes, read off the expectation the |
| + // implementation was held to above. |
| + for _, f := range s.families { |
| + at := -1 |
| + for i, step := range seq { |
| + if step.Href == s.href(theme, locale, f.Key+".html") { |
| + at = i |
| + } |
| + } |
| + if at < 0 || at+1 >= len(seq) || seq[at+1].Label != f.Examples[0].Name { |
| + t.Errorf("the %s page's next is not its first example %s", f.Key, f.Examples[0].Name) |
| + } |
| + } |
| +} |
| + |
| +// TestTheOverviewRoutesIntoEveryOtherPage: the Overview's .ds-routes |
| +// lists every page kind but itself, in table order, each with a |
| +// non-empty blurb and a link to its file. |
| +func TestTheOverviewRoutesIntoEveryOtherPage(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + theme, locale := fiesta.ThemeNames()[0], "en" |
| + doc := parse(t, files[theme+"/"+locale+"/index.html"]) |
| + routes, ok := doc.first(byClass("ds-routes")) |
| + if !ok { |
| + t.Fatal("the Overview has no .ds-routes") |
| + } |
| + items := routes.find(func(n node) bool { return n.n.Data == "li" }) |
| + kinds := s.pageKinds() |
| + if len(kinds) < 4 { |
| + t.Fatalf("pageKinds() lists %d kinds; the check below would read too little", len(kinds)) |
| + } |
| + var others []pageKind |
| + for _, pk := range kinds { |
| + if pk.Kind != "overview" { |
| + others = append(others, pk) |
| + } |
| + } |
| + if len(items) != len(others) { |
| + t.Fatalf("the Overview routes to %d pages, the table has %d others", len(items), len(others)) |
| + } |
| + for i, pk := range others { |
| + a, ok := items[i].first(func(n node) bool { return n.n.Data == "a" }) |
| + if !ok { |
| + t.Errorf("route %d has no link", i) |
| + continue |
| + } |
| + if href, _ := a.attr("href"); href != s.href(theme, locale, pk.File) { |
| + t.Errorf("route %d links %s, want %s", i, href, s.href(theme, locale, pk.File)) |
| + } |
| + if a.text() != s.titleOf(locale, pk) { |
| + t.Errorf("route %d reads %q, want %q", i, a.text(), s.titleOf(locale, pk)) |
| + } |
| + blurb, ok := items[i].first(func(n node) bool { return n.n.Data == "span" }) |
| + if !ok || blurb.text() == "" { |
| + t.Errorf("route %d (%s) has no blurb", i, pk.Kind) |
| + } |
| + } |
| +} |
| + |
| +// hashedSegment is the sixteen hex digits a hashed name carries before |
| +// its extension. |
| +var hashedSegment = regexp.MustCompile(`\.[0-9a-f]{16}\.[a-z]+$`) |
| + |
| +// TestEveryAssetIsWrittenHashedAndPlainAndPagesLinkTheHash: every path |
| +// under assets/ appears twice, plain and hashed with the same bytes; |
| +// no page or frame links a plain name; every hashed link resolves. |
| +func TestEveryAssetIsWrittenHashedAndPlainAndPagesLinkTheHash(t *testing.T) { |
| + const mount = "/x" |
| + files := render(t, mount) |
| + plain, hashed := map[string]bool{}, map[string]bool{} |
| + for name := range files { |
| + if !strings.HasPrefix(name, "assets/") { |
| + continue |
| + } |
| + if hashedSegment.MatchString(name) { |
| + hashed[name] = true |
| + } else { |
| + plain[name] = true |
| + } |
| + } |
| + if len(plain) == 0 || len(hashed) == 0 { |
| + t.Fatalf("%d plain and %d hashed assets; the pairing below would read nothing", len(plain), len(hashed)) |
| + } |
| + for name := range plain { |
| + ext := name[strings.LastIndex(name, "."):] |
| + stem := strings.TrimSuffix(name, ext) |
| + found := "" |
| + for h := range hashed { |
| + if strings.HasPrefix(h, stem+".") && strings.HasSuffix(h, ext) && len(h) == len(stem)+1+16+len(ext) { |
| + found = h |
| + } |
| + } |
| + if found == "" { |
| + t.Errorf("%s has no hashed sibling", name) |
| + continue |
| + } |
| + if !bytes.Equal(files[name], files[found]) { |
| + t.Errorf("%s and %s differ", name, found) |
| + } |
| + delete(hashed, found) |
| + } |
| + for h := range hashed { |
| + t.Errorf("%s has no plain sibling", h) |
| + } |
| + linked := 0 |
| + for name, body := range files { |
| + if !strings.HasSuffix(name, ".html") { |
| + continue |
| + } |
| + for _, u := range galleryURLs(t, body) { |
| + rel := strings.TrimPrefix(u, mount+"/") |
| + if !strings.HasPrefix(rel, "assets/") { |
| + continue |
| + } |
| + linked++ |
| + if !hashedSegment.MatchString(rel) { |
| + t.Errorf("%s links the plain asset %s", name, u) |
| + } |
| + if _, ok := files[rel]; !ok { |
| + t.Errorf("%s links %s, which the render did not write", name, u) |
| + } |
| + } |
| + } |
| + if linked == 0 { |
| + t.Fatal("no page links an asset; the check above read nothing") |
| + } |
| +} |
| + |
| +// TestEveryPageIsAWholeLocalisedDocument: every page under |
| +// <theme>/<locale>/ has <html lang="<locale>" dir="<Dir(locale)>">, |
| +// one <title>, and exactly one <h1> outside [rst-shell-title]; every |
| +// frame has the lang and dir and one <title>. |
| +func TestEveryPageIsAWholeLocalisedDocument(t *testing.T) { |
| + files := render(t, "") |
| + pages, frames := 0, 0 |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range Locales { |
| + dir := theme + "/" + locale + "/" |
| + for name, body := range files { |
| + if !strings.HasPrefix(name, dir) || !strings.HasSuffix(name, ".html") { |
| + continue |
| + } |
| + doc := parse(t, body) |
| + html, ok := doc.first(func(n node) bool { return n.n.Data == "html" }) |
| + if !ok { |
| + t.Errorf("%s has no <html>", name) |
| + continue |
| + } |
| + if lang, _ := html.attr("lang"); lang != locale { |
| + t.Errorf("%s says lang=%q, want %q", name, lang, locale) |
| + } |
| + if d, _ := html.attr("dir"); d != Dir(locale) { |
| + t.Errorf("%s says dir=%q, want %q", name, d, Dir(locale)) |
| + } |
| + if n := len(doc.find(func(n node) bool { return n.n.Data == "title" })); n != 1 { |
| + t.Errorf("%s has %d <title> elements", name, n) |
| + } |
| + if isFrame(name) { |
| + frames++ |
| + continue |
| + } |
| + pages++ |
| + h1s := doc.find(func(n node) bool { |
| + if n.n.Data != "h1" { |
| + return false |
| + } |
| + for p := n.n.Parent; p != nil; p = p.Parent { |
| + if _, shellTitle := (node{p}).attr("rst-shell-title"); shellTitle { |
| + return false |
| + } |
| + } |
| + return true |
| + }) |
| + if len(h1s) != 1 { |
| + t.Errorf("%s has %d <h1> outside the shell title, want 1", name, len(h1s)) |
| + } |
| + } |
| + } |
| + } |
| + if pages == 0 || frames == 0 { |
| + t.Fatalf("%d pages and %d frames; the walk above read one side only", pages, frames) |
| + } |
| +} |
| + |
| +// anchorIDs is the set of ids on the elements carrying data-ds-anchor. |
| +func anchorIDs(t *testing.T, doc []byte) []string { |
| + t.Helper() |
| + var out []string |
| + for _, n := range parse(t, doc).find(byAttr("data-ds-anchor", "")) { |
| + id, ok := n.attr("id") |
| + if !ok { |
| + t.Errorf("an element carries data-ds-anchor and no id") |
| + } |
| + out = append(out, id) |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// TestAnchorIDsAreTheSameInEveryThemeAndLocale: the data-ds-anchor ids |
| +// on tokens.html, icons.html and button.html are the same set in all |
| +// 36 directories, since they are built from English names. |
| +func TestAnchorIDsAreTheSameInEveryThemeAndLocale(t *testing.T) { |
| + files := render(t, "") |
| + ref := fiesta.ThemeNames()[0] + "/en/" |
| + for _, file := range []string{"tokens.html", "icons.html", "button.html"} { |
| + want := anchorIDs(t, files[ref+file]) |
| + if len(want) == 0 { |
| + t.Errorf("%s%s carries no data-ds-anchor", ref, file) |
| + continue |
| + } |
| + for _, dir := range dirs() { |
| + got := anchorIDs(t, files[dir+file]) |
| + if strings.Join(got, " ") != strings.Join(want, " ") { |
| + t.Errorf("%s%s anchors %v, %s%s anchors %v", dir, file, got, ref, file, want) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestNoPageCarriesTheSameIdTwice: on every page (frames are the |
| +// examples' own documents), every id is unique. |
| +func TestNoPageCarriesTheSameIdTwice(t *testing.T) { |
| + files := render(t, "") |
| + checked := 0 |
| + for name, body := range files { |
| + if !strings.HasSuffix(name, ".html") || isFrame(name) { |
| + continue |
| + } |
| + seen := map[string]int{} |
| + for _, n := range parse(t, body).find(byAttr("id", "")) { |
| + id, _ := n.attr("id") |
| + seen[id]++ |
| + checked++ |
| + } |
| + for id, n := range seen { |
| + if n > 1 { |
| + t.Errorf("%s carries id=%q %d times", name, id, n) |
| + } |
| + } |
| + } |
| + if checked == 0 { |
| + t.Fatal("no id on any page; the check above read nothing") |
| + } |
| +} |
| + |
| +// TestEveryPagesH1IsItsOwnTitle: the <h1> of every page reads the |
| +// page's own name, the first part of its <title>. |
| +func TestEveryPagesH1IsItsOwnTitle(t *testing.T) { |
| + files := render(t, "") |
| + checked := 0 |
| + for name, body := range files { |
| + if !strings.HasSuffix(name, ".html") || isFrame(name) { |
| + continue |
| + } |
| + doc := parse(t, body) |
| + title := titleOf(t, doc) |
| + h1, ok := doc.first(func(n node) bool { return n.n.Data == "h1" }) |
| + if !ok { |
| + t.Errorf("%s has no <h1>", name) |
| + continue |
| + } |
| + page, _, _ := strings.Cut(title, " · ") |
| + if h1.text() != page { |
| + t.Errorf("%s: the <h1> reads %q, the title opens %q", name, h1.text(), page) |
| + } |
| + checked++ |
| + } |
| + if checked == 0 { |
| + t.Fatal("no page was checked") |
| + } |
| +} |
| + |
| +// TestTitlesUseTheApprovedSeparators: a page's <title> is three parts |
| +// joined by " · ", the middle one the site's name, and no part carries |
| +// another separator. The frames' titles are the examples' own. |
| +func TestTitlesUseTheApprovedSeparators(t *testing.T) { |
| + files := render(t, "") |
| + checked := 0 |
| + for name, body := range files { |
| + if !strings.HasSuffix(name, ".html") || isFrame(name) { |
| + continue |
| + } |
| + title := titleOf(t, parse(t, body)) |
| + parts := strings.Split(title, " · ") |
| + if len(parts) != 3 || parts[1] != say("gallery.brand") { |
| + t.Errorf("%s: the title %q is not page · Fiesta · theme", name, title) |
| + } |
| + for _, part := range parts { |
| + for _, piece := range strings.Split(part, ", ") { |
| + if strings.ContainsAny(piece, "·:|–—") || strings.Contains(piece, " - ") { |
| + t.Errorf("%s: the title %q carries a separator that is not · or a comma", name, title) |
| + } |
| + } |
| + } |
| + checked++ |
| + } |
| + if checked == 0 { |
| + t.Fatal("no page was checked") |
| + } |
| +} |
| + |
| +// TestNoTitleOrStateLabelCarriesAnEmDash: no page title, frame title or |
| +// manifest state label carries an em dash. |
| +func TestNoTitleOrStateLabelCarriesAnEmDash(t *testing.T) { |
| + files := render(t, "") |
| + m := manifest(t) |
| + checked := 0 |
| + for name, body := range files { |
| + if !strings.HasSuffix(name, ".html") { |
| + continue |
| + } |
| + if title := titleOf(t, parse(t, body)); strings.Contains(title, "—") { |
| + t.Errorf("%s: the title %q carries an em dash", name, title) |
| + } |
| + checked++ |
| + } |
| + for _, e := range m.Examples { |
| + for _, st := range e.States { |
| + if strings.Contains(st.Label, "—") { |
| + t.Errorf("%s/%s: the label %q carries an em dash", e.Name, st.ID, st.Label) |
| + } |
| + checked++ |
| + } |
| + } |
| + if checked == 0 { |
| + t.Fatal("nothing was checked") |
| + } |
| +} |
| + |
| +// TestEverySamplePictureIsUsedOnce: every file under examples/assets/ |
| +// is the src of at least one frame's picture under its hashed name, |
| +// and under its plain name the src of none. |
| +func TestEverySamplePictureIsUsedOnce(t *testing.T) { |
| + const mount = "/x" |
| + files := render(t, mount) |
| + s := newTestSite(t, fiesta.FS(), mount) |
| + pictures, err := fs.ReadDir(fiesta.FS(), "examples/assets") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(pictures) == 0 { |
| + t.Fatal("examples/assets is empty") |
| + } |
| + srcs := map[string]int{} |
| + for name, body := range files { |
| + if !isFrame(name) { |
| + continue |
| + } |
| + attrsOf(t, body, func(tag string, attrs map[string]string) { |
| + if tag == "img" { |
| + srcs[attrs["src"]]++ |
| + } |
| + }) |
| + } |
| + for _, p := range pictures { |
| + plain := "images/" + p.Name() |
| + hashed, ok := s.hashed[plain] |
| + if !ok { |
| + t.Errorf("%s is not among the assets", plain) |
| + continue |
| + } |
| + if srcs[s.url("assets/"+hashed)] == 0 { |
| + t.Errorf("no frame shows %s", hashed) |
| + } |
| + if n := srcs[s.url("assets/"+plain)]; n > 0 { |
| + t.Errorf("%d frames show %s by its plain name", n, plain) |
| + } |
| + } |
| +} |
| + |
| +// overlay serves the files it holds and everything else from the tree |
| +// under it. |
| +type overlay struct { |
| + fs.FS |
| + files map[string][]byte |
| +} |
| + |
| +func (o overlay) Open(name string) (fs.File, error) { |
| + if b, ok := o.files[name]; ok { |
| + return fstest.MapFS{name: &fstest.MapFile{Data: b}}.Open(name) |
| + } |
| + return o.FS.Open(name) |
| +} |
| + |
| +// withWarning is the source with the password screen's warning in its |
| +// prose, for as long as the real prose.json has none: the title is |
| +// Rastrillo's, carried, and the body the approved note from its second |
| +// sentence on, as the review of the split has it. Once the entry is |
| +// in the file this returns src itself and the test reads the real |
| +// words. |
| +func withWarning(t *testing.T, src fs.FS) fs.FS { |
| + t.Helper() |
| + raw, err := fs.ReadFile(src, proseFile) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var doc map[string]json.RawMessage |
| + if err := json.Unmarshal(raw, &doc); err != nil { |
| + t.Fatal(err) |
| + } |
| + var examples map[string]map[string]json.RawMessage |
| + if err := json.Unmarshal(doc["examples"], &examples); err != nil { |
| + t.Fatal(err) |
| + } |
| + signin := examples["signin"] |
| + if _, ok := signin["warnings"]; ok { |
| + return src |
| + } |
| + var states map[string]string |
| + if err := json.Unmarshal(signin["states"], &states); err != nil { |
| + t.Fatal(err) |
| + } |
| + note := states["email-and-password"] |
| + first, rest, ok := strings.Cut(note, ". ") |
| + if !ok { |
| + t.Fatalf("the email-and-password note has one sentence: %q", note) |
| + } |
| + title, body, ok := strings.Cut(rest, ": ") |
| + if !ok { |
| + t.Fatalf("the note's second sentence has no colon to split at: %q", rest) |
| + } |
| + states["email-and-password"] = first + "." |
| + warnings := map[string]map[string]string{"email-and-password": {"title": title, "body": strings.ToUpper(body[:1]) + body[1:]}} |
| + signin["states"], _ = json.Marshal(states) |
| + signin["warnings"], _ = json.Marshal(warnings) |
| + doc["examples"], _ = json.Marshal(examples) |
| + out, _ := json.Marshal(doc) |
| + t.Logf("prose.json has no signin warning yet; using a scratch entry titled %q", title) |
| + return overlay{src, map[string][]byte{proseFile: out}} |
| +} |
| + |
| +// TestThePasswordScreenCarriesItsWarning: on signin.html the |
| +// email-and-password section holds one warning callout after its note, |
| +// with <strong> the warning's title and <p> its body; no other state |
| +// does. |
| +func TestThePasswordScreenCarriesItsWarning(t *testing.T) { |
| + src := withWarning(t, fiesta.FS()) |
| + files, err := Render(src, Options{}) |
| + if err != nil { |
| + t.Fatalf("Render: %v", err) |
| + } |
| + p, err := loadProse(src) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + warning, ok := p.Examples["signin"].Warnings["email-and-password"] |
| + if !ok || warning.Title == "" || warning.Body == "" { |
| + t.Fatalf("the prose has no whole warning for signin/email-and-password: %+v", warning) |
| + } |
| + e, ok := manifest(t).Entry("signin") |
| + if !ok { |
| + t.Fatal("no signin example") |
| + } |
| + page := files[fiesta.ThemeNames()[0]+"/en/signin.html"] |
| + if len(page) == 0 { |
| + t.Fatal("no day/en/signin.html") |
| + } |
| + for _, st := range e.States { |
| + sec := stateSection(t, page, "signin", st.ID) |
| + callouts := sec.find(func(n node) bool { |
| + _, callout := n.attr("rst-callout") |
| + tone, _ := n.attr("rst-tone") |
| + return callout && tone == "warning" |
| + }) |
| + if st.ID != "email-and-password" { |
| + if len(callouts) != 0 { |
| + t.Errorf("signin/%s carries %d warning callouts", st.ID, len(callouts)) |
| + } |
| + continue |
| + } |
| + if len(callouts) != 1 { |
| + t.Fatalf("signin/%s carries %d warning callouts, want 1", st.ID, len(callouts)) |
| + } |
| + order := sec.find(func(n node) bool { |
| + _, callout := n.attr("rst-callout") |
| + return callout || n.hasClass("ds-note") |
| + }) |
| + if len(order) != 2 || !order[0].hasClass("ds-note") { |
| + t.Errorf("signin/%s: the callout does not follow the note", st.ID) |
| + } |
| + strong, ok := callouts[0].first(func(n node) bool { return n.n.Data == "strong" }) |
| + if !ok || strong.text() != squash(warning.Title) { |
| + t.Errorf("signin/%s: the callout's title reads %q, want %q", st.ID, strong.text(), warning.Title) |
| + } |
| + body, ok := callouts[0].first(func(n node) bool { return n.n.Data == "p" }) |
| + if !ok || body.text() != squash(warning.Body) { |
| + t.Errorf("signin/%s: the callout's body reads %q, want %q", st.ID, body.text(), warning.Body) |
| + } |
| + } |
| +} |
| + |
| +// TestAnExampleDocumentFramesLikeASeededOne: a document exampleDocument |
| +// builds goes through frame like a seeded example does: frame.js first |
| +// in the head, the stylesheets and scripts hashed, the theme's own, |
| +// the locale on <html>, the style kept and the body's links deadened. |
| +// Written against the finished seam; its control is the document |
| +// built with no stylesheet, which frame refuses by name. |
| +func TestAnExampleDocumentFramesLikeASeededOne(t *testing.T) { |
| + s := newTestSite(t, fiesta.FS(), "/x") |
| + e := mustEntry(t, "button") |
| + doc := exampleDocument("A test", []string{"light.js"}, "body { padding: 0 }", `<p><a href="/orders">Orders</a></p>`) |
| + framed, err := s.frame(doc, "plain", "ar", e) |
| + if err != nil { |
| + t.Fatalf("frame: %v", err) |
| + } |
| + for _, want := range []string{ |
| + `<html lang="ar" dir="rtl">`, |
| + `<title>A test</title>`, |
| + `<script src="` + s.asset("frame.js") + `"></script>`, |
| + `href="` + s.asset("css/tokens.css") + `"`, |
| + `href="` + s.asset("css/themes/plain.css") + `"`, |
| + `src="` + s.asset("js/light.js") + `"`, |
| + `<style>body { padding: 0 }</style>`, |
| + `<a href="#">Orders</a>`, |
| + } { |
| + if !strings.Contains(framed, want) { |
| + t.Errorf("the framed document lacks %s:\n%s", want, framed) |
| + } |
| + } |
| + if i, j := strings.Index(framed, "frame."), strings.Index(framed, "tokens."); i < 0 || j < 0 || i > j { |
| + t.Errorf("frame.js is not before the first stylesheet") |
| + } |
| + bare := strings.Replace(strings.Replace(doc, `<link rel="stylesheet" href="../../css/tokens.css">`+"\n", "", 1), `<link rel="stylesheet" href="../../css/themes/day.css">`+"\n", "", 1) |
| + bare = strings.Replace(bare, `<script defer src="../../js/light.js"></script>`+"\n", "", 1) |
| + if _, err := s.frame(bare, "plain", "ar", e); err == nil { |
| + t.Error("frame accepted a document with nothing in its head to put frame.js before") |
| + } |
| +} |
| + |
| +// TestEveryLookupIsTraced: a tracer installed with setTrace hears every |
| +// id a render asks for, chrome, prose and labels alike, and hears |
| +// nothing once removed. This is the hook the translation parity gates |
| +// read. |
| +func TestEveryLookupIsTraced(t *testing.T) { |
| + seen := map[string]int{} |
| + stop := setTrace(func(id string) { seen[id]++ }) |
| + if _, err := Render(fiesta.FS(), Options{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + stop() |
| + for _, id := range []string{"gallery.title", "gallery.step.next", "gallery.locale.name", "families.display.title", "examples.button.blurb", "examples.signin.states.a-passkey", "pages.idioms.box.title", "labels.button.default"} { |
| + if seen[id] == 0 { |
| + t.Errorf("no render asked for %s", id) |
| + } |
| + } |
| + for id := range seen { |
| + if strings.HasPrefix(id, "gallery.direction") { |
| + t.Errorf("a render asked for %s, which is gone", id) |
| + } |
| + } |
| + before := len(seen) |
| + if _, err := Render(fiesta.FS(), Options{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(seen) != before { |
| + t.Errorf("the tracer heard %d ids after it was removed", len(seen)-before) |
| + } |
| +} |
| + |
| +// mustEntry is the manifest entry for name. |
| +func mustEntry(t *testing.T, name string) example.Entry { |
| + t.Helper() |
| + e, ok := manifest(t).Entry(name) |
| + if !ok { |
| + t.Fatalf("no %s example", name) |
| + } |
| + return e |
| +} |
| + |
| +// frameFilesOf is a sanity check on the seam the gates above read: |
| +// one frame per state, under frames/<name>/. |
| +func TestFrameFilesNameOneDocumentPerState(t *testing.T) { |
| + s := newTestSite(t, fiesta.FS(), "") |
| + e := mustEntry(t, "button") |
| + got := s.frameFiles(e) |
| + if len(got) != len(e.States) { |
| + t.Fatalf("frameFiles(button) = %v, want one per state (%d)", got, len(e.States)) |
| + } |
| + for i, st := range e.States { |
| + if want := fmt.Sprintf("frames/%s/%s.html", e.Name, st.ID); got[i] != want { |
| + t.Errorf("frameFiles(button)[%d] = %q, want %q", i, got[i], want) |
| + } |
| + } |
| +} |
diff --git a/internal/gallery/view.css b/internal/gallery/view.css| new file mode 100644 |
| index 0000000..f37e8e8 |
| --- /dev/null |
| +++ b/internal/gallery/view.css |
| @@ -0,0 +1,39 @@ |
| +/* view.css: the preview widget's half of the gallery's stylesheet, |
| + served joined to gallery.css as one file. The frame on its stage, |
| + the HTML disclosure and the code block under it, and the five |
| + highlight colours codeview.Highlight writes; |
| + TestHighlightColoursPassAgainstTheCodeBackground reads those off |
| + this file by their exact lines. */ |
| + |
| +/* One state's frame on its stage. */ |
| +.ds-view { margin: var(--rst-sp-2) 0 0; } |
| +.ds-view__stage { border: 1px solid var(--rst-line); border-radius: var(--rst-radius); overflow: hidden; } |
| +/* A frame shows nothing of its own until gallery.js marks it, once its |
| + document has loaded and has the page's scheme written on it. Before |
| + that it holds about:blank, or a document that has painted in the |
| + OS's scheme while the page is on Light or Dark. Hidden, the box |
| + behind it shows this page's background instead. data-gallery-js |
| + because gallery.js is what shows it: with scripts off nothing |
| + would, and the frames show as they come. */ |
| +.ds-view__frame { block-size: 20rem; border: 0; color-scheme: normal; display: block; inline-size: 100%; } |
| +.ds-view--page .ds-view__frame { block-size: 40rem; resize: vertical; } |
| +:root[data-gallery-js] .ds-view__frame:not([data-gallery-painted]) { visibility: hidden; } |
| + |
| +/* The HTML tab is a disclosure: no script, and the browser's own |
| + control. Its summary is a target like the links above. */ |
| +.ds-view__code > summary { align-items: center; cursor: pointer; display: inline-flex; font-weight: 600; min-block-size: var(--rst-target); } |
| +/* Source soft-wraps rather than scrolling sideways: a sample's row |
| + of attributes is one line, and the page is what has to stay inside |
| + 320px. overflow-x stays for the single token wider than the column. |
| + Code is left to right in every language. */ |
| +.ds-src { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); direction: ltr; margin: var(--rst-sp-2) 0 0; overflow-x: auto; padding: var(--rst-sp-4); text-align: start; unicode-bidi: isolate; } |
| +.ds-src code { overflow-wrap: anywhere; white-space: pre-wrap; } |
| + |
| +/* The five token colours, from theme tokens so they follow theme and |
| + scheme, each at 4.5:1 or better against --rst-surface in every |
| + theme and both schemes. */ |
| +ds-t { color: var(--rst-accent); } |
| +ds-a { color: var(--rst-tone-negative-fg); } |
| +ds-v { color: var(--rst-tone-positive-fg); } |
| +ds-x { color: var(--rst-text-muted); } |
| +ds-s { color: var(--rst-tone-warning-fg); } |
diff --git a/internal/gallery/view.go b/internal/gallery/view.go| new file mode 100644 |
| index 0000000..bc66a95 |
| --- /dev/null |
| +++ b/internal/gallery/view.go |
| @@ -0,0 +1,82 @@ |
| +package gallery |
| + |
| +import ( |
| + "html/template" |
| + "strings" |
| + |
| + "amadan.net/rastrillo/fiesta/example" |
| + "amadan.net/rastrillo/fiesta/internal/codeview" |
| +) |
| + |
| +// The preview widget, in its first form: one frame and the HTML in a |
| +// disclosure under it, under the class names the full widget will |
| +// wear. The Desktop, Mobile and HTML tabs, the measured heights, the |
| +// wrapper line and the grouped rows are the widget task's; the type, |
| +// the template's name and preview's signature are what it fills. |
| + |
| +// previewView is one widget. Group names the widget's radio group, |
| +// unique on the page. Style carries the frame's virtual heights. Class |
| +// is " ds-view--page" on the examples whose document is a whole page |
| +// and empty on the rest, with its own leading space so the template |
| +// writes class="ds-view{{.Class}}" without a conditional. Wrapper is |
| +// the line naming the container a wrapped sample needs; Source the |
| +// markup the HTML tab shows, formatted and highlighted. NoCopy marks a |
| +// block that offers no Copy button. Rows is a grouped example's code, |
| +// one block per state. |
| +type previewView struct { |
| + Group string |
| + Style template.CSS |
| + Class string |
| + Src string |
| + Wrapper template.HTML |
| + Source template.HTML |
| + Title string |
| + NoCopy bool |
| + Rows []rowCode |
| +} |
| + |
| +// rowCode is one state of a grouped example in the HTML panel. |
| +type rowCode struct { |
| + State string |
| + Source template.HTML |
| +} |
| + |
| +// HasHTML says whether the widget draws an HTML tab: it has markup to |
| +// show, as one block or as a grouped example's rows. |
| +func (v previewView) HasHTML() bool { return v.Source != "" || len(v.Rows) > 0 } |
| + |
| +// preview is one state's widget: the frame at frameURL, titled for the |
| +// example and state, and code highlighted for the HTML tab where there |
| +// is any. |
| +func (s *site) preview(p page, e example.Entry, st example.State, frameURL, code string) (previewView, error) { |
| + v := previewView{ |
| + Group: anchorID(e.Name, st.ID), |
| + Src: frameURL, |
| + Title: s.say(p.Locale, "gallery.frame.title", "example", e.Name, "state", s.label(p.Locale, e, st)), |
| + } |
| + if tall(e) { |
| + v.Class = " ds-view--page" |
| + } |
| + if code != "" { |
| + v.Source = codeview.Highlight(codeview.Format(code)) |
| + } |
| + return v, nil |
| +} |
| + |
| +// tall says whether an example's frames are drawn as pages rather |
| +// than fitted to their content: a layout, a screen, a shell idiom, and |
| +// the modal, whose overlay covers whatever viewport it gets. |
| +func tall(e example.Entry) bool { |
| + return e.Document || e.Kind == "screen" || strings.HasPrefix(e.Name, "shell-") || e.Name == "modal" |
| +} |
| + |
| +// viewTemplate is the widget: the frame on its stage, and the HTML in a |
| +// disclosure under it, which is no script and the browser's own |
| +// control. |
| +const viewTemplate = `{{define "ds-view"}}<div class="ds-view{{.Class}}"{{with .Style}} style="{{.}}"{{end}}> |
| +<div class="ds-view__stage"><iframe class="ds-view__frame" title="{{.Title}}" src="{{.Src}}" loading="lazy"></iframe></div> |
| +{{if .HasHTML}}<details class="ds-view__code"><summary>{{say "gallery.html"}}</summary> |
| +{{if .Wrapper}}<p class="ds-wrap">{{.Wrapper}}</p> |
| +{{end}}<pre class="ds-src rst-mono" tabindex="0"{{if .NoCopy}} data-ds-nocopy{{end}}><code>{{.Source}}</code></pre> |
| +</details> |
| +{{end}}</div>{{end}}` |
diff --git a/internal/gallery/view.js b/internal/gallery/view.js| new file mode 100644 |
| index 0000000..b6dc1b7 |
| --- /dev/null |
| +++ b/internal/gallery/view.js |
| @@ -0,0 +1,118 @@ |
| +/* view.js: the preview widget's half of the gallery's script, served |
| + joined after gallery.js as one file, and no part of the design |
| + system. It is the frame-painting half of rastrillo's gallery script, |
| + and nothing else: the scheme itself is fiesta.js's, from the scheme |
| + menu in the header, and this file only carries what fiesta.js |
| + decided into every preview frame. |
| + |
| + Every example on the page is a document of its own inside an |
| + iframe, and an iframe does not inherit the reader's choice: a colour |
| + scheme is not propagated into an embedded document that declares |
| + one, and every preview links a theme that does. The frames are |
| + same-origin, so the fix is the attribute fiesta.js wrote on this |
| + page's <html>, written again on each of them: at the frame's load, |
| + and on every rst-scheme event after. frame.js, first in each frame's |
| + head, does the same for itself before its first paint, so a frame |
| + loaded after the choice starts in it. |
| + |
| + A frame is hidden by view.css until this file marks it painted, |
| + which is why the joined script is a blocking script in <head>: the |
| + marker that turns the hiding rule on has to be there before the |
| + first frame can paint in the wrong scheme. With scripts off the |
| + marker is never set, nothing is hidden, and the frames show as they |
| + come, in the OS's scheme, which is what System means anyway. */ |
| +(function () { |
| + "use strict"; |
| + |
| + var root = document.documentElement; |
| + root.setAttribute("data-gallery-js", "on"); |
| + |
| + // The scheme in force on this page: fiesta.js keeps it on <html>, |
| + // and no attribute is System. |
| + function scheme() { |
| + return root.getAttribute("data-theme"); |
| + } |
| + |
| + function readable(frame) { |
| + try { |
| + var doc = frame.contentDocument; |
| + return doc && doc.documentElement && doc.URL !== "about:blank" ? doc : null; |
| + } catch (e) { |
| + return null; |
| + } |
| + } |
| + |
| + function paint(frame, s) { |
| + var doc = readable(frame); |
| + if (!doc) return; |
| + if (s === "light" || s === "dark") doc.documentElement.setAttribute("data-theme", s); |
| + else doc.documentElement.removeAttribute("data-theme"); |
| + } |
| + |
| + // A component's frame is as tall as its document, between a floor |
| + // and a ceiling; a page's frame keeps the stylesheet's height, since |
| + // a page lays itself out to whatever viewport it gets. Measured off |
| + // the body's box rather than scrollHeight, which is never less than |
| + // the frame it is measured in. The page class is the widget's |
| + // (ds-view--page on .ds-view), not the frame's own, so it is read |
| + // off the nearest widget. |
| + function fit(frame) { |
| + var doc = readable(frame); |
| + if (!doc || !doc.body || frame.closest(".ds-view--page")) return; |
| + var body = doc.body, style = doc.defaultView.getComputedStyle(body); |
| + var h = body.getBoundingClientRect().bottom + (parseFloat(style.marginBottom) || 0) + 16; |
| + frame.style.blockSize = Math.min(Math.max(Math.round(h), 160), 720) + "px"; |
| + } |
| + |
| + // Shown even if it could not be painted: a frame in the wrong scheme |
| + // beats one that never appears. |
| + function shown(frame) { |
| + paint(frame, scheme()); |
| + fit(frame); |
| + frame.setAttribute("data-gallery-painted", ""); |
| + } |
| + |
| + function frames() { |
| + return document.querySelectorAll(".ds-view__frame"); |
| + } |
| + |
| + function all(s) { |
| + var f = frames(); |
| + for (var i = 0; i < f.length; i++) paint(f[i], s); |
| + } |
| + |
| + // A frame's document is painted and shown at its load, or at once |
| + // when it was already complete before this ran (a frame served from |
| + // cache, or a page whose frames loaded before DOMContentLoaded). |
| + function watch(frame) { |
| + frame.addEventListener("load", function () { |
| + shown(frame); |
| + }); |
| + var doc = readable(frame); |
| + if (doc && doc.readyState === "complete") shown(frame); |
| + } |
| + |
| + // fiesta.js dispatches rst-scheme from the form after it has written |
| + // the attribute, so the detail and the root agree; the detail is |
| + // read all the same, so this file never depends on the order. |
| + document.addEventListener("rst-scheme", function (event) { |
| + var s = event.detail && event.detail.scheme; |
| + all(s === "light" || s === "dark" ? s : null); |
| + }); |
| + |
| + // A page back from the back/forward cache is reactivated, not |
| + // re-run, and fiesta.js may have restored a stored choice since. |
| + addEventListener("pageshow", function (event) { |
| + if (event.persisted) all(scheme()); |
| + }); |
| + |
| + function ready(fn) { |
| + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); |
| + else fn(); |
| + } |
| + |
| + ready(function () { |
| + var f = frames(); |
| + for (var i = 0; i < f.length; i++) watch(f[i]); |
| + }); |
| +})(); |
diff --git a/internal/gallery/weight.go b/internal/gallery/weight.go| new file mode 100644 |
| index 0000000..1714c44 |
| --- /dev/null |
| +++ b/internal/gallery/weight.go |
| @@ -0,0 +1,67 @@ |
| +package gallery |
| + |
| +import ( |
| + "strings" |
| + |
| + "golang.org/x/text/language" |
| + "golang.org/x/text/message" |
| + "golang.org/x/text/number" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/build" |
| +) |
| + |
| +// What a file weighs. The Getting started page gives each shipped file |
| +// two figures, the bytes as written and gzipped, computed at render |
| +// time from the release build's bytes. Neither is typed into prose or |
| +// pinned in a test: compressor output can move between Go versions, so |
| +// a golden figure would churn on a toolchain bump while proving |
| +// nothing. Rastrillo's third figure, gzipped without comments, has no |
| +// place here: a release file is already stripped, so the line's third |
| +// part is always dropped. |
| + |
| +// weightSep is the separator the weight line's parts are cut at. The |
| +// stripped figure is the last part after it in every language, so a |
| +// file with no stripped figure loses that part rather than repeating |
| +// the gzipped figure under a different name. |
| +const weightSep = " · " |
| + |
| +// weight is one file's figures, in bytes. |
| +type weight struct { |
| + Raw, Gzip int |
| +} |
| + |
| +// weigh measures one file: its length, and its length gzipped at level |
| +// 6, the level a server or edge that compresses on the fly typically |
| +// uses, through the release build's own compressor so the two agree. |
| +func weigh(body []byte) weight { |
| + return weight{Raw: len(body), Gzip: build.Gzipped(body)} |
| +} |
| + |
| +// kib formats a byte count in KiB (1024; lowercase "kb" is kilobits) |
| +// in the page's own number format: one decimal below 10 KiB, where |
| +// rounding to a whole number would turn a 634-byte theme into "1" or |
| +// "0", and whole numbers above, where a decimal is noise. The digits |
| +// and separators are the locale's own, as x/text writes them. |
| +func kib(locale string, n int) string { |
| + tag, err := language.Parse(locale) |
| + if err != nil { |
| + tag = language.English |
| + } |
| + p := message.NewPrinter(tag) |
| + v := float64(n) / 1024 |
| + if n < 10*1024 { |
| + return p.Sprint(number.Decimal(v, number.MinFractionDigits(1), number.MaxFractionDigits(1))) |
| + } |
| + return p.Sprint(number.Decimal(v, number.MaxFractionDigits(0))) |
| +} |
| + |
| +// weightIn is one file's line in one locale: the weight line with its |
| +// last part, the stripped figure, cut off, since no release file has |
| +// one, and the two figures written in. |
| +func (s *site) weightIn(locale string, w weight) string { |
| + line := s.say(locale, "gallery.started.weight") |
| + if i := strings.LastIndex(line, weightSep); i >= 0 { |
| + line = line[:i] |
| + } |
| + return interpolate(line, []string{"raw", kib(locale, w.Raw), "gzip", kib(locale, w.Gzip)}) |
| +} |
diff --git a/internal/gallery/weight_test.go b/internal/gallery/weight_test.go| new file mode 100644 |
| index 0000000..1fbe01e |
| --- /dev/null |
| +++ b/internal/gallery/weight_test.go |
| @@ -0,0 +1,85 @@ |
| +package gallery |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| +) |
| + |
| +// What a file weighs, written the way the page's language writes a |
| +// number. The figures are the compressor's and are never pinned; the |
| +// formatting and the shape of the line are. |
| + |
| +// KiB of 1024, one decimal below 10 KiB so a 634-byte file is not "1" |
| +// or "0", whole numbers above, in the locale's own number format. |
| +func TestKiBIsOneDecimalBelowTenAndWholeAbove(t *testing.T) { |
| + for _, c := range []struct { |
| + locale string |
| + n int |
| + want string |
| + }{ |
| + {"en", 634, "0.6"}, |
| + {"en", 1024, "1.0"}, |
| + {"en", 10*1024 - 1, "10.0"}, |
| + {"en", 10 * 1024, "10"}, |
| + {"en", 107279, "105"}, |
| + {"en", 2048 * 1024, "2,048"}, |
| + {"es", 634, "0,6"}, |
| + {"ru", 4300, "4,2"}, |
| + } { |
| + if got := kib(c.locale, c.n); got != c.want { |
| + t.Errorf("kib(%s, %d) = %q, want %q", c.locale, c.n, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// A locale's own digits and separators, as x/text writes them: Arabic |
| +// in Arabic-Indic digits with its own decimal mark, Bengali in Bengali |
| +// digits, Hindi in Latin digits with Indian grouping. |
| +func TestKiBWritesTheLocalesDigits(t *testing.T) { |
| + for _, c := range []struct { |
| + locale string |
| + n int |
| + want string |
| + }{ |
| + {"ar", 24576, "٢٤"}, |
| + {"ar", 4300, "٤٫٢"}, |
| + {"bn", 24576, "২৪"}, |
| + {"hi", 123456 * 1024, "1,23,456"}, |
| + } { |
| + if got := kib(c.locale, c.n); got != c.want { |
| + t.Errorf("kib(%s, %d) = %q, want %q", c.locale, c.n, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// The weight line has three parts split on " · ", the stripped figure |
| +// alone in the last, so a file with no stripped figure, which is every |
| +// release file, loses exactly that part. Every locale the line has a |
| +// translation in is held to the shape; today that is English alone. |
| +func TestTheWeightLineDropsItsLastPartCleanly(t *testing.T) { |
| + s := newTestSite(t, fiesta.FS(), "") |
| + w := weight{Raw: 20000, Gzip: 6000} |
| + for _, locale := range Locales { |
| + line := s.say(locale, "gallery.started.weight") |
| + parts := strings.Split(line, weightSep) |
| + if len(parts) != 3 { |
| + t.Errorf("%s: the weight line has %d parts split on %q, want 3: %q", locale, len(parts), weightSep, line) |
| + continue |
| + } |
| + if !strings.Contains(parts[2], "{stripped}") || strings.Contains(parts[0]+parts[1], "{stripped}") { |
| + t.Errorf("%s: {stripped} is not alone in the last part: %q", locale, line) |
| + } |
| + got := s.weightIn(locale, w) |
| + if strings.Count(got, weightSep) != 1 || strings.Contains(got, "{") || got == "" { |
| + t.Errorf("%s: with nothing to strip the line should be two figures: %q", locale, got) |
| + } |
| + if !strings.Contains(got, kib(locale, w.Raw)) || !strings.Contains(got, kib(locale, w.Gzip)) { |
| + t.Errorf("%s: the line %q does not carry both figures", locale, got) |
| + } |
| + } |
| + if g := weigh([]byte(strings.Repeat("abc ", 1000))); g.Raw != 4000 || g.Gzip <= 0 || g.Gzip >= g.Raw { |
| + t.Errorf("weigh(4000 bytes of repetition) = %+v; want Raw 4000 and a smaller Gzip", g) |
| + } |
| +} |
diff --git a/internal/www/site_browser_test.go b/internal/www/site_browser_test.go| index a213c62..de9426d 100644 |
| --- a/internal/www/site_browser_test.go |
| +++ b/internal/www/site_browser_test.go |
| @@ -9,6 +9,7 @@ import ( |
| "context" |
| "fmt" |
| "net/http" |
| + "regexp" |
| "strings" |
| "sync" |
| "testing" |
| @@ -78,13 +79,13 @@ func TestTheSiteServesFromItsOwnOrigin(t *testing.T) { |
| // nothing of the kind the drive is here for cannot pass: the |
| // landing pages their theme through the /1/ rewrite, the example |
| // its frames. |
| - expects := map[string]string{ |
| - "/": "/1/fiesta.css", |
| - "/ar/": "/1/fiesta.css", |
| - "/design-system/": "/design-system/assets/gallery.css", |
| - "/design-system/day/dropdown.html": "/design-system/day/frames/dropdown/", |
| + expects := map[string]*regexp.Regexp{ |
| + "/": regexp.MustCompile(`^/1/fiesta\.css`), |
| + "/ar/": regexp.MustCompile(`^/1/fiesta\.css`), |
| + "/design-system/": regexp.MustCompile(`^/design-system/assets/gallery\.[0-9a-f]{16}\.css$`), |
| + "/design-system/day/en/dropdown.html": regexp.MustCompile(`^/design-system/day/en/frames/dropdown/`), |
| } |
| - for _, p := range []string{"/", "/ar/", "/design-system/", "/design-system/day/dropdown.html"} { |
| + for _, p := range []string{"/", "/ar/", "/design-system/", "/design-system/day/en/dropdown.html"} { |
| tr.take() |
| mustRun(t, ctx, chromedp.Navigate(rig.Origin+p), chromedp.WaitReady("body")) |
| deadline := time.Now().Add(20 * time.Second) |
| @@ -111,7 +112,7 @@ func TestTheSiteServesFromItsOwnOrigin(t *testing.T) { |
| if !strings.HasPrefix(u, rig.Origin+"/") && !strings.HasPrefix(u, "data:") { |
| t.Errorf("%s: a request left the origin: %s", p, u) |
| } |
| - asked = asked || strings.HasPrefix(u, rig.Origin+expects[p]) |
| + asked = asked || expects[p].MatchString(strings.TrimPrefix(u, rig.Origin)) |
| } |
| if !asked { |
| t.Errorf("%s: nothing under %s was requested; this is not the page the drive is for, or its frames never loaded", p, expects[p]) |
diff --git a/internal/www/www_test.go b/internal/www/www_test.go| index 91126e7..f33e76a 100644 |
| --- a/internal/www/www_test.go |
| +++ b/internal/www/www_test.go |
| @@ -12,6 +12,7 @@ import ( |
| xhtml "golang.org/x/net/html" |
| "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/gallery" |
| "amadan.net/rastrillo/fiesta/internal/site" |
| "amadan.net/rastrillo/fiesta/internal/www" |
| ) |
| @@ -79,7 +80,7 @@ func TestPagesHasARootAndTheDesignSystem(t *testing.T) { |
| // the site's root. |
| func TestTheDesignSystemIsMounted(t *testing.T) { |
| out := pages(t, siteOf(nil)) |
| - for _, name := range []string{"design-system/index.html", "design-system/day/button.html"} { |
| + for _, name := range []string{"design-system/index.html", "design-system/day/en/button.html"} { |
| doc, ok := out[name] |
| if !ok { |
| t.Errorf("Pages wrote no %s", name) |
| @@ -101,6 +102,15 @@ func TestTheDesignSystemIsMounted(t *testing.T) { |
| } |
| } |
| +// TestTheReleaseBaseIsTheSitesOrigin: the base the design system |
| +// writes its release-file links under is this site's origin and its |
| +// /1/ rewrite, so a link to a shipped file is one of the site's own. |
| +func TestTheReleaseBaseIsTheSitesOrigin(t *testing.T) { |
| + if gallery.ReleaseBase != www.Origin+"/1/" { |
| + t.Errorf("gallery.ReleaseBase is %q, want %q", gallery.ReleaseBase, www.Origin+"/1/") |
| + } |
| +} |
| + |
| // TestAHiddenSiteFileIsRefused: a hidden file under the site source |
| // fails the build, and the error names it, rather than being skipped |
| // or published. |