rastrillo / fiesta Public

Getting started: what ships and what it weighs; the icons' Lucide names; the Overview's first paragraph

Three pages' worth of content on the seams Task 2 left for them, in the files the plan gives this stream and no other.

Getting started (started.go) is a page kind of its own, the row after the Overview in pageKinds. Its body lists the release build: build.Dist(src, fiesta.Version) read while the page renders, every file in the spec's order (the one-file themes with fiesta.css first, tokens.css, each theme, shell.css, the eight scripts fiesta.js first and calendar.js last, the sprite, the strings for each language), each with its anchor, its blurb from copy.go, a link to the file under ReleaseBase marked data-gallery-site, and its weight as weightIn writes it from weigh() of the release bytes. No figure is typed anywhere. A file the release ships and the page does not list fails the render by name, as does a listed file the release lacks. The total is fiesta.css and the scripts summed, with kibUnit's unit. The one-link and own-copy blocks are codeview.Highlight of the two link forms, in code marked translate="no", so the site's link gate resolves the address in the first. assetNav is one rail entry per row, derived from the same view.

The rail asks every page kind's Nav on every page of the tree, so a release build and forty gzips a page was most of the render (the weights test took 73s). release() builds and weighs once per site and keeps it while the site lives, in a package-level map keyed by weak.Pointer[site] and cleared by runtime.AddCleanup, since site has no field for it and gallery.go is not this stream's to edit. The render is back at about two seconds.

The Icons page (icons_page.go) prints under each chip the address lucide.dev/icons/<name> from lucideNames, Fiesta's slug to the name lucide.dev publishes for the five that differ; then the names section with the renamed count and the total both counted from the set; then the source section with the one link that leaves the tree, in a new tab with the sr-only note. Each of the twelve names was checked at lucide.dev/icons/<name>: kebab is ellipsis-vertical, alert-triangle is triangle-alert, check-circle is circle-check, x-circle is circle-x, and help-circle's published name is circle-question-mark, not the circle-help the plan wrote (lucide.dev/icons/circle-help lands on circle-question-mark). outboundLinks is the whole list of off-tree addresses, lucide.dev alone, with its reason.

The Overview opens with <p class="ds-intro">, the reviewed intro, before everything else in its body. The lead under it is left as it was; which of the two the finished Overview keeps is the integration task's call. internal/www's outsideURLs gains https://lucide.dev.

Tests, each red first by assertion against the stubs of the seams (types present, buildAssets and assetNav returning nothing, lucideNames empty, no row): TestTheGettingStartedPageWeighsTheRealAssets (the render wrote no day/en/getting-started.html, in all thirty-six directories), TestEveryReleaseLinkIsTheOneLinkForm (no data-gallery-site link in the tree), TestTheIconsPageIsAReadingOfIconSlugs (the chip for alert-triangle has 1 value lines, want the file and the address), TestTheIntroOpensTheOverview (day/en/index.html carries 0 .ds-intro, want 1), TestEveryOutboundLinkIsAllowedAndUsed (outboundLinks names https://lucide.dev and nothing in the tree links it; no off-tree links found at all). The icons test also renders a scratch source with one more icon and sees the page grow by one chip and one address.

Already passing when written, labelled so in its comment: TestEveryShippedFileWeighsLessCompressed, every release file gzipping below its raw size; mutation run in the gate worktree, weigh reporting Gzip as max(Raw, Gzip), reported for every file (icons.svg weighs 2721 as written and 2721 gzipped; gzip must shrink it). TestTreeShapeIsComplete and TestEveryPageEndsWithItsPlaceInTheSequence pick the new row up through pageKinds with no edit; the row itself is held by the weights test, whose control was run: File misspelled geting-started.html, reported as the render wrote no day/en/getting-started.html while both tree gates stayed green, since their output and expectation follow the misspelling together.

Committed with git commit --only on this stream's six paths while three other streams' uncommitted files sat in the shared worktree; gated with make ci in a detached /var/tmp worktree at HEAD with only these six files copied in. In the shared tree, TestEveryShellHasABlurb in another stream's uncommitted view_test.go was failing; nothing of this commit touches it.
Human

Prompt

Implement Task 6 (Getting started, the Icons page, the Overview's intro) of the Fiesta part 4 plan, in parallel with three other implementers in the same worktree, editing only the Files block: started.go and started_test.go new; pages.go (the one row), icons_page.go, overview.go (the intro), internal/www/links_test.go (lucide.dev). Use Task 2's seams; copy is in copy.go already; tests red by name first; make ci in a detached worktree; commit through amadan commit with explicit paths.

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

A Getting started page kind after the Overview, listing the release build with each file's weight measured at render time, linked under ReleaseBase with data-gallery-site, with a rail entry per file and a per-site cache of the weighed release. The Icons page prints each icon's lucide.dev address, counts the renamed names and links the set once. The Overview opens with the reviewed intro. internal/www allows https://lucide.dev. Six tests, five red first, one labelled already passing with its mutation run.
Agent notes
help-circle's lucide.dev name is circle-question-mark, not the plan's circle-help; lucideNames says so and the plan should follow. The release is weighed once per site in started.go's releases map (weak.Pointer key, runtime.AddCleanup), because galleryNav calls assetNav on every page; a site field would be simpler if gallery.go is ever this stream's. The Overview still renders gallery.index.lead under the intro; the integration task decides whether it stays. The strings rows name each language in itself (gallery.locale.name in that locale), as the language menu does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 87698c1c519e13a09608f79e44ea091ca2d13489 parent 682572c
6 files changed, +906 −18
  • internal/gallery/icons_page.go +101 −9
  • internal/gallery/overview.go +3 −2
  • internal/gallery/pages.go +4 −4
  • internal/gallery/started.go +287 −0
  • internal/gallery/started_test.go +506 −0
  • internal/www/links_test.go +5 −3

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.123s
=== 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.022s
=== suite ===
--- shard 0 of 12 ---
==> shard 0 of 12: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
ok  	amadan.net/rastrillo/fiesta	1.139s
ok  	amadan.net/rastrillo/fiesta/check	5.205s
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	50.748s
ok  	amadan.net/rastrillo/fiesta/example	0.196s
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.124s
ok  	amadan.net/rastrillo/fiesta/internal/build	1.448s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.005s
ok  	amadan.net/rastrillo/fiesta/internal/gallery	34.610s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.012s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.372s
ok  	amadan.net/rastrillo/fiesta/internal/site	3.157s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.019s
ok  	amadan.net/rastrillo/fiesta/internal/www	41.975s
--- 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' .
1:17AM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
1:17AM INF 1 commits scanned.
1:17AM INF scanned ~5976198 bytes (5.98 MB) in 3.78s
1:17AM INF no leaks found
--- 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	12.371s
--- 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	212.535s
--- 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	223.544s
--- 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	228.986s
--- 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 4 of 56 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	98.055s
--- 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 52 of 56 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	72.680s
--- 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	84.283s
--- 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	76.266s
diff --git a/internal/gallery/icons_page.go b/internal/gallery/icons_page.go
index ba66e19..f4486fa 100644
--- a/internal/gallery/icons_page.go
+++ b/internal/gallery/icons_page.go
@@ -3,24 +3,116 @@ package gallery
import (
"fmt"
"html/template"
+ "strings"
)
-// 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}}
+// The Icons page: one chip per icon, drawn the way a component draws
+// it, with its slug, the file to copy and where the glyph comes from.
+// Nothing in this file names an icon or counts them: the chips are
+// s.icons, read off icons/*.svg, and both numbers in the sentence
+// under the grid are counted from the set. The one list here is the
+// names Fiesta gives glyphs that Lucide names otherwise.
+
+// lucideHome is the one address in this tree that is not in this tree:
+// where the glyphs come from, and where a reader goes to see the rest
+// of the set. outboundLinks is the gate's side of the same decision.
+const lucideHome = "https://lucide.dev"
+
+// lucideNames is Fiesta's slug to the name lucide.dev publishes, for
+// the slugs where the two differ; every other slug is Lucide's own
+// name. Each was checked at lucide.dev/icons/<name> on 2026-10-29.
+// help-circle's published name is circle-question-mark: the address
+// lucide.dev/icons/circle-help now lands on that page.
+var lucideNames = map[string]string{
+ "kebab": "ellipsis-vertical",
+ "alert-triangle": "triangle-alert",
+ "check-circle": "circle-check",
+ "help-circle": "circle-question-mark",
+ "x-circle": "circle-x",
+}
+
+// outboundLinks is the whole list of addresses the tree may point at
+// that are not in it, each with the reason it is there. The table is
+// exact strings, not prefixes, and every entry has to be linked by some
+// page; TestEveryOutboundLinkIsAllowedAndUsed holds both halves, so it
+// cannot become a licence for the next link somebody feels like adding.
+var outboundLinks = map[string]string{
+ lucideHome: "where the glyphs come from: the set the icons are copied from, under the ISC licence, and the only place a reader can see the rest of it",
+}
+
+// lucideName is what lucide.dev calls the icon Fiesta calls slug.
+func lucideName(slug string) string {
+ if name, ok := lucideNames[slug]; ok {
+ return name
+ }
+ return slug
+}
+
+// iconView is one chip: its anchor, its slug, the glyph, and its
+// provenance, an address rather than a sentence (lucide.dev/icons/…)
+// so it needs no translation and a reader can type it.
+type iconView struct {
+ ID, Slug, Provenance string
+ Markup template.HTML
+}
+
+// iconsView is the page's data. Total and Renamed are counted from
+// the set rather than written into the sentence, so the sentence
+// cannot be wrong about the set it describes. Lucide is the line that
+// links the set, with the link in it.
+type iconsView struct {
+ List []iconView
+ Total, Renamed string
+ Lucide template.HTML
+}
+
+// buildIcons reads the set for one locale's page.
+func (s *site) buildIcons(locale string) iconsView {
+ out := iconsView{Total: fmt.Sprint(len(s.icons))}
+ renamed := 0
+ for _, ic := range s.icons {
+ name := lucideName(ic.Slug)
+ if name != ic.Slug {
+ renamed++
+ }
+ out.List = append(out.List, iconView{
+ ID: anchorID("icon", ic.Slug),
+ Slug: ic.Slug,
+ Provenance: "lucide.dev/icons/" + name,
+ Markup: ic.Markup,
+ })
+ }
+ out.Renamed = fmt.Sprint(renamed)
+ link := `<a href="` + lucideHome + `" target="_blank" rel="noopener">lucide.dev<span class="rst-sr-only"> (` +
+ template.HTMLEscapeString(s.say(locale, "gallery.newtab")) + `)</span></a>`
+ out.Lucide = template.HTML(strings.Replace(template.HTMLEscapeString(s.say(locale, "gallery.icons.lucide")), "{link}", link, 1))
+ return out
+}
+
+// iconsBody is every slug, drawn at the size tokens.css gives .icon,
+// each with its file and its lucide.dev address; then the names that
+// are Fiesta's own, with the count; then where the glyphs come from,
+// with the one link that leaves the tree.
+const iconsBody = `{{define "ds-body-icons"}}<p class="ds-lead">{{say "gallery.icons.lead" "total" .Icons.Total}}</p>
+<ul class="ds-toks ds-icons">{{range .Icons.List}}
+<li class="ds-tok" id="{{.ID}}" 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 class="ds-tok__value">{{.Provenance}}</span></span></li>{{end}}
</ul>
+
+<h2 class="ds-sub">{{say "gallery.icons.names.heading"}}</h2>
+<p class="ds-lead">{{say "gallery.icons.names.body" "renamed" .Icons.Renamed "total" .Icons.Total}}</p>
+<p class="ds-note">{{say "gallery.icons.kebab"}}</p>
+
+<h2 class="ds-sub">{{say "gallery.icons.source"}}</h2>
+<p class="ds-lead">{{say "gallery.icons.source.body"}}</p>
+<p class="ds-note">{{.Icons.Lucide}}</p>
{{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))})
+ Icons iconsView
+ }{p, s.buildIcons(p.Locale)})
}
// iconMarkup is one of Fiesta's icons, inline, for the gallery's own
diff --git a/internal/gallery/overview.go b/internal/gallery/overview.go
index 1c18d07..5aff47e 100644
--- a/internal/gallery/overview.go
+++ b/internal/gallery/overview.go
@@ -8,8 +8,9 @@ import (
// 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>
+// lands. The intro opens it; the demo application is a later task's.
+const overviewBody = `{{define "ds-body-overview"}}<p class="ds-intro">{{say "gallery.overview.intro"}}</p>
+<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}}
diff --git a/internal/gallery/pages.go b/internal/gallery/pages.go
index a97884e..1bd5b47 100644
--- a/internal/gallery/pages.go
+++ b/internal/gallery/pages.go
@@ -50,13 +50,13 @@ type pageKind struct {
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.
+// pageKinds is the table, in page order: the Overview, Getting
+// started, Tokens, Icons, then one row per family in the gallery's
+// family order.
func (s *site) pageKinds() []pageKind {
kinds := []pageKind{
{Kind: "overview", File: "index.html", Title: "gallery.kind.overview", Body: s.overviewBody},
+ {Kind: "getting-started", File: "getting-started.html", Title: "gallery.kind.getting-started", Blurb: "gallery.routes.getting-started", Nav: s.assetNav, Body: s.startedBody},
{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.routes.icons", Nav: s.iconNav, Body: s.iconsBody},
}
diff --git a/internal/gallery/started.go b/internal/gallery/started.go
new file mode 100644
index 0000000..eba3b91
--- /dev/null
+++ b/internal/gallery/started.go
@@ -0,0 +1,287 @@
+package gallery
+
+import (
+ "fmt"
+ "html/template"
+ "runtime"
+ "sort"
+ "strings"
+ "sync"
+ "weak"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/internal/build"
+ "amadan.net/rastrillo/fiesta/internal/codeview"
+)
+
+// Getting started: what ships and what it weighs, read off the release
+// build while the page renders. Every weight on the page is weigh() of
+// the bytes build.Dist returns for fiesta.Version; no figure is written
+// in copy.go or here, so a page about file sizes stays true after
+// someone edits a file. Each row links its file where the site serves
+// it, under ReleaseBase, marked data-gallery-site: the one link form
+// internal/www resolves through its redirect to the newest release.
+//
+// Rastrillo's page lists what a scaffolded app is handed; this one
+// lists the release, so the rows and the total are Fiesta's own, while
+// the view types, the rail reader and the weight line keep Rastrillo's
+// names.
+
+// assetView is one shipped file: what it is called, where the site
+// serves it, what it weighs (the finished line in the page's language)
+// and what it is for.
+type assetView struct{ ID, Name, Href, Weight, Blurb string }
+
+// assetsView is the page's data: the rows in page order, the total a
+// page that links fiesta.css and every script pays, and the two code
+// blocks, highlighted: the one link, and the two-file form for a site
+// serving its own copy.
+type assetsView struct {
+ List []assetView
+ Total string
+ Link, Own template.HTML
+}
+
+// scriptOrder is the order the scripts are listed in: fiesta.js first
+// and calendar.js last, as the page says to load them, the rest as the
+// scripts paragraph names them. A script in the release and not here
+// fails the render by name, so a ninth script cannot ship off this
+// page.
+var scriptOrder = []string{"fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "select.js", "datetime.js", "calendar.js"}
+
+// blurbs is the copy id of each shipped file whose sentence has no
+// placeholder. The one-file themes, the themes and the strings take
+// theirs with a theme or a language written in; see buildAssets.
+var blurbs = map[string]string{
+ "tokens.css": "gallery.started.file.tokens",
+ "shell.css": "gallery.started.file.shell-css",
+ "fiesta.js": "gallery.started.file.fiesta-js",
+ "busy.js": "gallery.started.file.busy-js",
+ "light.js": "gallery.started.file.light-js",
+ "anchor.js": "gallery.started.file.anchor-js",
+ "shell.js": "gallery.started.file.shell-js",
+ "select.js": "gallery.started.file.select-js",
+ "datetime.js": "gallery.started.file.datetime-js",
+ "calendar.js": "gallery.started.file.calendar-js",
+ "icons.svg": "gallery.started.file.sprite",
+}
+
+// release is the release build of one site's source, reduced to what
+// the page needs: every file's weight, by name.
+type release map[string]weight
+
+// releases is each live site's release, built once. The rail asks
+// assetNav for the rows on every page of the tree, and a release build
+// and forty gzips a page would be most of the render. The site has no
+// field for it, so the cache is here, keyed by a weak pointer to the
+// site and cleared when the site is collected.
+var releases struct {
+ sync.Mutex
+ m map[weak.Pointer[site]]release
+}
+
+// release is s's release build, weighed, built on the first call and
+// kept while s lives.
+func (s *site) release() (release, error) {
+ key := weak.Make(s)
+ releases.Lock()
+ defer releases.Unlock()
+ if rel, ok := releases.m[key]; ok {
+ return rel, nil
+ }
+ dist, err := build.Dist(s.src, fiesta.Version)
+ if err != nil {
+ return nil, err
+ }
+ rel := make(release, len(dist))
+ for name, body := range dist {
+ rel[name] = weigh(body)
+ }
+ if releases.m == nil {
+ releases.m = map[weak.Pointer[site]]release{}
+ }
+ releases.m[key] = rel
+ runtime.AddCleanup(s, func(k weak.Pointer[site]) {
+ releases.Lock()
+ delete(releases.m, k)
+ releases.Unlock()
+ }, key)
+ return rel, nil
+}
+
+// buildAssets reads the release build for p's page: the rows in the
+// order the spec lists them (the one-file themes, fiesta.css first;
+// tokens.css; each theme; shell.css; the scripts; the sprite; the
+// strings, one per language), each weighed from the bytes the release
+// holds. Every file the release ships is a row, or one of the files the
+// page leaves out by design (LICENSE, NOTICE, SHA256SUMS, the per-icon
+// SVGs the Icons page draws, the JSON the strings scripts wrap), or an
+// error naming it.
+func (s *site) buildAssets(p page) (assetsView, error) {
+ rel, err := s.release()
+ if err != nil {
+ return assetsView{}, err
+ }
+ var out assetsView
+ listed := map[string]bool{}
+ add := func(name, blurb string) error {
+ w, ok := rel[name]
+ if !ok {
+ return fmt.Errorf("gallery: Getting started lists %s and the release has no such file", name)
+ }
+ listed[name] = true
+ out.List = append(out.List, assetView{
+ ID: anchorID("asset", name),
+ Name: name,
+ Href: ReleaseBase + name,
+ Weight: s.weightIn(p.Locale, w),
+ Blurb: blurb,
+ })
+ return nil
+ }
+ // The one-file themes. The default theme's is fiesta.css: the one
+ // theme with no fiesta-<theme>.css of its own.
+ themes := fiesta.ThemeNames()
+ def := ""
+ for _, theme := range themes {
+ if _, ok := rel["fiesta-"+theme+".css"]; !ok {
+ if def != "" {
+ return assetsView{}, fmt.Errorf("gallery: both %s and %s lack a one-file theme of their own", def, theme)
+ }
+ def = theme
+ }
+ }
+ if def == "" {
+ return assetsView{}, fmt.Errorf("gallery: no theme is fiesta.css")
+ }
+ if err := add("fiesta.css", s.say(p.Locale, "gallery.started.file.bundle", "theme", def)); err != nil {
+ return assetsView{}, err
+ }
+ for _, theme := range themes {
+ if theme == def {
+ continue
+ }
+ if err := add("fiesta-"+theme+".css", s.say(p.Locale, "gallery.started.file.bundle", "theme", theme)); err != nil {
+ return assetsView{}, err
+ }
+ }
+ if err := add("tokens.css", s.say(p.Locale, blurbs["tokens.css"])); err != nil {
+ return assetsView{}, err
+ }
+ for _, theme := range themes {
+ if err := add(theme+".css", s.say(p.Locale, "gallery.started.file.theme", "theme", theme)); err != nil {
+ return assetsView{}, err
+ }
+ }
+ for _, name := range append([]string{"shell.css"}, scriptOrder...) {
+ if err := add(name, s.say(p.Locale, blurbs[name])); err != nil {
+ return assetsView{}, err
+ }
+ }
+ if err := add("icons.svg", s.say(p.Locale, blurbs["icons.svg"])); err != nil {
+ return assetsView{}, err
+ }
+ var langs []string
+ for name := range rel {
+ if strings.HasPrefix(name, "i18n/") && strings.HasSuffix(name, ".js") {
+ langs = append(langs, strings.TrimSuffix(strings.TrimPrefix(name, "i18n/"), ".js"))
+ }
+ }
+ sort.Strings(langs)
+ for _, lang := range langs {
+ // Each language named in itself, as the language menu names it.
+ if err := add("i18n/"+lang+".js", s.say(p.Locale, "gallery.started.file.strings", "language", s.say(lang, "gallery.locale.name"))); err != nil {
+ return assetsView{}, err
+ }
+ }
+ for name := range rel {
+ switch {
+ case listed[name], name == "LICENSE", name == "NOTICE", name == "SHA256SUMS",
+ strings.HasPrefix(name, "icons/"),
+ strings.HasPrefix(name, "i18n/") && strings.HasSuffix(name, ".json"):
+ continue
+ }
+ return assetsView{}, fmt.Errorf("gallery: the release ships %s and Getting started has no row for it", name)
+ }
+
+ // The total: fiesta.css and every script, each weighed on its own,
+ // the way a visitor downloads them.
+ raw, gz := 0, 0
+ for _, name := range append([]string{"fiesta.css"}, scriptOrder...) {
+ raw, gz = raw+rel[name].Raw, gz+rel[name].Gzip
+ }
+ out.Total = s.say(p.Locale, "gallery.started.total", "raw", kibUnit(p.Locale, raw), "gzip", kibUnit(p.Locale, gz))
+ out.Link = codeview.Highlight(`<link rel="stylesheet" href="` + ReleaseBase + `fiesta.css">`)
+ out.Own = codeview.Highlight(`<link rel="stylesheet" href="tokens.css">` + "\n" + `<link rel="stylesheet" href="` + p.Theme + `.css">`)
+ return out, nil
+}
+
+// kibUnit is kib with the unit after it, for a sentence that stands on
+// its own rather than under a note saying the unit. KiB is the unit's
+// own symbol, written the same in every language.
+func kibUnit(locale string, n int) string { return kib(locale, n) + " KiB" }
+
+// assetNav is one rail entry per shipped file, in the order the page
+// lists them, derived from the same view the page renders from, so a
+// file joins both lists on the day it ships, or neither. A release the
+// view cannot be built from is reported by startedBody, which fails
+// the render; the empty rail returned here is never written.
+func (s *site) assetNav(p page) []navItem {
+ view, err := s.buildAssets(p)
+ if err != nil {
+ return nil
+ }
+ file := s.href(p.Theme, p.Locale, s.fileOf("getting-started"))
+ var items []navItem
+ for _, a := range view.List {
+ items = append(items, navItem{Label: a.Name, Href: file + "#" + a.ID, Code: true})
+ }
+ return items
+}
+
+// startedBody is the page a reader meets before any of the vocabulary:
+// what ships, what each file is for and what it weighs, then the one
+// link, the two-file form for a site serving its own copy, and how an
+// upgrade reaches a page. The code blocks are translate="no": the
+// address in the first is a URL the site's link gate resolves like any
+// other, and neither is prose.
+const startedBody = `{{define "ds-body-getting-started"}}<p class="ds-lead">{{say "gallery.started.lead"}}</p>
+
+<h2 class="ds-sub">{{say "gallery.started.stylesheets"}}</h2>
+<p class="ds-lead">{{say "gallery.started.stylesheets.body"}}</p>
+
+<h2 class="ds-sub">{{say "gallery.started.scripts"}}</h2>
+<p class="ds-lead">{{say "gallery.started.scripts.body"}}</p>
+
+<h2 class="ds-sub">{{say "gallery.started.weights"}}</h2>
+<p class="ds-note">{{say "gallery.started.weights.note"}}</p>
+<ul class="ds-files">{{range .Assets.List}}
+<li id="{{.ID}}" data-ds-anchor><a class="rst-mono" data-gallery-site href="{{.Href}}">{{.Name}}</a><span>{{.Blurb}}</span><span class="rst-mono">{{.Weight}}</span></li>{{end}}
+</ul>
+<p class="ds-lead">{{.Assets.Total}}</p>
+
+<h2 class="ds-sub">{{say "gallery.started.link.heading"}}</h2>
+<p class="ds-lead">{{say "gallery.started.link.body"}}</p>
+<pre class="ds-src rst-mono" tabindex="0"><code translate="no">{{.Assets.Link}}</code></pre>
+
+<h2 class="ds-sub">{{say "gallery.started.own.heading"}}</h2>
+<p class="ds-lead">{{say "gallery.started.own.body"}}</p>
+<pre class="ds-src rst-mono" tabindex="0"><code translate="no">{{.Assets.Own}}</code></pre>
+
+<h2 class="ds-sub">{{say "gallery.started.upgrading"}}</h2>
+<p class="ds-lead">{{say "gallery.started.upgrading.body"}}</p>
+{{end}}`
+
+// startedBody renders the Getting started page's body for p.
+func (s *site) startedBody(p page) (template.HTML, error) {
+ assets, err := s.buildAssets(p)
+ if err != nil {
+ return "", err
+ }
+ return s.body(p.Locale, "ds-body-getting-started", struct {
+ page
+ Assets assetsView
+ }{p, assets})
+}
+
+func init() { registerTemplate(startedBody) }
diff --git a/internal/gallery/started_test.go b/internal/gallery/started_test.go
new file mode 100644
index 0000000..0ea79aa
--- /dev/null
+++ b/internal/gallery/started_test.go
@@ -0,0 +1,506 @@
+package gallery
+
+import (
+ "fmt"
+ "html/template"
+ "io/fs"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/internal/build"
+)
+
+// Getting started, the Icons page and the Overview's intro. The
+// weights page is held to the release build itself: every figure on it
+// is re-measured here from build.Dist, so a number beside a file name
+// and the file cannot disagree. The Icons page is held to icons/*.svg
+// the same way, and the two pages' off-site links are the only two
+// kinds of link that leave the mount, each with its gate.
+
+// shippedRows is the order Getting started lists the release, as the
+// spec gives it: the one-file themes, the tokens, each theme, the
+// shell, the scripts fiesta.js first and calendar.js last, the sprite,
+// then the strings for every language the source carries. Written out
+// here rather than read off the page, so the page is held to a list it
+// did not write.
+func shippedRows(t *testing.T) []string {
+ t.Helper()
+ rows := []string{
+ "fiesta.css", "fiesta-plain.css", "fiesta-signal.css",
+ "tokens.css", "day.css", "plain.css", "signal.css", "shell.css",
+ "fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "select.js", "datetime.js", "calendar.js",
+ "icons.svg",
+ }
+ entries, err := fs.ReadDir(fiesta.FS(), "i18n")
+ if err != nil {
+ t.Fatal(err)
+ }
+ langs := 0
+ for _, e := range entries {
+ if strings.HasSuffix(e.Name(), ".json") {
+ rows = append(rows, "i18n/"+strings.TrimSuffix(e.Name(), ".json")+".js")
+ langs++
+ }
+ }
+ if langs != 12 {
+ t.Fatalf("the source carries %d languages, want 12", langs)
+ }
+ return rows
+}
+
+// releaseBuild is the release build of the real source, failed loudly.
+func releaseBuild(t *testing.T) map[string][]byte {
+ t.Helper()
+ dist, err := build.Dist(fiesta.FS(), fiesta.Version)
+ if err != nil {
+ t.Fatalf("build.Dist: %v", err)
+ }
+ return dist
+}
+
+// TestTheGettingStartedPageWeighsTheRealAssets: in every theme and
+// locale the page lists exactly the shipped files, in the spec's order,
+// each with its anchor, its link to the release in the one link form,
+// and a weight that is weigh() of that very file in the release build;
+// the total is the arithmetic over fiesta.css and the scripts; the rail
+// entries assetNav returns are one per row, in order, by file name
+// (checked as a unit, since the rendered rail is the shell's); and the
+// gallery's own furniture has no row.
+//
+// The control for the row itself is a File misspelled geting-started.html
+// in pageKinds: the page is then missing by this name.
+func TestTheGettingStartedPageWeighsTheRealAssets(t *testing.T) {
+ files := render(t, "")
+ s := newTestSite(t, fiesta.FS(), "")
+ dist := releaseBuild(t)
+ rows := shippedRows(t)
+ for _, name := range rows {
+ if _, ok := dist[name]; !ok {
+ t.Fatalf("the release has no %s; the list of rows is wrong", name)
+ }
+ }
+ totalRaw, totalGzip := 0, 0
+ for _, name := range rows {
+ if name == "fiesta.css" || (strings.HasSuffix(name, ".js") && !strings.Contains(name, "/")) {
+ w := weigh(dist[name])
+ totalRaw, totalGzip = totalRaw+w.Raw, totalGzip+w.Gzip
+ }
+ }
+ if totalGzip >= totalRaw {
+ t.Fatalf("the total gzipped, %d, is not below the total as written, %d", totalGzip, totalRaw)
+ }
+ for _, theme := range fiesta.ThemeNames() {
+ for _, locale := range Locales {
+ name := theme + "/" + locale + "/getting-started.html"
+ body, ok := files[name]
+ if !ok {
+ t.Errorf("the render wrote no %s", name)
+ continue
+ }
+ doc := parse(t, body)
+ list, ok := doc.first(byClass("ds-files"))
+ if !ok {
+ t.Errorf("%s has no .ds-files list", name)
+ continue
+ }
+ items := list.find(func(n node) bool { return n.n.Data == "li" })
+ if len(items) != len(rows) {
+ t.Errorf("%s lists %d files, the release ships %d rows", name, len(items), len(rows))
+ }
+ p := s.newPage(theme, locale, "getting-started", "getting-started.html", "")
+ nav := s.assetNav(p)
+ if len(nav) != len(rows) {
+ t.Errorf("%s: assetNav returns %d entries, the page has %d rows", name, len(nav), len(rows))
+ }
+ page := s.href(theme, locale, "getting-started.html")
+ for i, want := range rows {
+ id := anchorID("asset", want)
+ if i < len(nav) {
+ if nav[i].Label != want || nav[i].Href != page+"#"+id || !nav[i].Code {
+ t.Errorf("%s: rail entry %d is %+v, want %s at %s#%s in the mono face", name, i, nav[i], want, page, id)
+ }
+ }
+ if i >= len(items) {
+ break
+ }
+ li := items[i]
+ if got, _ := li.attr("id"); got != id {
+ t.Errorf("%s: row %d has id %q, want %q", name, i, got, id)
+ }
+ if _, ok := li.attr("data-ds-anchor"); !ok {
+ t.Errorf("%s: row %s carries no data-ds-anchor", name, want)
+ }
+ a, ok := li.first(func(n node) bool { return n.n.Data == "a" })
+ if !ok {
+ t.Errorf("%s: row %s has no link", name, want)
+ continue
+ }
+ if href, _ := a.attr("href"); href != ReleaseBase+want {
+ t.Errorf("%s: row %s links %q, want %s", name, want, href, ReleaseBase+want)
+ }
+ if _, ok := a.attr("data-gallery-site"); !ok {
+ t.Errorf("%s: row %s's link carries no data-gallery-site", name, want)
+ }
+ if a.text() != want {
+ t.Errorf("%s: row %d reads %q, want %q", name, i, a.text(), want)
+ }
+ weight := s.weightIn(locale, weigh(dist[want]))
+ if !strings.Contains(li.text(), weight) {
+ t.Errorf("%s: row %s does not read %q; the weight on the page is not that of the release file", name, want, weight)
+ }
+ }
+ total := s.say(locale, "gallery.started.total", "raw", kibUnit(locale, totalRaw), "gzip", kibUnit(locale, totalGzip))
+ if !strings.Contains(string(body), template.HTMLEscapeString(total)) {
+ t.Errorf("%s does not say %q; the total is not the sum of fiesta.css and the scripts", name, total)
+ }
+ link := `<link rel="stylesheet" href="` + ReleaseBase + `fiesta.css">`
+ own := `<link rel="stylesheet" href="tokens.css">` + "\n" + `<link rel="stylesheet" href="` + theme + `.css">`
+ var codes []string
+ for _, pre := range doc.find(byClass("ds-src")) {
+ codes = append(codes, pre.text())
+ }
+ for _, want := range []string{link, own} {
+ found := false
+ for _, c := range codes {
+ found = found || c == strings.Join(strings.Fields(want), " ")
+ }
+ if !found {
+ t.Errorf("%s: no code block reads %q; the blocks read %q", name, want, codes)
+ }
+ }
+ _, read, ok := strings.Cut(string(body), "</head>")
+ if !ok {
+ t.Errorf("%s: no </head>", name)
+ continue
+ }
+ for _, own := range []string{anchorID("asset", "gallery.js"), anchorID("asset", "gallery.css")} {
+ if strings.Contains(read, own) {
+ t.Errorf("%s lists the gallery's own %s, which no page ships", name, own)
+ }
+ }
+ }
+ }
+}
+
+// TestEveryReleaseLinkIsTheOneLinkForm: every link marked
+// data-gallery-site is an <a> whose href is ReleaseBase followed by a
+// file the release build holds, and no href starting with ReleaseBase
+// lacks the mark. Asserted non-vacuous: Getting started links every
+// shipped file.
+func TestEveryReleaseLinkIsTheOneLinkForm(t *testing.T) {
+ files := render(t, "")
+ dist := releaseBuild(t)
+ marked := 0
+ for name, body := range files {
+ if !strings.HasSuffix(name, ".html") {
+ continue
+ }
+ attrsOf(t, body, func(tag string, attrs map[string]string) {
+ href, hasHref := attrs["href"]
+ if _, site := attrs["data-gallery-site"]; site {
+ marked++
+ if tag != "a" {
+ t.Errorf("%s: a <%s> carries data-gallery-site; only a link does", name, tag)
+ }
+ file := strings.TrimPrefix(href, ReleaseBase)
+ switch {
+ case !hasHref || file == href:
+ t.Errorf("%s: a data-gallery-site link reads %q, which does not start with %s", name, href, ReleaseBase)
+ case dist[file] == nil:
+ t.Errorf("%s: links %s, and the release has no %s", name, href, file)
+ }
+ return
+ }
+ if hasHref && strings.HasPrefix(href, ReleaseBase) {
+ t.Errorf("%s: <%s href=%q> links the release without data-gallery-site", name, tag, href)
+ }
+ })
+ }
+ if marked == 0 {
+ t.Error("no data-gallery-site link in the tree; Getting started links every shipped file")
+ }
+}
+
+// lucideNameShape is what lucide.dev names an icon: lowercase words
+// joined by single hyphens.
+var lucideNameShape = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
+
+// copyFS is src read whole into a map, so a test can add a file beside
+// the real ones and the directory listings see it.
+func copyFS(t *testing.T, src fs.FS) fstest.MapFS {
+ t.Helper()
+ out := fstest.MapFS{}
+ err := fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ b, err := fs.ReadFile(src, path)
+ if err != nil {
+ return err
+ }
+ out[path] = &fstest.MapFile{Data: b}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+// iconsPageReads holds one Icons page to one set of slugs: a chip per
+// slug with its anchor, its slug, its file and its lucide.dev address;
+// as many chips and addresses as slugs, each address well formed; the
+// renamed count read off the page equal to the number of addresses
+// that do not repeat the slug and to the count the sentence says; the
+// lead's count; and the one link to the set.
+func iconsPageReads(t *testing.T, name string, doc node, s *site, locale string, slugs []string) {
+ t.Helper()
+ chips := doc.find(func(n node) bool {
+ id, _ := n.attr("id")
+ return n.hasClass("ds-tok") && strings.HasPrefix(id, "icon-")
+ })
+ if len(chips) != len(slugs) {
+ t.Errorf("%s draws %d icons, the source has %d slugs", name, len(chips), len(slugs))
+ }
+ renamed := 0
+ addresses := 0
+ for _, slug := range slugs {
+ li, ok := doc.first(byAttr("id", anchorID("icon", slug)))
+ if !ok {
+ t.Errorf("%s: no chip with id %q; the page is not reading the slugs", name, anchorID("icon", slug))
+ continue
+ }
+ if _, ok := li.attr("data-ds-anchor"); !ok {
+ t.Errorf("%s: the chip for %s carries no data-ds-anchor", name, slug)
+ }
+ if _, ok := li.first(func(n node) bool { return n.n.Data == "svg" }); !ok {
+ t.Errorf("%s: the chip for %s draws no svg", name, slug)
+ }
+ values := li.find(byClass("ds-tok__value"))
+ if len(values) < 2 {
+ t.Errorf("%s: the chip for %s has %d value lines, want the file and the address", name, slug, len(values))
+ continue
+ }
+ if got := values[0].text(); got != "icons/"+slug+".svg" {
+ t.Errorf("%s: the chip for %s names the file %q, want icons/%s.svg", name, slug, got, slug)
+ }
+ address := values[len(values)-1].text()
+ lucide, ok := strings.CutPrefix(address, "lucide.dev/icons/")
+ if !ok {
+ t.Errorf("%s: the last line under %s is %q, want a lucide.dev/icons/ address", name, slug, address)
+ continue
+ }
+ addresses++
+ if !lucideNameShape.MatchString(lucide) {
+ t.Errorf("%s: the address under %s is %q, which names no icon", name, slug, address)
+ }
+ if lucide != lucideName(slug) {
+ t.Errorf("%s: the address under %s names %q, lucideNames says %q", name, slug, lucide, lucideName(slug))
+ }
+ if lucide != slug {
+ renamed++
+ }
+ }
+ if addresses != len(slugs) {
+ t.Errorf("%s prints %d addresses, want one per slug (%d)", name, addresses, len(slugs))
+ }
+ if renamed == 0 {
+ t.Errorf("%s: no address differs from its slug, and the source renames five of Lucide's", name)
+ }
+ total := fmt.Sprint(len(slugs))
+ for _, tc := range []struct{ what, want string }{
+ {"the lead", s.say(locale, "gallery.icons.lead", "total", total)},
+ {"the renamed sentence", s.say(locale, "gallery.icons.names.body", "renamed", fmt.Sprint(renamed), "total", total)},
+ } {
+ found := false
+ for _, p := range doc.find(func(n node) bool { return n.n.Data == "p" }) {
+ found = found || p.text() == tc.want
+ }
+ if !found {
+ t.Errorf("%s: %s does not read %q; a number on the page is typed rather than counted", name, tc.what, tc.want)
+ }
+ }
+ links := doc.find(func(n node) bool { href, _ := n.attr("href"); return n.n.Data == "a" && href == lucideHome })
+ if len(links) != 1 {
+ t.Errorf("%s links %s %d times, want once", name, lucideHome, len(links))
+ } else {
+ a := links[0]
+ if target, _ := a.attr("target"); target != "_blank" {
+ t.Errorf("%s: the link to %s does not open a new tab", name, lucideHome)
+ }
+ if rel, _ := a.attr("rel"); rel != "noopener" {
+ t.Errorf("%s: the link to %s has rel=%q, want noopener", name, lucideHome, rel)
+ }
+ if !strings.Contains(a.text(), s.say(locale, "gallery.newtab")) {
+ t.Errorf("%s: the link to %s does not say it opens in a new tab", name, lucideHome)
+ }
+ }
+}
+
+// TestTheIconsPageIsAReadingOfIconSlugs: in every theme and locale the
+// Icons page draws one chip per icons/*.svg, each with its lucide.dev
+// address, and the renamed count equals the addresses that do not
+// repeat their slug; lucideNames names only real slugs; and a scratch
+// source with one more icon grows the page by one chip and one address.
+func TestTheIconsPageIsAReadingOfIconSlugs(t *testing.T) {
+ files := render(t, "")
+ s := newTestSite(t, fiesta.FS(), "")
+ slugs := fiesta.IconSlugs()
+ if len(slugs) == 0 {
+ t.Fatal("fiesta.IconSlugs() is empty; there is nothing for this gate to read")
+ }
+ for slug, lucide := range lucideNames {
+ found := false
+ for _, have := range slugs {
+ found = found || have == slug
+ }
+ if !found {
+ t.Errorf("lucideNames renames %q, which is not an icon", slug)
+ }
+ if lucide == slug || !lucideNameShape.MatchString(lucide) {
+ t.Errorf("lucideNames[%q] is %q, which is not a different, well-formed Lucide name", slug, lucide)
+ }
+ }
+ for _, theme := range fiesta.ThemeNames() {
+ for _, locale := range Locales {
+ name := theme + "/" + locale + "/icons.html"
+ body, ok := files[name]
+ if !ok {
+ t.Errorf("the render wrote no %s", name)
+ continue
+ }
+ iconsPageReads(t, name, parse(t, body), s, locale, slugs)
+ }
+ }
+
+ src := copyFS(t, fiesta.FS())
+ src["icons/scratch-glyph.svg"] = &fstest.MapFile{Data: []byte(`<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 12h16"/></svg>`)}
+ s2 := newTestSite(t, src, "")
+ body, err := s2.iconsBody(s2.newPage("day", "en", "icons", "icons.html", ""))
+ if err != nil {
+ t.Fatalf("iconsBody on the scratch source: %v", err)
+ }
+ more := append(append([]string(nil), slugs...), "scratch-glyph")
+ sort.Strings(more)
+ iconsPageReads(t, "the scratch source's Icons page", parse(t, []byte(body)), s2, "en", more)
+}
+
+// TestTheIntroOpensTheOverview: in every theme and locale the Overview
+// carries exactly one .ds-intro paragraph, reading the intro, before
+// any other block of its body; no other page carries one.
+func TestTheIntroOpensTheOverview(t *testing.T) {
+ files := render(t, "")
+ s := newTestSite(t, fiesta.FS(), "")
+ blocks := []string{"ds-intro", "ds-lead", "ds-sub", "ds-routes", "ds-demo", "ds-note"}
+ for _, dir := range dirs() {
+ locale := strings.Split(dir, "/")[1]
+ name := dir + "index.html"
+ doc := parse(t, files[name])
+ main, ok := doc.first(byAttr("id", "main"))
+ if !ok {
+ t.Errorf("%s has no #main", name)
+ continue
+ }
+ intros := main.find(byClass("ds-intro"))
+ if len(intros) != 1 {
+ t.Errorf("%s carries %d .ds-intro, want 1", name, len(intros))
+ continue
+ }
+ if intros[0].n.Data != "p" {
+ t.Errorf("%s: the intro is a <%s>, want a paragraph", name, intros[0].n.Data)
+ }
+ if want := s.say(locale, "gallery.overview.intro"); intros[0].text() != want {
+ t.Errorf("%s: the intro reads %q, want %q", name, intros[0].text(), want)
+ }
+ first, ok := main.first(func(n node) bool {
+ for _, c := range blocks {
+ if n.hasClass(c) {
+ return true
+ }
+ }
+ return false
+ })
+ if !ok || !first.hasClass("ds-intro") {
+ class, _ := first.attr("class")
+ t.Errorf("%s opens with a %q block, want the intro first", name, class)
+ }
+ }
+ for name, body := range files {
+ if !strings.HasSuffix(name, ".html") || strings.HasSuffix(name, "/index.html") || name == "index.html" {
+ continue
+ }
+ if strings.Contains(string(body), `class="ds-intro"`) {
+ t.Errorf("%s carries the intro, which is the Overview's alone", name)
+ }
+ }
+}
+
+// TestEveryOutboundLinkIsAllowedAndUsed: every https:// link in the
+// tree that is not a release link is in outboundLinks, exactly; every
+// entry there is https, has its reason, and is linked by some page;
+// nothing links plaintext http. Asserted non-vacuous: the Icons page
+// links lucide.dev.
+func TestEveryOutboundLinkIsAllowedAndUsed(t *testing.T) {
+ files := render(t, "")
+ seen := map[string]int{}
+ for name, body := range files {
+ if !strings.HasSuffix(name, ".html") {
+ continue
+ }
+ attrsOf(t, body, func(tag string, attrs map[string]string) {
+ href, ok := attrs["href"]
+ if !ok || tag != "a" {
+ return
+ }
+ if strings.HasPrefix(href, "http://") {
+ t.Errorf("%s links %s; this tree does not send a reader to plaintext", name, href)
+ }
+ if _, site := attrs["data-gallery-site"]; site || !strings.HasPrefix(href, "https://") {
+ return
+ }
+ seen[href]++
+ })
+ }
+ for href, why := range outboundLinks {
+ if !strings.HasPrefix(href, "https://") {
+ t.Errorf("outboundLinks[%q] is not https", href)
+ }
+ if why == "" {
+ t.Errorf("outboundLinks[%q] has no reason beside it", href)
+ }
+ if seen[href] == 0 {
+ t.Errorf("outboundLinks names %q and nothing in the tree links it: delete the entry", href)
+ }
+ }
+ for href, n := range seen {
+ if _, ok := outboundLinks[href]; !ok {
+ t.Errorf("%d links go to %q, which is not in outboundLinks", n, href)
+ }
+ }
+ if len(seen) == 0 {
+ t.Error("no off-tree links found at all; the Icons page has lost its link to the set, or this gate has stopped looking")
+ }
+}
+
+// TestEveryShippedFileWeighsLessCompressed: every file of the release
+// build gzips to fewer bytes than it holds, so the two figures on a
+// Getting started row are never the wrong way round.
+//
+// Already passing when written: the release has no file so small that
+// gzip's framing outweighs its saving. The named mutation that turns
+// it red: weigh reporting Gzip as Raw.
+func TestEveryShippedFileWeighsLessCompressed(t *testing.T) {
+ dist := releaseBuild(t)
+ if len(dist) == 0 {
+ t.Fatal("the release build is empty")
+ }
+ for name, body := range dist {
+ if w := weigh(body); w.Gzip >= w.Raw {
+ t.Errorf("%s weighs %d as written and %d gzipped; gzip must shrink it", name, w.Raw, w.Gzip)
+ }
+ }
+}
diff --git a/internal/www/links_test.go b/internal/www/links_test.go
index b00fd9a..98466d8 100644
--- a/internal/www/links_test.go
+++ b/internal/www/links_test.go
@@ -16,9 +16,10 @@ import (
)
// outsideURLs are the URLs the site links on purpose that are not its
-// own: the top bar's and the footer's. Exactly these, as written, so
-// a new outside link is a decision someone takes here rather than a
-// line that slips in. The site's own absolute URLs, the hreflang
+// own: the top bar's, the footer's, and the design system's one
+// outbound link, lucide.dev, where its icons come from. Exactly these,
+// as written, so a new outside link is a decision someone takes here
+// rather than a line that slips in. The site's own absolute URLs, the hreflang
// links and the install text, start with www.Origin and are resolved
// as pages of the site instead, so a wrong path under the origin is
// caught; the origin itself is never on this list.
@@ -34,6 +35,7 @@ var outsideURLs = []string{
"https://carlosframework.com/trust/",
"https://carloku.com",
"https://11factor.org",
+ "https://lucide.dev",
}
// textURL is an absolute URL inside text: the install box's <code>