From a494375db74c27683c0a652e1f96556568893710 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 1 Oct 2026 16:09:40 +0200 Subject: [PATCH] feat(docsite): optional site_url and site_label link back to the main site - site.yaml keys site_url and site_label, validated: http(s) URL with a host or a path starting with a single /; a label needs a URL - docs:build and docs:serve flags --site-url and --site-label override them the way --base-url overrides base_url - every page header, the 404 page included, links back with the explicit label, else the URL host, else Home; unset output is unchanged - docs/console/utilities.md documents the keys and flags --- cmd/summer/docs.go | 6 ++ cmd/summer/docs_test.go | 34 +++++++ docs/console/utilities.md | 6 +- internal/docsite/docsite.go | 4 + internal/docsite/emit.go | 7 ++ internal/docsite/load.go | 98 ++++++++++++++++++-- internal/docsite/load_test.go | 9 ++ internal/docsite/theme/assets/site.css | 18 ++++ internal/docsite/theme/templates/header.html | 2 +- internal/docsite/theme_test.go | 33 +++++++ 10 files changed, 207 insertions(+), 10 deletions(-) diff --git a/cmd/summer/docs.go b/cmd/summer/docs.go index b6b3f28..1cb0dd8 100644 --- a/cmd/summer/docs.go +++ b/cmd/summer/docs.go @@ -29,6 +29,8 @@ func docsBuildCommand() bonfire.Command { {Name: "src", Description: "Docs source directory (default /docs)"}, {Name: "out", Description: "Output directory (default /site)"}, {Name: "base-url", Description: "Base URL for site links (overrides site.yaml base_url)"}, + {Name: "site-url", Description: "Main site URL linked from the docs header (overrides site.yaml site_url)"}, + {Name: "site-label", Description: "Label of the main site link (overrides site.yaml site_label; default: the URL host, or Home)"}, {Name: "check", Description: "Validate the docs and write nothing", Bare: true}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { @@ -91,6 +93,8 @@ func docsServeCommand() bonfire.Command { {Name: "root", Description: "Repository root; src= paths and modules/ resolve against it", Default: "."}, {Name: "src", Description: "Docs source directory (default /docs)"}, {Name: "base-url", Description: "Base URL for site links (overrides site.yaml base_url)"}, + {Name: "site-url", Description: "Main site URL linked from the docs header (overrides site.yaml site_url)"}, + {Name: "site-label", Description: "Label of the main site link (overrides site.yaml site_label; default: the URL host, or Home)"}, {Name: "addr", Description: "Listen address; must be loopback unless --allow-remote", Default: docsite.DefaultServeAddr}, {Name: "allow-remote", Description: "Allow a non-loopback --addr (serves the docs on the network)", Bare: true}, }, @@ -126,6 +130,8 @@ func docsOptions(in bonfire.Input) docsite.Options { opts.Src, _ = in.Flag("src") opts.Out, _ = in.Flag("out") opts.BaseURL, _ = in.Flag("base-url") + opts.SiteURL, _ = in.Flag("site-url") + opts.SiteLabel, _ = in.Flag("site-label") return opts } diff --git a/cmd/summer/docs_test.go b/cmd/summer/docs_test.go index 24722e0..ce30976 100644 --- a/cmd/summer/docs_test.go +++ b/cmd/summer/docs_test.go @@ -72,6 +72,40 @@ func TestDocsBuildRealTree(t *testing.T) { } } +// TestDocsBuildSiteFlags checks that --site-url and --site-label reach the +// header and that an unsafe --site-url is refused. +func TestDocsBuildSiteFlags(t *testing.T) { + out := filepath.Join(t.TempDir(), "site") + var buf bytes.Buffer + root, err := bonfire.NewRoot("summer", toolCommands(), &buf) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"docs:build", "--root", repoRoot, "--out", out, "--site-url", "/", "--site-label", "example.org"}) + if err := root.Execute(); err != nil { + t.Fatalf("docs:build: %v\n%s", err, buf.String()) + } + index, err := os.ReadFile(filepath.Join(out, "index.html")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`class="site-link" href="/"`, "example.org"} { + if !bytes.Contains(index, []byte(want)) { + t.Errorf("index.html lacks %s", want) + } + } + + buf.Reset() + root, err = bonfire.NewRoot("summer", toolCommands(), &buf) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"docs:build", "--root", repoRoot, "--out", filepath.Join(t.TempDir(), "bad"), "--site-url", "javascript:alert(1)"}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--site-url") { + t.Fatalf("docs:build --site-url javascript:alert(1): err = %v, want a --site-url error", err) + } +} + // requiredPages lists the guide pages the docs must keep. Each content plan // appends the pages it writes. var requiredPages = []string{ diff --git a/docs/console/utilities.md b/docs/console/utilities.md index 8fcc31c..53c6207 100644 --- a/docs/console/utilities.md +++ b/docs/console/utilities.md @@ -32,9 +32,11 @@ These docs are Markdown files under `docs/`, plus every module README, built int | Command | Flags | Purpose | |---------|-------|---------| -| `summer docs:build` | `--root` (default `.`), `--src`, `--out`, `--base-url`, `--check` | Checks every page and writes the site to `site/` (or `--out`): HTML pages, a raw `.md` copy of each page, `llms.txt`, `llms-full.txt` and the search index. With `--check` it only reports problems and writes nothing. | +| `summer docs:build` | `--root` (default `.`), `--src`, `--out`, `--base-url`, `--site-url`, `--site-label`, `--check` | Checks every page and writes the site to `site/` (or `--out`): HTML pages, a raw `.md` copy of each page, `llms.txt`, `llms-full.txt` and the search index. With `--check` it only reports problems and writes nothing. | | `summer docs:sync` | `--root` (default `.`), `--src` | Rewrites every code block that has a `src=` reference from its source file. | -| `summer docs:serve` | `--root` (default `.`), `--src`, `--base-url`, `--addr` (default `127.0.0.1:8088`), `--allow-remote` | Builds the site into a temporary directory, serves it and rebuilds when a page, a module or a referenced source changes. A failed rebuild prints its problems and keeps serving the last good build. | +| `summer docs:serve` | `--root` (default `.`), `--src`, `--base-url`, `--site-url`, `--site-label`, `--addr` (default `127.0.0.1:8088`), `--allow-remote` | Builds the site into a temporary directory, serves it and rebuilds when a page, a module or a referenced source changes. A failed rebuild prints its problems and keeps serving the last good build. | + +`docs/site.yaml` accepts two optional keys, `site_url` and `site_label`, that add a link back to the main site to every page header. With `site_url: https://acme.example/` the link reads "acme.example". Without `site_label` the label is the URL's host, or Home when `site_url` is a path such as `/`. `site_url` must be an `http://` or `https://` URL with a host, or a path starting with a single `/`. The `--site-url` and `--site-label` flags override the two keys the way `--base-url` overrides `base_url`. ```sh summer docs:build --check diff --git a/internal/docsite/docsite.go b/internal/docsite/docsite.go index fb44771..af04ac2 100644 --- a/internal/docsite/docsite.go +++ b/internal/docsite/docsite.go @@ -34,6 +34,10 @@ type Options struct { Out string // BaseURL overrides site.yaml base_url when non-empty. BaseURL string + // SiteURL overrides site.yaml site_url when non-empty. + SiteURL string + // SiteLabel overrides site.yaml site_label when non-empty. + SiteLabel string // Commands is the set of summer and application command names pages // may show. Check and Build report a problem when it is nil. Commands *Commands diff --git a/internal/docsite/emit.go b/internal/docsite/emit.go index a9d6383..dd42538 100644 --- a/internal/docsite/emit.go +++ b/internal/docsite/emit.go @@ -67,6 +67,11 @@ type pageView struct { Content template.HTML Assets string HomeURL string + // SiteURL and SiteLabel are the link back to the main site, empty + // when site_url is unset. SiteURL points at another site, so it is + // never prefixed with base_url. + SiteURL string + SiteLabel string SearchIndex string LLMS string LLMSFull string @@ -85,6 +90,8 @@ func (s *site) baseView(current *Page) pageView { return pageView{ Assets: s.url("assets"), HomeURL: s.url("index.html"), + SiteURL: s.siteURL, + SiteLabel: s.siteLabel, SearchIndex: s.url("search-index.json"), LLMS: s.url("llms.txt"), LLMSFull: s.url("llms-full.txt"), diff --git a/internal/docsite/load.go b/internal/docsite/load.go index 00244a2..9d13901 100644 --- a/internal/docsite/load.go +++ b/internal/docsite/load.go @@ -6,12 +6,14 @@ import ( "errors" "fmt" "io/fs" + "net/url" "os" "path" "path/filepath" "regexp" "slices" "strings" + "unicode" "unicode/utf8" "github.com/goccy/go-yaml" @@ -28,6 +30,12 @@ type Site struct { SourceURL string `yaml:"source_url"` LLMSNotes []string `yaml:"llms_notes"` Sections []Section `yaml:"sections"` + // SiteURL, when set, adds a link back to the main site to every page + // header: an http(s) URL with a host, or a path starting with one /. + SiteURL string `yaml:"site_url"` + // SiteLabel is the text of that link. It needs SiteURL; when empty the + // label is the URL's host, or "Home" for a path. + SiteLabel string `yaml:"site_label"` } // Section is one sidebar group, listed in sidebar order in site.yaml. @@ -114,9 +122,62 @@ func ParseSite(raw []byte) (Site, error) { } seen[sec.Name] = true } + if s.SiteURL != "" && checkSiteURL(s.SiteURL) != nil { + return Site{}, fmt.Errorf("docsite: site config: site_url must be an http(s) URL with a host or a path starting with a single /") + } + if s.SiteLabel != "" { + if s.SiteURL == "" { + return Site{}, fmt.Errorf("docsite: site config: site_label needs site_url") + } + if !oneLine(s.SiteLabel) { + return Site{}, fmt.Errorf("docsite: site config: site_label must be one non-empty line") + } + } return s, nil } +var errSiteURL = errors.New("docsite: site URL must be an http(s) URL with a host or a path starting with a single /") + +// checkSiteURL accepts an http:// or https:// URL with a host and no user +// info, or a path starting with exactly one /. Everything else is refused, +// including other schemes (javascript:, data:), protocol-relative //host +// URLs, relative paths, backslashes, whitespace and control characters. +func checkSiteURL(raw string) error { + if raw == "" || strings.ContainsRune(raw, '\\') || strings.IndexFunc(raw, func(r rune) bool { + return unicode.IsSpace(r) || unicode.IsControl(r) + }) >= 0 { + return errSiteURL + } + if strings.HasPrefix(raw, "/") { + if strings.HasPrefix(raw, "//") { + return errSiteURL + } + return nil + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.Opaque != "" { + return errSiteURL + } + return nil +} + +// siteLabel is the text of the link back to the main site: label when set, +// else the host of an absolute URL (port included), else "Home". +func siteLabel(siteURL, label string) string { + if l := strings.TrimSpace(label); l != "" { + return l + } + if u, err := url.Parse(siteURL); err == nil && u.Host != "" && (u.Scheme == "http" || u.Scheme == "https") { + return u.Host + } + return "Home" +} + +// oneLine reports whether s is non-blank and holds no line break. +func oneLine(s string) bool { + return strings.TrimSpace(s) != "" && !strings.ContainsAny(s, "\r\n") +} + var slugName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) func firstLine(s string) string { @@ -140,13 +201,17 @@ func (s Site) hasSection(name string) bool { // site is one assembled documentation site. type site struct { - opts Options - cfg Site - cfgRaw []byte - base string - pages []*Page // reading order - bySource map[string]*Page - outputs map[string][]byte + opts Options + cfg Site + cfgRaw []byte + base string + // siteURL and siteLabel are the link back to the main site; empty + // siteURL means no link. + siteURL string + siteLabel string + pages []*Page // reading order + bySource map[string]*Page + outputs map[string][]byte } // rel returns the display path of an absolute path: repository-relative @@ -219,6 +284,25 @@ func load(opts Options) (*site, []Problem, error) { if opts.BaseURL != "" { s.base = strings.TrimRight(opts.BaseURL, "/") } + siteURL, label := cfg.SiteURL, cfg.SiteLabel + if opts.SiteURL != "" { + if checkSiteURL(opts.SiteURL) != nil { + return nil, nil, errors.New("docsite: --site-url: must be an http(s) URL with a host or a path starting with a single /") + } + siteURL = opts.SiteURL + } + if opts.SiteLabel != "" { + if !oneLine(opts.SiteLabel) { + return nil, nil, errors.New("docsite: --site-label: must be one non-empty line") + } + label = opts.SiteLabel + } + if label != "" && siteURL == "" { + return nil, nil, errors.New("docsite: --site-label needs --site-url or site.yaml site_url") + } + if siteURL != "" { + s.siteURL, s.siteLabel = siteURL, siteLabel(siteURL, label) + } guides, problems, err := s.loadGuides() if err != nil { diff --git a/internal/docsite/load_test.go b/internal/docsite/load_test.go index 82c13bf..062cd5e 100644 --- a/internal/docsite/load_test.go +++ b/internal/docsite/load_test.go @@ -27,11 +27,20 @@ func TestParseSite(t *testing.T) { {"reserved index", "title: t\ndescription: d\nsections:\n - name: index\n title: I\n", `section name "index" is reserved`}, {"bad slug", "title: t\ndescription: d\nsections:\n - name: Setup\n title: S\n", `section name "Setup" must be lowercase`}, {"duplicate", "title: t\ndescription: d\nsections:\n - name: a\n title: A\n - name: a\n title: B\n", `section "a" is listed twice`}, + {"site_url javascript", valid + "site_url: javascript:alert(1)\n", "site_url must be an http(s) URL with a host or a path starting with a single /"}, + {"site_url protocol-relative", valid + "site_url: //acme.example\n", "site_url must be an http(s) URL with a host or a path starting with a single /"}, + {"site_label without site_url", valid + "site_label: Acme\n", "site_label needs site_url"}, } { if _, err := ParseSite([]byte(tc.raw)); err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("%s: err = %v, want %q", tc.name, err, tc.want) } } + for _, u := range []string{"https://acme.example/", "/"} { + got, err := ParseSite([]byte(valid + "site_url: " + u + "\n")) + if err != nil || got.SiteURL != u { + t.Errorf("site_url %q: ParseSite = %+v, %v", u, got, err) + } + } if strings.Contains(func() string { _, err := ParseSite([]byte(valid + "colour: red\n")); return err.Error() }(), "\n") { t.Error("a decode error must be reported on one line") } diff --git a/internal/docsite/theme/assets/site.css b/internal/docsite/theme/assets/site.css index c1da2ed..357b9f6 100644 --- a/internal/docsite/theme/assets/site.css +++ b/internal/docsite/theme/assets/site.css @@ -280,6 +280,23 @@ a { color: var(--c-primary); } .wordmark-cms { color: var(--c-accent); } +/* Optional link back to the main site (site_url). */ +.site-link { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + color: var(--c-side-label); + font-size: 14px; + line-height: 1.5; + text-decoration: none; + transition: color var(--ease); +} + +.site-link:hover { color: var(--c-accent); } + +.site-link .icon { width: 16px; height: 16px; vertical-align: 0; } + .icon-button, .theme-toggle, .search-trigger { @@ -892,6 +909,7 @@ html.js .copy-button:not([hidden]) { display: inline-flex; } } @media (max-width: 639.98px) { + .site-link-label { display: none; } .pager { grid-template-columns: 1fr; } .pager-next { grid-column: 1; } .pager-empty { display: none; } diff --git a/internal/docsite/theme/templates/header.html b/internal/docsite/theme/templates/header.html index 236daf3..fe20673 100644 --- a/internal/docsite/theme/templates/header.html +++ b/internal/docsite/theme/templates/header.html @@ -1,7 +1,7 @@ {{define "header" -}}