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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user