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:
Jakub Zych
2026-10-01 16:09:40 +02:00
parent 7936234e8c
commit a494375db7
10 changed files with 207 additions and 10 deletions

View File

@@ -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 {