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
This commit is contained in:
@@ -29,6 +29,8 @@ func docsBuildCommand() bonfire.Command {
|
|||||||
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
||||||
{Name: "out", Description: "Output directory (default <root>/site)"},
|
{Name: "out", Description: "Output directory (default <root>/site)"},
|
||||||
{Name: "base-url", Description: "Base URL for site links (overrides site.yaml base_url)"},
|
{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},
|
{Name: "check", Description: "Validate the docs and write nothing", Bare: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
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: "root", Description: "Repository root; src= paths and modules/ resolve against it", Default: "."},
|
||||||
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
|
||||||
{Name: "base-url", Description: "Base URL for site links (overrides site.yaml base_url)"},
|
{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: "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},
|
{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.Src, _ = in.Flag("src")
|
||||||
opts.Out, _ = in.Flag("out")
|
opts.Out, _ = in.Flag("out")
|
||||||
opts.BaseURL, _ = in.Flag("base-url")
|
opts.BaseURL, _ = in.Flag("base-url")
|
||||||
|
opts.SiteURL, _ = in.Flag("site-url")
|
||||||
|
opts.SiteLabel, _ = in.Flag("site-label")
|
||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// requiredPages lists the guide pages the docs must keep. Each content plan
|
||||||
// appends the pages it writes.
|
// appends the pages it writes.
|
||||||
var requiredPages = []string{
|
var requiredPages = []string{
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ These docs are Markdown files under `docs/`, plus every module README, built int
|
|||||||
|
|
||||||
| Command | Flags | Purpose |
|
| 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: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
|
```sh
|
||||||
summer docs:build --check
|
summer docs:build --check
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ type Options struct {
|
|||||||
Out string
|
Out string
|
||||||
// BaseURL overrides site.yaml base_url when non-empty.
|
// BaseURL overrides site.yaml base_url when non-empty.
|
||||||
BaseURL string
|
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
|
// Commands is the set of summer and application command names pages
|
||||||
// may show. Check and Build report a problem when it is nil.
|
// may show. Check and Build report a problem when it is nil.
|
||||||
Commands *Commands
|
Commands *Commands
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ type pageView struct {
|
|||||||
Content template.HTML
|
Content template.HTML
|
||||||
Assets string
|
Assets string
|
||||||
HomeURL 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
|
SearchIndex string
|
||||||
LLMS string
|
LLMS string
|
||||||
LLMSFull string
|
LLMSFull string
|
||||||
@@ -85,6 +90,8 @@ func (s *site) baseView(current *Page) pageView {
|
|||||||
return pageView{
|
return pageView{
|
||||||
Assets: s.url("assets"),
|
Assets: s.url("assets"),
|
||||||
HomeURL: s.url("index.html"),
|
HomeURL: s.url("index.html"),
|
||||||
|
SiteURL: s.siteURL,
|
||||||
|
SiteLabel: s.siteLabel,
|
||||||
SearchIndex: s.url("search-index.json"),
|
SearchIndex: s.url("search-index.json"),
|
||||||
LLMS: s.url("llms.txt"),
|
LLMS: s.url("llms.txt"),
|
||||||
LLMSFull: s.url("llms-full.txt"),
|
LLMSFull: s.url("llms-full.txt"),
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
@@ -28,6 +30,12 @@ type Site struct {
|
|||||||
SourceURL string `yaml:"source_url"`
|
SourceURL string `yaml:"source_url"`
|
||||||
LLMSNotes []string `yaml:"llms_notes"`
|
LLMSNotes []string `yaml:"llms_notes"`
|
||||||
Sections []Section `yaml:"sections"`
|
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.
|
// 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
|
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
|
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-]*$`)
|
var slugName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||||
|
|
||||||
func firstLine(s string) string {
|
func firstLine(s string) string {
|
||||||
@@ -144,6 +205,10 @@ type site struct {
|
|||||||
cfg Site
|
cfg Site
|
||||||
cfgRaw []byte
|
cfgRaw []byte
|
||||||
base string
|
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
|
pages []*Page // reading order
|
||||||
bySource map[string]*Page
|
bySource map[string]*Page
|
||||||
outputs map[string][]byte
|
outputs map[string][]byte
|
||||||
@@ -219,6 +284,25 @@ func load(opts Options) (*site, []Problem, error) {
|
|||||||
if opts.BaseURL != "" {
|
if opts.BaseURL != "" {
|
||||||
s.base = strings.TrimRight(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()
|
guides, problems, err := s.loadGuides()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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`},
|
{"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`},
|
{"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`},
|
{"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) {
|
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)
|
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") {
|
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")
|
t.Error("a decode error must be reported on one line")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,23 @@ a { color: var(--c-primary); }
|
|||||||
|
|
||||||
.wordmark-cms { color: var(--c-accent); }
|
.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,
|
.icon-button,
|
||||||
.theme-toggle,
|
.theme-toggle,
|
||||||
.search-trigger {
|
.search-trigger {
|
||||||
@@ -892,6 +909,7 @@ html.js .copy-button:not([hidden]) { display: inline-flex; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 639.98px) {
|
@media (max-width: 639.98px) {
|
||||||
|
.site-link-label { display: none; }
|
||||||
.pager { grid-template-columns: 1fr; }
|
.pager { grid-template-columns: 1fr; }
|
||||||
.pager-next { grid-column: 1; }
|
.pager-next { grid-column: 1; }
|
||||||
.pager-empty { display: none; }
|
.pager-empty { display: none; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "header" -}}
|
{{define "header" -}}
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<button class="menu-button icon-button" type="button" aria-label="Open navigation" aria-expanded="false" aria-controls="sidebar">{{template "icon-menu"}}{{template "icon-x"}}</button>
|
<button class="menu-button icon-button" type="button" aria-label="Open navigation" aria-expanded="false" aria-controls="sidebar">{{template "icon-menu"}}{{template "icon-x"}}</button>
|
||||||
<a class="wordmark" href="{{.HomeURL}}" aria-label="SummerCMS documentation home"><span class="wordmark-sun">{{template "icon-sun"}}</span><span class="wordmark-text">Summer<span class="wordmark-cms">CMS</span></span></a>
|
<a class="wordmark" href="{{.HomeURL}}" aria-label="SummerCMS documentation home"><span class="wordmark-sun">{{template "icon-sun"}}</span><span class="wordmark-text">Summer<span class="wordmark-cms">CMS</span></span></a>{{if .SiteURL}}<a class="site-link" href="{{.SiteURL}}" aria-label="{{.SiteLabel}}">{{template "icon-chevron-left"}}<span class="site-link-label">{{.SiteLabel}}</span></a>{{end}}
|
||||||
<div class="header-spacer"></div>
|
<div class="header-spacer"></div>
|
||||||
<button class="search-trigger" type="button" aria-label="Search docs" aria-haspopup="dialog" aria-controls="search">{{template "icon-search"}}<span class="search-trigger-label">Search docs</span><kbd>Ctrl K</kbd></button>
|
<button class="search-trigger" type="button" aria-label="Search docs" aria-haspopup="dialog" aria-controls="search">{{template "icon-search"}}<span class="search-trigger-label">Search docs</span><kbd>Ctrl K</kbd></button>
|
||||||
<button class="theme-toggle" type="button" data-theme="system" aria-label="Color theme: System. Switch to Light." title="Color theme: System. Switch to Light.">{{template "icon-monitor"}}{{template "icon-sun"}}{{template "icon-moon"}}</button>
|
<button class="theme-toggle" type="button" data-theme="system" aria-label="Color theme: System. Switch to Light." title="Color theme: System. Switch to Light.">{{template "icon-monitor"}}{{template "icon-sun"}}{{template "icon-moon"}}</button>
|
||||||
|
|||||||
@@ -77,6 +77,39 @@ func assertCSPSafe(t *testing.T, name, html string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSiteLink checks the optional link back to the main site on a page
|
||||||
|
// and on the 404 page: absent without a site URL, labelled explicitly, by
|
||||||
|
// the URL host, or Home.
|
||||||
|
func TestSiteLink(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
opts Options
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"unset", Options{}, nil},
|
||||||
|
{"explicit label", Options{SiteURL: "/", SiteLabel: "acme.example"}, []string{`<a class="site-link" href="/"`, `<span class="site-link-label">acme.example</span>`}},
|
||||||
|
{"host label", Options{SiteURL: "https://acme.example/docs"}, []string{`<a class="site-link" href="https://acme.example/docs"`, `<span class="site-link-label">acme.example</span>`}},
|
||||||
|
{"home label", Options{SiteURL: "/"}, []string{`<a class="site-link" href="/"`, `<span class="site-link-label">Home</span>`}},
|
||||||
|
} {
|
||||||
|
opts := tc.opts
|
||||||
|
opts.Root, opts.Commands, opts.Out = themeTree(t), fixtureCommands, filepath.Join(t.TempDir(), "site")
|
||||||
|
if _, problems, err := Build(opts); err != nil || len(problems) > 0 {
|
||||||
|
t.Fatalf("%s: Build: %v %q", tc.name, err, problemLines(problems))
|
||||||
|
}
|
||||||
|
for _, name := range []string{"index.html", "404.html"} {
|
||||||
|
html := readOut(t, opts.Out, name)
|
||||||
|
if tc.want == nil && strings.Contains(html, "site-link") {
|
||||||
|
t.Errorf("%s: %s has a site link", tc.name, name)
|
||||||
|
}
|
||||||
|
for _, w := range tc.want {
|
||||||
|
if !strings.Contains(html, w) {
|
||||||
|
t.Errorf("%s: %s lacks %s", tc.name, name, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildSiteMarkers(t *testing.T) {
|
func TestBuildSiteMarkers(t *testing.T) {
|
||||||
out := buildTheme(t)
|
out := buildTheme(t)
|
||||||
index := readOut(t, out, "index.html")
|
index := readOut(t, out, "index.html")
|
||||||
|
|||||||
Reference in New Issue
Block a user