feat(11.1-02): add the documentation theme, chroma highlighting and docs:serve
- WinterCMS-style shell: header with search and theme toggle, grouped sidebar, on-page TOC, pager, page actions, callouts, heading permalinks, footer and a 404 page - fenced code highlighted at build time by chroma/v2 into tok-* classes, with a copy button; no inline script, style or handler - vendored DM Sans/DM Mono fonts and Lucide icons with their licences - client-side search over search-index.json built with textContent only - summer docs:serve builds into a temp dir, serves on loopback by default, returns 404.html with status 404 and rebuilds on change
This commit is contained in:
295
internal/docsite/theme_test.go
Normal file
295
internal/docsite/theme_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package docsite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// themeTree is a two-section fixture: the index (two H2 headings, a
|
||||
// callout), two setup pages and one API page with a Go fence.
|
||||
func themeTree(t *testing.T) string {
|
||||
t.Helper()
|
||||
return writeTree(t, map[string]string{
|
||||
"docs/site.yaml": fixtureSite + "edit_url: \"https://forge.example/edit/{path}\"\n",
|
||||
"docs/index.md": page("Acme docs", "index", 0,
|
||||
"## Alpha\n\nText.\n\n### Detail\n\n> [!NOTE]\n> A note.\n\n> [!TIP]\n> A tip.\n\n> [!WARNING]\n> Careful.\n\n"+
|
||||
"```sh\n$ summer docs:build\n```\n\n## Beta\n\nMore.\n"),
|
||||
"docs/setup/start.md": page("Start", "setup", 10, "## Only heading\n\nText.\n"),
|
||||
"docs/setup/second.md": page("Second", "setup", 20, "Text.\n"),
|
||||
"modules/alpha/alpha.go": "package alpha\n",
|
||||
"modules/alpha/README.md": "# alpha\n\nAlpha does one thing well.\n\n## Usage\n\n" +
|
||||
"```go\nfunc main() {\n\treturn \"x\" // done\n}\n```\n\n```yaml\nkey: true\n```\n",
|
||||
})
|
||||
}
|
||||
|
||||
func buildTheme(t *testing.T) string {
|
||||
t.Helper()
|
||||
out := filepath.Join(t.TempDir(), "site")
|
||||
if _, problems, err := Build(Options{Root: themeTree(t), Commands: fixtureCommands, Out: out}); err != nil || len(problems) > 0 {
|
||||
t.Fatalf("Build: %v %q", err, problemLines(problems))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readOut(t *testing.T, out, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(out, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var (
|
||||
scriptTag = regexp.MustCompile(`<script[^>]*>`)
|
||||
anyTag = regexp.MustCompile(`<[a-zA-Z][^>]*>`)
|
||||
styleAttr = regexp.MustCompile(`\sstyle=`)
|
||||
handlerAttr = regexp.MustCompile(`\son[a-z]+=`)
|
||||
)
|
||||
|
||||
// assertCSPSafe fails on inline script bodies, inline styles and inline
|
||||
// event handlers (script-src 'self'; style-src 'self').
|
||||
func assertCSPSafe(t *testing.T, name, html string) {
|
||||
t.Helper()
|
||||
for _, loc := range scriptTag.FindAllStringIndex(html, -1) {
|
||||
tag := html[loc[0]:loc[1]]
|
||||
if !strings.Contains(tag, ` src="`) || !strings.HasPrefix(html[loc[1]:], "</script>") {
|
||||
t.Errorf("%s has an inline script: %s", name, tag)
|
||||
}
|
||||
}
|
||||
for _, tag := range anyTag.FindAllString(html, -1) {
|
||||
if styleAttr.MatchString(tag) || handlerAttr.MatchString(tag) {
|
||||
t.Errorf("%s has an inline style or handler: %s", name, tag)
|
||||
}
|
||||
}
|
||||
if strings.Contains(html, "<style") {
|
||||
t.Errorf("%s has a <style> element", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSiteMarkers(t *testing.T) {
|
||||
out := buildTheme(t)
|
||||
index := readOut(t, out, "index.html")
|
||||
for _, marker := range []string{
|
||||
`<body class="docs">`,
|
||||
`<header class="site-header">`,
|
||||
`<nav class="sidebar" aria-label="Documentation">`,
|
||||
`<aside class="toc" aria-label="On this page">`,
|
||||
`<details class="toc-inline">`,
|
||||
`<div class="page-actions">`,
|
||||
`<nav class="pager" aria-label="Previous and next page">`,
|
||||
`<footer class="site-footer">`,
|
||||
`<dialog class="search" id="search">`,
|
||||
`<button class="theme-toggle"`,
|
||||
`<figure class="code">`,
|
||||
`<aside class="callout callout-note" role="note">`,
|
||||
`<aside class="callout callout-tip" role="note">`,
|
||||
`<aside class="callout callout-warning" role="note">`,
|
||||
`<a class="heading-anchor" href="#alpha" aria-label="Link to section: Alpha">#</a>`,
|
||||
`<svg class="icon icon-`,
|
||||
`<link rel="stylesheet" href="/assets/site.css">`,
|
||||
`<script src="/assets/theme-init.js"></script>`,
|
||||
`<meta name="color-scheme" content="light dark">`,
|
||||
`<meta property="og:title" content="Acme docs">`,
|
||||
`<link rel="alternate" type="text/markdown" href="/index.md">`,
|
||||
`href="https://forge.example/edit/docs/index.md"`,
|
||||
`View as Markdown`,
|
||||
`aria-label="SummerCMS documentation home"`,
|
||||
`aria-controls="sidebar"`,
|
||||
`<span class="tok-prompt">$ </span><span class="tok-kw">summer</span>`,
|
||||
`href="/llms.txt"`,
|
||||
`href="/llms-full.txt"`,
|
||||
`class="toc-h3"`,
|
||||
} {
|
||||
if !strings.Contains(index, marker) {
|
||||
t.Errorf("index.html missing %s", marker)
|
||||
}
|
||||
}
|
||||
if strings.Contains(index, "[!NOTE]") {
|
||||
t.Error("callout marker line rendered")
|
||||
}
|
||||
if i, j := strings.Index(index, "theme-init.js"), strings.Index(index, "site.css"); i < 0 || j < 0 || i > j {
|
||||
t.Error("theme-init.js must load before the stylesheet")
|
||||
}
|
||||
|
||||
// First page: Next only (the empty Previous cell keeps Next on the right).
|
||||
if !strings.Contains(index, `<span class="pager-empty"></span>`) || strings.Contains(index, "pager-prev") || !strings.Contains(index, "pager-next") {
|
||||
t.Error("index.html pager should hold Next only")
|
||||
}
|
||||
// One H2 and no H3: no TOC; the pager crosses into the API section.
|
||||
start := readOut(t, out, "setup/start.html")
|
||||
if strings.Contains(start, `class="toc"`) || strings.Contains(start, "toc-inline") {
|
||||
t.Error("setup/start.html has a TOC with one heading")
|
||||
}
|
||||
if !strings.Contains(start, `<p class="eyebrow">Setup</p>`) {
|
||||
t.Error("setup/start.html has no section eyebrow")
|
||||
}
|
||||
second := readOut(t, out, "setup/second.html")
|
||||
if !strings.Contains(second, `<span class="pager-section">API reference</span>`) {
|
||||
t.Error("cross-section Next has no section line")
|
||||
}
|
||||
if strings.Count(second, `class="pager-section"`) != 1 {
|
||||
t.Error("same-section Previous must not carry a section line")
|
||||
}
|
||||
// Last page: Previous only.
|
||||
api := readOut(t, out, "api/alpha.html")
|
||||
if !strings.Contains(api, "pager-prev") || strings.Contains(api, "pager-next") {
|
||||
t.Error("api/alpha.html pager should hold Previous only")
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<span class="tok-kw">func</span>`,
|
||||
`<span class="tok-str">"x"</span>`,
|
||||
`<span class="tok-com">// done</span>`,
|
||||
`<span class="tok-key">key</span>`,
|
||||
`<span class="tok-num">true</span>`,
|
||||
`<code class="language-go">`,
|
||||
`<button type="button" class="copy-button" aria-label="Copy code" hidden>`,
|
||||
`<div class="sidebar-section sidebar-api">`,
|
||||
`aria-current="page"`,
|
||||
} {
|
||||
if !strings.Contains(api, want) {
|
||||
t.Errorf("api/alpha.html missing %s", want)
|
||||
}
|
||||
}
|
||||
|
||||
notFound := readOut(t, out, "404.html")
|
||||
for _, want := range []string{`<body class="docs docs-404">`, "<h1>Page not found</h1>", "This page does not exist or has moved.", `>documentation home</a>`} {
|
||||
if !strings.Contains(notFound, want) {
|
||||
t.Errorf("404.html missing %s", want)
|
||||
}
|
||||
}
|
||||
for _, not := range []string{`aria-current="page"`, `class="toc"`, `class="pager"`, "page-actions"} {
|
||||
if strings.Contains(notFound, not) {
|
||||
t.Errorf("404.html must not contain %s", not)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range []string{"index.html", "setup/start.html", "setup/second.html", "api/alpha.html", "404.html"} {
|
||||
html := readOut(t, out, name)
|
||||
assertCSPSafe(t, name, html)
|
||||
if strings.Contains(html, "//fonts.") || strings.Contains(html, "cdn") {
|
||||
t.Errorf("%s references a third-party asset origin", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"assets/site.js", "assets/search.js", "assets/theme-init.js", "assets/fonts/dm-mono-latin-400-normal.woff2"} {
|
||||
if _, err := os.Stat(filepath.Join(out, name)); err != nil {
|
||||
t.Errorf("missing %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// A one-page site renders no pager.
|
||||
var buf bytes.Buffer
|
||||
if err := pageTmpl.ExecuteTemplate(&buf, "pager", pageView{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(buf.String()) != "" {
|
||||
t.Errorf("pager without prev/next = %q, want nothing", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHandler(t *testing.T) {
|
||||
srv := httptest.NewServer(Handler(buildTheme(t)))
|
||||
defer srv.Close()
|
||||
get := func(p string) (int, string) {
|
||||
t.Helper()
|
||||
res, err := http.Get(srv.URL + p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
return res.StatusCode, string(body)
|
||||
}
|
||||
for _, p := range []string{"/index.html", "/", "/setup/start.html", "/assets/site.css", "/search-index.json"} {
|
||||
if code, _ := get(p); code != http.StatusOK {
|
||||
t.Errorf("GET %s = %d, want 200", p, code)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"/missing.html", "/setup/", "/.summer-docs", "/assets/../.summer-docs", "/%2e%2e/etc/passwd"} {
|
||||
code, body := get(p)
|
||||
if code != http.StatusNotFound || !strings.Contains(body, "Page not found") {
|
||||
t.Errorf("GET %s = %d, want 404 with the Page not found body", p, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncBuffer is a goroutine-safe bytes.Buffer.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
var servingLine = regexp.MustCompile(`Serving docs at (http://\S+) \(press Ctrl\+C to stop\)`)
|
||||
|
||||
func TestServeRefusesNonLoopback(t *testing.T) {
|
||||
opts := Options{Root: themeTree(t), Commands: fixtureCommands}
|
||||
for _, addr := range []string{"0.0.0.0:8088", "192.0.2.1:8088", "example.com:8088", ":8088"} {
|
||||
var out bytes.Buffer
|
||||
err := Serve(context.Background(), opts, addr, false, &out)
|
||||
want := "docs:serve: refusing to listen on " + addr + ": not a loopback address. Pass --allow-remote to serve on the network."
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("Serve(%s) = %v, want %q", addr, err, want)
|
||||
}
|
||||
}
|
||||
if err := checkServeAddr("0.0.0.0:0", true); err != nil {
|
||||
t.Errorf("--allow-remote still refused: %v", err)
|
||||
}
|
||||
|
||||
for _, addr := range []string{"127.0.0.1:0", "localhost:0"} {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
out := &syncBuffer{}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- Serve(ctx, opts, addr, false, out) }()
|
||||
var base string
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
|
||||
if m := servingLine.FindStringSubmatch(out.String()); m != nil {
|
||||
base = m[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
cancel()
|
||||
t.Fatalf("Serve(%s) printed no serving line: %s", addr, out.String())
|
||||
}
|
||||
res, err := http.Get(base + "/index.html")
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Serve(%s) GET /index.html = %d", addr, res.StatusCode)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("Serve(%s) = %v after cancel", addr, err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("Serve(%s) did not return after cancel", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user