extract rendering into importable display subpackage
commit c714b1582887a4bc481e6248c292a032b93c4ddd · parent b0ad0cdd90
· browse this tree
Ximianth, 2026-10-26 19:28:48 UTC
Pure code motion plus the minimal export renames: markdown conversion (callouts, diagrams, heading anchors), the page chrome template, the stylesheet, and hostname theming now live in vitrine/display, with no HTTP or filesystem coupling. The server keeps thin wrappers (serveRendered, renderPage) in render.go. Crumbs gained a site-name parameter instead of hardcoding "vitrine", since the package is now meant for sibling tools to import. Server-coupled tests that had lived in slug_test.go and theme_test.go move to render_test.go in the main package; pure tests move with their subjects. Behavior is identical; all tests pass unchanged in substance.
diff --git a/callout.go b/display/callout.go similarity index 99% rename from callout.go rename to display/callout.go index 54bd329..f844ef2 100644 --- a/callout.go +++ b/display/callout.go @@ -1,4 +1,4 @@ -package main +package display import ( "bytes" diff --git a/callout_test.go b/display/callout_test.go similarity index 99% rename from callout_test.go rename to display/callout_test.go index 69f3efd..54965da 100644 --- a/callout_test.go +++ b/display/callout_test.go @@ -1,4 +1,4 @@ -package main +package display import ( "bytes" diff --git a/diagram.go b/display/diagram.go similarity index 99% rename from diagram.go rename to display/diagram.go index fde923f..01ccd69 100644 --- a/diagram.go +++ b/display/diagram.go @@ -1,4 +1,4 @@ -package main +package display import ( "bytes" diff --git a/diagram_test.go b/display/diagram_test.go similarity index 99% rename from diagram_test.go rename to display/diagram_test.go index a6dfd93..1b670b2 100644 --- a/diagram_test.go +++ b/display/diagram_test.go @@ -1,4 +1,4 @@ -package main +package display import ( "bytes" diff --git a/display/display.go b/display/display.go new file mode 100644 index 0000000..407bdd8 --- /dev/null +++ b/display/display.go @@ -0,0 +1,116 @@ +// Package display is vitrine's rendering heart, shared with sibling tools: +// markdown-to-HTML conversion (with callouts, diagrams, and heading anchors), +// the page chrome template, and hostname-derived theming. +// It is pure presentation: no HTTP, no filesystem access. +package display + +import ( + "bytes" + _ "embed" + "html/template" + "io" + "strings" + + "github.com/yuin/goldmark" + gmext "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + gmutil "github.com/yuin/goldmark/util" +) + +// MaxRenderSize caps how much callers should read into memory for rendered views. +// Bigger files still work fine via raw views, which stream. +const MaxRenderSize = 4 << 20 + +var markdown = goldmark.New( + goldmark.WithExtensions(gmext.GFM, callouts{}, diagrams{}), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + parser.WithASTTransformers(gmutil.Prioritized(headingAnchors{}, 100)), + ), +) + +// Markdown converts markdown source to HTML. +func Markdown(src []byte) (template.HTML, error) { + var buf bytes.Buffer + // A fresh IDs registry per document keeps duplicate-heading suffixes deterministic. + ctx := parser.NewContext(parser.WithIDs(newSlugIDs())) + if err := markdown.Convert(src, &buf, parser.WithContext(ctx)); err != nil { + return "", err + } + return template.HTML(buf.String()), nil +} + +//go:embed style.css +var StyleCSS []byte + +type DirEntry struct { + Name string + Href string +} + +type Crumb struct { + Name string + Href string +} + +type PageData struct { + Title string + ThemeName string // host theme name, shown beside the site name in the nav + Crumbs []Crumb + RawHref string // if set, a "raw" link is shown + Listing []DirEntry // directory listing mode + Content template.HTML // rendered-markdown mode + Iframe string // iframe-passthrough mode: src URL +} + +// JoinURL joins a mount name and a tree-relative path into one URL path +// (no leading slash); rel of "." means the mount root itself. +func JoinURL(mount, rel string) string { + if rel == "." { + return mount + } + return mount + "/" + rel +} + +// Crumbs builds a breadcrumb trail from a URL path (no leading slash): +// home (shown as the site name), then each path segment. +// The last segment gets no href; it's the page being viewed. +func Crumbs(site, urlpath string) []Crumb { + cs := []Crumb{{Name: site, Href: "/"}} + if urlpath == "" { + cs[0].Href = "" + return cs + } + href := "/" + for _, seg := range strings.Split(urlpath, "/") { + href += seg + "/" + cs = append(cs, Crumb{Name: seg, Href: href}) + } + cs[len(cs)-1].Href = "" + return cs +} + +var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{.Title}}</title> +<link rel="stylesheet" href="/-/style.css"> +</head> +<body> +<nav> +{{range $i, $c := .Crumbs}}{{if $i}} / {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Name}}</a>{{else}}{{$c.Name}}{{end}}{{if and (eq $i 0) $.ThemeName}}<span class="theme">@{{$.ThemeName}}</span>{{end}}{{end}} +{{if .RawHref}}<a class="raw" href="{{.RawHref}}">raw</a>{{end}} +</nav> +{{if .Listing}}<ul class="listing">{{range .Listing}}<li><a href="{{.Href}}">{{.Name}}</a></li>{{end}}</ul>{{end}} +{{if .Content}}<article>{{.Content}}</article>{{end}} +{{if .Iframe}}<iframe class="passthrough" src="{{.Iframe}}"></iframe>{{end}} +</body> +</html> +`)) + +// Page executes the page chrome template. +func Page(w io.Writer, pd PageData) error { + return pageTmpl.Execute(w, pd) +} diff --git a/slug.go b/display/slug.go similarity index 99% rename from slug.go rename to display/slug.go index ac36e22..c537162 100644 --- a/slug.go +++ b/display/slug.go @@ -1,4 +1,4 @@ -package main +package display import ( "strconv" diff --git a/display/slug_test.go b/display/slug_test.go new file mode 100644 index 0000000..85546c9 --- /dev/null +++ b/display/slug_test.go @@ -0,0 +1,20 @@ +package display + +import ( + "testing" +) + +func TestSlugify(t *testing.T) { + cases := map[string]string{ + "What the verifier catches": "what-the-verifier-catches", + "Use before definition": "use-before-definition", + "C++ / FFI (part 2)": "c-ffi-part-2", + " spaces everywhere ": "spaces-everywhere", + "???": "", + } + for in, want := range cases { + if got := slugify(in); got != want { + t.Errorf("slugify(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/style.css b/display/style.css similarity index 100% rename from style.css rename to display/style.css diff --git a/theme.go b/display/theme.go similarity index 95% rename from theme.go rename to display/theme.go index 3e5f92a..03d9521 100644 --- a/theme.go +++ b/display/theme.go @@ -1,4 +1,4 @@ -package main +package display import ( "fmt" @@ -13,14 +13,14 @@ import ( // (colorblind users, and nearby hues look alike anyway). // theme is one host's visual identity, computed once at startup. -type theme struct { +type Theme struct { Name string // the seed itself, shown in the nav next to the site name CSS []byte // ":root" accent override (light and dark), appended after the stylesheet by the /-/style.css handler } -func newTheme(seed string) theme { +func NewTheme(seed string) Theme { hue := themeHue(seed) - return theme{ + return Theme{ Name: seed, CSS: []byte(fmt.Sprintf( ":root { --accent: hsl(%d 70%% %d%%); }\n"+ @@ -32,7 +32,7 @@ func newTheme(seed string) theme { // hostLabel extracts the theme seed from a hostname: the first dot-segment, // so both "langoustine" and "langoustine.exe.xyz" yield "langoustine". // (The first segment is the machine-distinct one; the tail is shared domain.) -func hostLabel(hostname string) string { +func HostLabel(hostname string) string { label, _, _ := strings.Cut(hostname, ".") return label } diff --git a/display/theme_test.go b/display/theme_test.go new file mode 100644 index 0000000..1ca26f2 --- /dev/null +++ b/display/theme_test.go @@ -0,0 +1,56 @@ +package display + +import ( + "testing" +) + +// TestThemeHuePinned pins hue values for known seeds. +// These pins are load-bearing: a refactor that changes the hash or the modulo +// silently repaints every deployed host, and this test is what catches it. +func TestThemeHuePinned(t *testing.T) { + cases := map[string]int{ + "langoustine": 180, + "vitrine": 72, + "alpha": 107, + "zebra": 95, + "": 61, + } + for seed, want := range cases { + if got := themeHue(seed); got != want { + t.Errorf("themeHue(%q) = %d, want %d", seed, got, want) + } + } +} + +// TestThemeLightnessBand pins the contrast-driven lightness rule +// at the band edges and representative points; see themeLightness for the derivation. +func TestThemeLightnessBand(t *testing.T) { + cases := map[int]int{ + 0: 35, // reds: fine at 35% + 34: 35, // last hue before the band + 35: 26, // band start + 60: 26, // pure yellow, the worst offender + 180: 26, // cyan, still in the band + 200: 26, // band end + 201: 35, // blues onward: fine at 35% + 240: 35, + } + for hue, want := range cases { + if got := themeLightness(hue); got != want { + t.Errorf("themeLightness(%d) = %d, want %d", hue, got, want) + } + } +} + +func TestHostLabel(t *testing.T) { + cases := map[string]string{ + "langoustine": "langoustine", + "langoustine.exe.xyz": "langoustine", + "a.b": "a", + } + for hostname, want := range cases { + if got := HostLabel(hostname); got != want { + t.Errorf("HostLabel(%q) = %q, want %q", hostname, got, want) + } + } +} diff --git a/forward_test.go b/forward_test.go index 29396f3..6ff79e8 100644 --- a/forward_test.go +++ b/forward_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "testing" + + "vitrine/display" ) // echoBackend is a test backend that reports the path and query it saw, @@ -33,7 +35,7 @@ func forwardServer(t *testing.T, forwards map[string]string) *server { } fwds[name] = forward{To: to, url: u} } - return newServer(&config{Forwards: fwds}, theme{}) + return newServer(&config{Forwards: fwds}, display.Theme{}) } func TestForwardStripsPrefixKeepsQuery(t *testing.T) { @@ -105,7 +107,7 @@ func TestForwardRootYieldsToMounts(t *testing.T) { srv := newServer(&config{ Exhibits: map[string]exhibit{"meta/research": {Path: filepath.Join(dir, "research")}}, Forwards: map[string]forward{"": {To: b.URL, url: u}}, - }, theme{}) + }, display.Theme{}) // Inside the exhibit: the tree serves, not the backend. if body := get(t, srv, "/meta/research/notes.md?fmt=raw").Body.String(); body != "# exhibit wins\n" { diff --git a/main.go b/main.go index b46b007..d6f9cea 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,8 @@ import ( "net/http" "os" "time" + + "vitrine/display" ) func main() { @@ -34,13 +36,13 @@ func main() { if err != nil { log.Fatalf("cannot determine hostname for theming (use -theme-seed to bypass): %v", err) } - seed = hostLabel(hostname) + seed = display.HostLabel(hostname) } cfg, err := loadConfig(*configPath) if err != nil { log.Fatal(err) } - srv := newServer(cfg, newTheme(seed)) + srv := newServer(cfg, display.NewTheme(seed)) // Config changes apply live: reloads are fail-stable // (a malformed config logs loudly and the old exhibits keep serving). diff --git a/render.go b/render.go index f133a32..08acf95 100644 --- a/render.go +++ b/render.go @@ -1,135 +1,45 @@ package main import ( - "bytes" - _ "embed" "fmt" - "html/template" "net/http" "os" "strings" - "github.com/yuin/goldmark" - gmext "github.com/yuin/goldmark/extension" - "github.com/yuin/goldmark/parser" - gmutil "github.com/yuin/goldmark/util" + "vitrine/display" ) -// maxRenderSize caps how much we'll read into memory for rendered views. -// Bigger files still work fine via ?fmt=raw, which streams. -const maxRenderSize = 4 << 20 - -var markdown = goldmark.New( - goldmark.WithExtensions(gmext.GFM, callouts{}, diagrams{}), - goldmark.WithParserOptions( - parser.WithAutoHeadingID(), - parser.WithASTTransformers(gmutil.Prioritized(headingAnchors{}, 100)), - ), -) - -//go:embed style.css -var styleCSS []byte - -type dirEntry struct { - Name string - Href string -} - -type crumb struct { - Name string - Href string -} - -type pageData struct { - Title string - ThemeName string // host theme name, shown beside the site name in the nav - Crumbs []crumb - RawHref string // if set, a "raw" link is shown - Listing []dirEntry // directory listing mode - Content template.HTML // rendered-markdown mode - Iframe string // iframe-passthrough mode: src URL -} - -// joinURL joins a mount name and a tree-relative path into one URL path -// (no leading slash); rel of "." means the mount root itself. -func joinURL(mount, rel string) string { - if rel == "." { - return mount - } - return mount + "/" + rel -} - -// crumbs builds a breadcrumb trail from a URL path (no leading slash): -// home, then each path segment. The last segment gets no href; -// it's the page being viewed. -func crumbs(urlpath string) []crumb { - cs := []crumb{{Name: "vitrine", Href: "/"}} - if urlpath == "" { - cs[0].Href = "" - return cs - } - href := "/" - for _, seg := range strings.Split(urlpath, "/") { - href += seg + "/" - cs = append(cs, crumb{Name: seg, Href: href}) - } - cs[len(cs)-1].Href = "" - return cs -} - // serveRendered shows a file as a page: markdown rendered to HTML, // anything else passed through in an iframe pointed at the raw view. func (s *server) serveRendered(w http.ResponseWriter, r *http.Request, root *os.Root, mount, rel string, size int64) { - pd := pageData{ + pd := display.PageData{ Title: mount + "/" + rel, - Crumbs: crumbs(joinURL(mount, rel)), + Crumbs: display.Crumbs("vitrine", display.JoinURL(mount, rel)), RawHref: "?fmt=raw", } - if strings.HasSuffix(rel, ".md") && size <= maxRenderSize { + if strings.HasSuffix(rel, ".md") && size <= display.MaxRenderSize { src, err := root.ReadFile(rel) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - var buf bytes.Buffer - // A fresh IDs registry per document keeps duplicate-heading suffixes deterministic. - ctx := parser.NewContext(parser.WithIDs(newSlugIDs())) - if err := markdown.Convert(src, &buf, parser.WithContext(ctx)); err != nil { + content, err := display.Markdown(src) + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - pd.Content = template.HTML(buf.String()) + pd.Content = content } else { pd.Iframe = "?fmt=raw" } s.renderPage(w, pd) } -var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> -<title>{{.Title}}</title> -<link rel="stylesheet" href="/-/style.css"> -</head> -<body> -<nav> -{{range $i, $c := .Crumbs}}{{if $i}} / {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Name}}</a>{{else}}{{$c.Name}}{{end}}{{if and (eq $i 0) $.ThemeName}}<span class="theme">@{{$.ThemeName}}</span>{{end}}{{end}} -{{if .RawHref}}<a class="raw" href="{{.RawHref}}">raw</a>{{end}} -</nav> -{{if .Listing}}<ul class="listing">{{range .Listing}}<li><a href="{{.Href}}">{{.Name}}</a></li>{{end}}</ul>{{end}} -{{if .Content}}<article>{{.Content}}</article>{{end}} -{{if .Iframe}}<iframe class="passthrough" src="{{.Iframe}}"></iframe>{{end}} -</body> -</html> -`)) - // renderPage fills in the server-wide theme name and executes the page template. -func (s *server) renderPage(w http.ResponseWriter, pd pageData) { +func (s *server) renderPage(w http.ResponseWriter, pd display.PageData) { pd.ThemeName = s.theme.Name w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := pageTmpl.Execute(w, pd); err != nil { + if err := display.Page(w, pd); err != nil { fmt.Fprintf(w, "\n<!-- template error: %v -->", err) } } diff --git a/render_test.go b/render_test.go new file mode 100644 index 0000000..c531755 --- /dev/null +++ b/render_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "net/http" + "strings" + "testing" +) + +func TestHeadingAnchorsInRenderedPage(t *testing.T) { + srv := newTestServer(t) + rec := get(t, srv, "/demo/README.md") + body := rec.Body.String() + if !strings.Contains(body, `<h1 id="hello">`) { + t.Errorf("heading missing slug id; body:\n%s", body) + } + if !strings.Contains(body, `<a href="#hello" class="anchor">`) { + t.Errorf("heading missing chainlink anchor; body:\n%s", body) + } +} + +func TestDuplicateHeadingsGetDistinctIDs(t *testing.T) { + srv := newTestServer(t) + // dup.md is written by newTestServer with two identical headings. + rec := get(t, srv, "/demo/dup.md") + body := rec.Body.String() + if !strings.Contains(body, `id="twice"`) || !strings.Contains(body, `id="twice-2"`) { + t.Errorf("duplicate headings not disambiguated; body:\n%s", body) + } +} + +func TestStylesheetServed(t *testing.T) { + rec := get(t, newTestServer(t), "/-/style.css") + if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") { + t.Errorf("status = %d, content-type = %q", rec.Code, rec.Header().Get("Content-Type")) + } +} + +// TestStylesheetCarriesThemeAccent checks that /-/style.css ends with the +// computed :root override (light and dark), after the embedded stylesheet: +// at equal specificity the later declaration wins, +// so appending (not prepending) is what makes the theme accent take effect. +func TestStylesheetCarriesThemeAccent(t *testing.T) { + rec := get(t, newTestServer(t), "/-/style.css") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + body := rec.Body.String() + // "testhost" is the seed newTestServer uses; it hashes to hue 351, lightness 35%/70%. + want := ":root { --accent: hsl(351 70% 35%); }\n" + + "@media (prefers-color-scheme: dark) { :root { --accent: hsl(351 70% 70%); } }\n" + if !strings.HasSuffix(body, want) { + t.Errorf("stylesheet does not end with theme override %q; tail: %q", want, body[max(0, len(body)-200):]) + } + if !strings.Contains(body, "--paper") { + t.Errorf("embedded stylesheet missing before theme override") + } +} + +// TestNavShowsThemeName checks that pages name the host theme beside the site name. +func TestNavShowsThemeName(t *testing.T) { + rec := get(t, newTestServer(t), "/") + if !strings.Contains(rec.Body.String(), `<span class="theme">@testhost</span>`) { + t.Errorf("nav missing theme name; body:\n%s", rec.Body.String()) + } +} diff --git a/serve.go b/serve.go index f807c0a..699860c 100644 --- a/serve.go +++ b/serve.go @@ -12,6 +12,8 @@ import ( "sort" "strings" "sync/atomic" + + "vitrine/display" ) // server holds the currently mounted exhibits and forwards behind an atomic pointer, @@ -21,7 +23,7 @@ import ( // the atomic pointer makes the publish-immutable-snapshots discipline explicit.) type server struct { mounts atomic.Pointer[mountSet] - theme theme // fixed for the process lifetime; see theme.go + theme display.Theme // fixed for the process lifetime; see display/theme.go } // mountSet holds everything served under mount names (URL prefixes): @@ -43,7 +45,7 @@ type tree struct { raw bool } -func newServer(cfg *config, th theme) *server { +func newServer(cfg *config, th display.Theme) *server { s := &server{theme: th} s.setMounts(cfg) return s @@ -146,7 +148,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // The theme's :root block follows the embedded stylesheet: // at equal specificity the later declaration wins, // so appending is what lets the theme's --accent override the stylesheet's default. - w.Write(styleCSS) + w.Write(display.StyleCSS) w.Write(s.theme.CSS) return } @@ -217,7 +219,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if req != "" { title = req + "/" } - s.renderPage(w, pageData{Title: title, Crumbs: crumbs(req), Listing: kids}) + s.renderPage(w, display.PageData{Title: title, Crumbs: display.Crumbs("vitrine", req), Listing: kids}) return } @@ -235,7 +237,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // forwards alike) under the given prefix. // Returns nil if no mount lives under this prefix. // The root forward (empty name) never appears: it has no name to list. -func mountChildren(mounts *mountSet, prefix string) []dirEntry { +func mountChildren(mounts *mountSet, prefix string) []display.DirEntry { seen := make(map[string]bool) note := func(mount string) { rest := mount @@ -264,12 +266,12 @@ func mountChildren(mounts *mountSet, prefix string) []dirEntry { names = append(names, seg) } sort.Strings(names) - entries := make([]dirEntry, 0, len(names)+1) + entries := make([]display.DirEntry, 0, len(names)+1) if prefix != "" { - entries = append(entries, dirEntry{Name: "../", Href: "../"}) + entries = append(entries, display.DirEntry{Name: "../", Href: "../"}) } for _, seg := range names { - entries = append(entries, dirEntry{Name: seg + "/", Href: seg + "/"}) + entries = append(entries, display.DirEntry{Name: seg + "/", Href: seg + "/"}) } return entries } @@ -374,8 +376,8 @@ func (s *server) serveDir(w http.ResponseWriter, r *http.Request, tr tree, mount http.Error(w, err.Error(), http.StatusInternalServerError) return } - var listing []dirEntry - listing = append(listing, dirEntry{Name: "../", Href: "../"}) + var listing []display.DirEntry + listing = append(listing, display.DirEntry{Name: "../", Href: "../"}) for _, e := range ents { if e.Name() == ".git" && !tr.exposeGit { continue @@ -384,11 +386,11 @@ func (s *server) serveDir(w http.ResponseWriter, r *http.Request, tr tree, mount if e.IsDir() { n += "/" } - listing = append(listing, dirEntry{Name: n, Href: n}) + listing = append(listing, display.DirEntry{Name: n, Href: n}) } - s.renderPage(w, pageData{ + s.renderPage(w, display.PageData{ Title: mount + "/" + strings.TrimSuffix(rel, "."), - Crumbs: crumbs(joinURL(mount, rel)), + Crumbs: display.Crumbs("vitrine", display.JoinURL(mount, rel)), Listing: listing, }) } diff --git a/serve_test.go b/serve_test.go index 029acec..e53828a 100644 --- a/serve_test.go +++ b/serve_test.go @@ -8,10 +8,12 @@ import ( "path/filepath" "strings" "testing" + + "vitrine/display" ) // exhibitServer builds a server around exhibits only, no forwards. -func exhibitServer(exhibits map[string]exhibit, th theme) *server { +func exhibitServer(exhibits map[string]exhibit, th display.Theme) *server { return newServer(&config{Exhibits: exhibits}, th) } @@ -34,7 +36,7 @@ func newTestServer(t *testing.T) *server { t.Fatal(err) } } - srv := exhibitServer(map[string]exhibit{"demo": {Path: dir}}, newTheme("testhost")) + srv := exhibitServer(map[string]exhibit{"demo": {Path: dir}}, display.NewTheme("testhost")) return srv } @@ -91,7 +93,7 @@ func TestImageFormatsServeRawByDefault(t *testing.T) { t.Fatal(err) } } - srv := exhibitServer(map[string]exhibit{"img": {Path: dir}}, theme{}) + srv := exhibitServer(map[string]exhibit{"img": {Path: dir}}, display.Theme{}) // These should serve raw bytes, no iframe. for _, name := range []string{"icon.png", "drawing.svg", "photo.jpg"} { @@ -128,7 +130,7 @@ func newRawTestServer(t *testing.T) *server { t.Fatal(err) } } - return exhibitServer(map[string]exhibit{"demo": {Path: dir, Raw: true}}, newTheme("testhost")) + return exhibitServer(map[string]exhibit{"demo": {Path: dir, Raw: true}}, display.NewTheme("testhost")) } // On a raw mount, markdown serves as its plain bytes, not rendered HTML: @@ -232,7 +234,7 @@ func newNestedTestServer(t *testing.T) *server { } srv := exhibitServer(map[string]exhibit{ "meta/research/latest": {Path: filepath.Join(dir, "research/latest")}, - }, theme{}) + }, display.Theme{}) return srv } @@ -279,7 +281,7 @@ func TestNestedMountWinsOverParent(t *testing.T) { srv := exhibitServer(map[string]exhibit{ "docs": {Path: filepath.Join(dir, "parent")}, "docs/deep": {Path: filepath.Join(dir, "child")}, - }, theme{}) + }, display.Theme{}) for i := 0; i < 20; i++ { rec := get(t, srv, "/docs/deep/note.md?fmt=raw") if got := rec.Body.String(); got != "from child mount\n" { @@ -347,7 +349,7 @@ func newExposeGitTestServer(t *testing.T) (*server, string) { t.Fatalf("git %v: %v: %s", args, err, out) } } - srv := exhibitServer(map[string]exhibit{"repo": {Path: dir, ExposeGit: true}}, theme{}) + srv := exhibitServer(map[string]exhibit{"repo": {Path: dir, ExposeGit: true}}, display.Theme{}) return srv, dir } diff --git a/slug_test.go b/slug_test.go deleted file mode 100644 index 55a0050..0000000 --- a/slug_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -func TestSlugify(t *testing.T) { - cases := map[string]string{ - "What the verifier catches": "what-the-verifier-catches", - "Use before definition": "use-before-definition", - "C++ / FFI (part 2)": "c-ffi-part-2", - " spaces everywhere ": "spaces-everywhere", - "???": "", - } - for in, want := range cases { - if got := slugify(in); got != want { - t.Errorf("slugify(%q) = %q, want %q", in, got, want) - } - } -} - -func TestHeadingAnchorsInRenderedPage(t *testing.T) { - srv := newTestServer(t) - rec := get(t, srv, "/demo/README.md") - body := rec.Body.String() - if !strings.Contains(body, `<h1 id="hello">`) { - t.Errorf("heading missing slug id; body:\n%s", body) - } - if !strings.Contains(body, `<a href="#hello" class="anchor">`) { - t.Errorf("heading missing chainlink anchor; body:\n%s", body) - } -} - -func TestDuplicateHeadingsGetDistinctIDs(t *testing.T) { - srv := newTestServer(t) - // dup.md is written by newTestServer with two identical headings. - rec := get(t, srv, "/demo/dup.md") - body := rec.Body.String() - if !strings.Contains(body, `id="twice"`) || !strings.Contains(body, `id="twice-2"`) { - t.Errorf("duplicate headings not disambiguated; body:\n%s", body) - } -} - -func TestStylesheetServed(t *testing.T) { - rec := get(t, newTestServer(t), "/-/style.css") - if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") { - t.Errorf("status = %d, content-type = %q", rec.Code, rec.Header().Get("Content-Type")) - } -} diff --git a/theme_test.go b/theme_test.go deleted file mode 100644 index 4de0452..0000000 --- a/theme_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "net/http" - "strings" - "testing" -) - -// TestThemeHuePinned pins hue values for known seeds. -// These pins are load-bearing: a refactor that changes the hash or the modulo -// silently repaints every deployed host, and this test is what catches it. -func TestThemeHuePinned(t *testing.T) { - cases := map[string]int{ - "langoustine": 180, - "vitrine": 72, - "alpha": 107, - "zebra": 95, - "": 61, - } - for seed, want := range cases { - if got := themeHue(seed); got != want { - t.Errorf("themeHue(%q) = %d, want %d", seed, got, want) - } - } -} - -// TestThemeLightnessBand pins the contrast-driven lightness rule -// at the band edges and representative points; see themeLightness for the derivation. -func TestThemeLightnessBand(t *testing.T) { - cases := map[int]int{ - 0: 35, // reds: fine at 35% - 34: 35, // last hue before the band - 35: 26, // band start - 60: 26, // pure yellow, the worst offender - 180: 26, // cyan, still in the band - 200: 26, // band end - 201: 35, // blues onward: fine at 35% - 240: 35, - } - for hue, want := range cases { - if got := themeLightness(hue); got != want { - t.Errorf("themeLightness(%d) = %d, want %d", hue, got, want) - } - } -} - -func TestHostLabel(t *testing.T) { - cases := map[string]string{ - "langoustine": "langoustine", - "langoustine.exe.xyz": "langoustine", - "a.b": "a", - } - for hostname, want := range cases { - if got := hostLabel(hostname); got != want { - t.Errorf("hostLabel(%q) = %q, want %q", hostname, got, want) - } - } -} - -// TestStylesheetCarriesThemeAccent checks that /-/style.css ends with the -// computed :root override (light and dark), after the embedded stylesheet: -// at equal specificity the later declaration wins, -// so appending (not prepending) is what makes the theme accent take effect. -func TestStylesheetCarriesThemeAccent(t *testing.T) { - rec := get(t, newTestServer(t), "/-/style.css") - if rec.Code != http.StatusOK { - t.Fatalf("status = %d", rec.Code) - } - body := rec.Body.String() - // "testhost" is the seed newTestServer uses; it hashes to hue 351, lightness 35%/70%. - want := ":root { --accent: hsl(351 70% 35%); }\n" + - "@media (prefers-color-scheme: dark) { :root { --accent: hsl(351 70% 70%); } }\n" - if !strings.HasSuffix(body, want) { - t.Errorf("stylesheet does not end with theme override %q; tail: %q", want, body[max(0, len(body)-200):]) - } - if !strings.Contains(body, "--paper") { - t.Errorf("embedded stylesheet missing before theme override") - } -} - -// TestNavShowsThemeName checks that pages name the host theme beside the site name. -func TestNavShowsThemeName(t *testing.T) { - rec := get(t, newTestServer(t), "/") - if !strings.Contains(rec.Body.String(), `<span class="theme">@testhost</span>`) { - t.Errorf("nav missing theme name; body:\n%s", rec.Body.String()) - } -} diff --git a/watch_test.go b/watch_test.go index e0cd821..baa25c7 100644 --- a/watch_test.go +++ b/watch_test.go @@ -7,7 +7,9 @@ import ( "path/filepath" "sync/atomic" "testing" + "time" + "vitrine/display" ) // watchFixture is a server plus a watched config file in a temp dir, @@ -40,7 +42,7 @@ func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture { if err != nil { t.Fatal(err) } - fx.srv = newServer(cfg, theme{}) + fx.srv = newServer(cfg, display.Theme{}) watchConfig(fx.cfgPath, pollInterval, func() { reloadConfig(fx.srv, fx.cfgPath) fx.checked.Add(1)