feat: full precedence resolver with value sources
This commit is contained in:
@@ -4,9 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Config keys of the resolver.
|
||||
@@ -124,6 +128,67 @@ func (s *Service) UnprefixedLocale() string {
|
||||
return strings.TrimSpace(cfg.String(UnprefixedLocaleConfigKey))
|
||||
}
|
||||
|
||||
// Staging reports whether the site runs outside production. It depends on
|
||||
// the environment only (D-22): no setting, admin field or MCP tool can turn
|
||||
// the lock off.
|
||||
func (s *Service) Staging() bool {
|
||||
cfg := s.Config()
|
||||
if cfg == nil {
|
||||
return true
|
||||
}
|
||||
return cfg.Environment() != "production"
|
||||
}
|
||||
|
||||
// DefaultLocale is the site's default content locale: the translation
|
||||
// writer's default, else the app locale.
|
||||
func (s *Service) DefaultLocale(ctx context.Context, db *gorm.DB) string {
|
||||
if s != nil && s.app != nil {
|
||||
if w, ok := s.app.Lookup[cabana.TranslationWriter](); ok && w != nil {
|
||||
if code, err := w.DefaultLocale(ctx, db); err == nil && code != "" {
|
||||
return code
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg := s.Config(); cfg != nil {
|
||||
if code := strings.TrimSpace(cfg.String("app.locale")); code != "" {
|
||||
return code
|
||||
}
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// translatedSetting returns a translatable settings column in locale: the
|
||||
// stored translation when there is one, else the row's own value.
|
||||
func (s *Service) translatedSetting(ctx context.Context, db *gorm.DB, row *models.Settings, field, own, locale string) string {
|
||||
if s == nil || s.app == nil || locale == "" {
|
||||
return own
|
||||
}
|
||||
w, ok := s.app.Lookup[cabana.TranslationWriter]()
|
||||
if !ok || w == nil {
|
||||
return own
|
||||
}
|
||||
if def, err := w.DefaultLocale(ctx, db); err == nil && def == locale {
|
||||
return own
|
||||
}
|
||||
text, found, err := w.TranslatedExact(ctx, db, row, field, locale)
|
||||
if err != nil || !found || strings.TrimSpace(text) == "" {
|
||||
return own
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// LoadSettings reads settings row 1; without one the defaults apply.
|
||||
func LoadSettings(db *gorm.DB) (models.Settings, error) {
|
||||
var rows []models.Settings
|
||||
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Settings{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.DefaultSettings(), nil
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
// parseTarget turns the resolver input into a path and its query. A path
|
||||
// must start with "/"; an absolute URL must be http or https. Fragments are
|
||||
// dropped.
|
||||
@@ -184,39 +249,295 @@ func contentLocale(rec *pact.ContentRecord, locale string) pact.ContentLocale {
|
||||
return rec.Locales[0]
|
||||
}
|
||||
|
||||
// matchingRules returns the URL rules that apply to path and query, the
|
||||
// most specific first: exact rules, then patterns by the length of their
|
||||
// address. A rule address without "?" is compared with the path only; one
|
||||
// with a query with the path and the raw query.
|
||||
func matchingRules(db *gorm.DB, path, rawQuery string) ([]models.Rule, error) {
|
||||
var rows []models.Rule
|
||||
if err := db.Where("match = ? OR url = ? OR url = ?", models.MatchPattern, path, path+"?"+rawQuery).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
full := path
|
||||
if rawQuery != "" {
|
||||
full = path + "?" + rawQuery
|
||||
}
|
||||
var out []models.Rule
|
||||
for _, r := range rows {
|
||||
target := path
|
||||
if strings.Contains(r.URL, "?") {
|
||||
target = full
|
||||
}
|
||||
if r.Match == models.MatchPattern {
|
||||
if MatchGlob(r.URL, target) {
|
||||
out = append(out, r)
|
||||
}
|
||||
} else if r.URL == target {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if (out[i].Match == models.MatchExact) != (out[j].Match == models.MatchExact) {
|
||||
return out[i].Match == models.MatchExact
|
||||
}
|
||||
return len(out[i].URL) > len(out[j].URL)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ruleValue returns the first non-empty value of column among rules.
|
||||
func ruleValue(rules []models.Rule, column string) string {
|
||||
for _, r := range rules {
|
||||
if v := strings.TrimSpace(r.Value(column)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pick returns the first non-empty candidate and its source.
|
||||
type candidate struct {
|
||||
value string
|
||||
source string
|
||||
}
|
||||
|
||||
func pick(cands ...candidate) (string, string) {
|
||||
for _, c := range cands {
|
||||
if strings.TrimSpace(c.value) != "" {
|
||||
return strings.TrimSpace(c.value), c.source
|
||||
}
|
||||
}
|
||||
return "", SourceDefault
|
||||
}
|
||||
|
||||
// robotsLayer is one layer of the robots precedence chain.
|
||||
type robotsLayer struct {
|
||||
value string
|
||||
source string
|
||||
}
|
||||
|
||||
// resolveRobots merges the layers per directive (UI-SPEC C5): each
|
||||
// directive comes from the first layer that sets it, index,follow last.
|
||||
// The source is the layer that decided indexing. A malformed stored value
|
||||
// is skipped.
|
||||
func resolveRobots(layers []robotsLayer) (Robots, string) {
|
||||
var out Robots
|
||||
source := SourceDefault
|
||||
for _, layer := range layers {
|
||||
r, err := ParseRobots(layer.value)
|
||||
if err != nil || r.Empty() {
|
||||
continue
|
||||
}
|
||||
if out.Index == "" && r.Index != "" {
|
||||
source = layer.source
|
||||
}
|
||||
out = out.Inherit(r)
|
||||
}
|
||||
return out.Inherit(Robots{Index: "index", Follow: "follow"}), source
|
||||
}
|
||||
|
||||
// Resolve answers the effective SEO of rawURL, a path or an absolute URL of
|
||||
// the site. A path no published record owns answers not_found.
|
||||
// the site, with the D-02 precedence per value (P-14): URL rule > record >
|
||||
// per-type template or global settings > content-derived values. A path
|
||||
// with neither a published record nor a URL rule answers not_found, except
|
||||
// the home page. Outside production every answer is noindex,nofollow with
|
||||
// staging true (D-22). Absolute URLs come from SiteURL, never from a
|
||||
// request.
|
||||
func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) {
|
||||
path, _, err := parseTarget(rawURL)
|
||||
path, query, err := parseTarget(rawURL)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
svc := r.Service
|
||||
db, err := svc.DB(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
settings, err := LoadSettings(db)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
site := svc.SiteURL()
|
||||
kept, filtered := splitQuery(settings.Params(), path, query)
|
||||
self := site + path
|
||||
if q := encodeQuery(kept); q != "" {
|
||||
self += "?" + q
|
||||
}
|
||||
res := Result{Action: ActionNotFound, Status: 404, URL: self, Staging: svc.Staging(), Sources: map[string]string{}}
|
||||
|
||||
rules, err := matchingRules(db, path, rawQueryOf(rawURL))
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
site := r.Service.SiteURL()
|
||||
res := Result{Action: ActionNotFound, Status: 404, URL: site + path, Sources: map[string]string{}}
|
||||
key, match, rec, err := r.lookup(ctx, path)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if key == "" {
|
||||
home := path == "/"
|
||||
if key == "" && len(rules) == 0 && !home {
|
||||
return res, nil
|
||||
}
|
||||
typ, _ := r.Service.Registry().ContentType(key)
|
||||
db, err := r.Service.DB(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
|
||||
locale := ""
|
||||
var entry models.Entry
|
||||
var content pact.ContentLocale
|
||||
typeSetting := models.TypeSetting{Inject: true}
|
||||
if key != "" {
|
||||
typ, _ := svc.Registry().ContentType(key)
|
||||
locale = match.Locale
|
||||
if entry, _, err = LoadEntry(db, typ.Content.Model, match.ID, locale); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
content = contentLocale(rec, locale)
|
||||
if typeSetting, err = LoadTypeSetting(db, key); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
res.TypeKey, res.RecordID = key, match.ID
|
||||
}
|
||||
entry, _, err := LoadEntry(db, typ.Content.Model, match.ID, match.Locale)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
if locale == "" {
|
||||
locale = svc.DefaultLocale(ctx, db)
|
||||
}
|
||||
content := contentLocale(rec, match.Locale)
|
||||
seo := &SEO{Canonical: site + path, Verification: []MetaTag{}}
|
||||
if entry.Title != "" {
|
||||
seo.Title, res.Sources["title"] = entry.Title, SourceRecord
|
||||
siteName := svc.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, locale)
|
||||
defaultDescription := svc.translatedSetting(ctx, db, &settings, "default_description", settings.DefaultDescription, locale)
|
||||
vars := TemplateVars{Title: content.Title, Description: content.Description, Site: siteName}
|
||||
|
||||
seo := &SEO{Verification: []MetaTag{}}
|
||||
src := res.Sources
|
||||
|
||||
// Title: rule > record > type template, else the general template, on
|
||||
// the content title > content title > site name. The home page without
|
||||
// a record uses the home template.
|
||||
var titleTemplate candidate
|
||||
switch {
|
||||
case key != "":
|
||||
tpl := typeSetting.TitleTemplate
|
||||
if strings.TrimSpace(tpl) == "" {
|
||||
tpl = settings.TitleTemplate
|
||||
}
|
||||
titleTemplate = candidate{RenderTemplate(tpl, vars), SourceTemplate}
|
||||
case home:
|
||||
titleTemplate = candidate{RenderTemplate(settings.HomeTemplate, vars), SourceTemplate}
|
||||
}
|
||||
seo.Title, src["title"] = pick(
|
||||
candidate{ruleValue(rules, "title"), SourceRule},
|
||||
candidate{entry.Title, SourceRecord},
|
||||
titleTemplate,
|
||||
candidate{content.Title, SourceContent},
|
||||
candidate{siteName, SourceGlobal},
|
||||
)
|
||||
|
||||
// Description: rule > record > type template > content > settings.
|
||||
var descTemplate candidate
|
||||
if key != "" {
|
||||
descTemplate = candidate{RenderTemplate(typeSetting.DescriptionTemplate, vars), SourceTemplate}
|
||||
}
|
||||
seo.Description, src["description"] = pick(
|
||||
candidate{ruleValue(rules, "description"), SourceRule},
|
||||
candidate{entry.Description, SourceRecord},
|
||||
descTemplate,
|
||||
candidate{content.Description, SourceContent},
|
||||
candidate{defaultDescription, SourceGlobal},
|
||||
)
|
||||
|
||||
// Canonical: rule > record > self.
|
||||
canonical, canonicalSource := pick(
|
||||
candidate{ruleValue(rules, "canonical"), SourceRule},
|
||||
candidate{entry.Canonical, SourceRecord},
|
||||
)
|
||||
if canonical == "" {
|
||||
canonical, canonicalSource = self, SourceDefault
|
||||
} else {
|
||||
seo.Title, res.Sources["title"] = content.Title, SourceContent
|
||||
canonical = AbsoluteURL(site, canonical)
|
||||
}
|
||||
res.Action, res.Status, res.Locale, res.SEO = ActionOK, 200, match.Locale, seo
|
||||
res.TypeKey, res.RecordID = key, match.ID
|
||||
seo.Canonical, src["canonical"] = canonical, canonicalSource
|
||||
|
||||
// Robots per directive: rule > record > never-indexed paths, search
|
||||
// paths and filter URLs > type default > index,follow.
|
||||
layers := []robotsLayer{
|
||||
{ruleValue(rules, "robots"), SourceRule},
|
||||
{entry.Robots, SourceRecord},
|
||||
}
|
||||
switch {
|
||||
case MatchPathList(settings.NoindexPaths, path):
|
||||
layers = append(layers, robotsLayer{"noindex,nofollow", SourceGlobal})
|
||||
case MatchPathList(settings.SearchPaths, path) || filtered:
|
||||
layers = append(layers, robotsLayer{"noindex,follow", SourceGlobal})
|
||||
}
|
||||
if key != "" {
|
||||
layers = append(layers, robotsLayer{typeSetting.Robots, SourceTemplate})
|
||||
}
|
||||
robots, robotsSource := resolveRobots(layers)
|
||||
if res.Staging {
|
||||
robots, robotsSource = Robots{Index: "noindex", Follow: "nofollow"}, SourceDefault
|
||||
}
|
||||
seo.Robots, src["robots"] = robots.Header(), robotsSource
|
||||
seo.XRobotsTag = robots.Header()
|
||||
|
||||
// Social values (D-04): overrides, else the effective title and
|
||||
// description; the image falls back to the content's, then the
|
||||
// settings' default social image.
|
||||
seo.OG.Title, src["og_title"] = pick(
|
||||
candidate{ruleValue(rules, "og_title"), SourceRule},
|
||||
candidate{entry.OGTitle, SourceRecord},
|
||||
candidate{seo.Title, src["title"]},
|
||||
)
|
||||
seo.OG.Description, src["og_description"] = pick(
|
||||
candidate{ruleValue(rules, "og_description"), SourceRule},
|
||||
candidate{entry.OGDescription, SourceRecord},
|
||||
candidate{seo.Description, src["description"]},
|
||||
)
|
||||
image, imageSource := pick(
|
||||
candidate{ruleValue(rules, "og_image"), SourceRule},
|
||||
candidate{entry.OGImage, SourceRecord},
|
||||
candidate{content.Image, SourceContent},
|
||||
candidate{settings.DefaultOGImage, SourceGlobal},
|
||||
)
|
||||
if image != "" {
|
||||
image = AbsoluteURL(site, image)
|
||||
}
|
||||
seo.OG.Image, src["og_image"] = image, imageSource
|
||||
seo.OG.URL = seo.Canonical
|
||||
seo.Twitter.Card = "summary"
|
||||
if image != "" {
|
||||
seo.Twitter.Card = "summary_large_image"
|
||||
}
|
||||
seo.Twitter.Site = strings.TrimSpace(settings.TwitterSite)
|
||||
|
||||
// Verification meta (D-05) on the home page only.
|
||||
if home {
|
||||
seo.Verification = verificationMeta(settings)
|
||||
}
|
||||
|
||||
res.Action, res.Status, res.Locale, res.SEO = ActionOK, 200, locale, seo
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// rawQueryOf returns the raw query of the resolver input.
|
||||
func rawQueryOf(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.RawQuery
|
||||
}
|
||||
|
||||
// verificationMeta lists the verification meta tags of the settings.
|
||||
func verificationMeta(s models.Settings) []MetaTag {
|
||||
out := []MetaTag{}
|
||||
for _, m := range []MetaTag{
|
||||
{Name: "google-site-verification", Content: s.VerifyGoogle},
|
||||
{Name: "msvalidate.01", Content: s.VerifyBing},
|
||||
{Name: "yandex-verification", Content: s.VerifyYandex},
|
||||
} {
|
||||
if strings.TrimSpace(m.Content) != "" {
|
||||
m.Content = strings.TrimSpace(m.Content)
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
for _, m := range s.OtherMeta() {
|
||||
if strings.TrimSpace(m.Name) != "" && strings.TrimSpace(m.Content) != "" {
|
||||
out = append(out, MetaTag{Name: strings.TrimSpace(m.Name), Content: strings.TrimSpace(m.Content)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
195
classes/robots_value.go
Normal file
195
classes/robots_value.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrRobotsValue is the refusal of a malformed robots value.
|
||||
var ErrRobotsValue = errors.New("golem15.seo: malformed robots value")
|
||||
|
||||
// Robots is a parsed robots value (UI-SPEC C5, D-08): comma-separated
|
||||
// directives in the fixed order index|noindex, follow|nofollow, noarchive,
|
||||
// nosnippet, noimageindex, max-snippet:N, max-image-preview:none|standard|
|
||||
// large, max-video-preview:N, unavailable_after:YYYY-MM-DD. An empty string
|
||||
// inherits everything; an unset directive inherits too.
|
||||
type Robots struct {
|
||||
Index string // "index", "noindex" or "" (inherit)
|
||||
Follow string // "follow", "nofollow" or "" (inherit)
|
||||
NoArchive bool
|
||||
NoSnippet bool
|
||||
NoImageIndex bool
|
||||
MaxSnippet *int
|
||||
MaxImagePreview string
|
||||
MaxVideoPreview *int
|
||||
UnavailableAfter string
|
||||
}
|
||||
|
||||
var robotsDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
|
||||
// ParseRobots reads a robots value; spaces around directives and their
|
||||
// case are ignored. A directive outside the grammar, a repeated directive,
|
||||
// two contradicting directives or a malformed number or date is refused.
|
||||
func ParseRobots(value string) (Robots, error) {
|
||||
var r Robots
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return r, nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
d := strings.ToLower(strings.TrimSpace(part))
|
||||
if d == "" {
|
||||
return Robots{}, fmt.Errorf("%w: empty directive", ErrRobotsValue)
|
||||
}
|
||||
name, arg, hasArg := strings.Cut(d, ":")
|
||||
name, arg = strings.TrimSpace(name), strings.TrimSpace(arg)
|
||||
group := name
|
||||
switch name {
|
||||
case "index", "noindex":
|
||||
group = "index"
|
||||
case "follow", "nofollow":
|
||||
group = "follow"
|
||||
}
|
||||
if seen[group] {
|
||||
return Robots{}, fmt.Errorf("%w: %s given twice", ErrRobotsValue, group)
|
||||
}
|
||||
seen[group] = true
|
||||
switch name {
|
||||
case "index", "noindex":
|
||||
if hasArg {
|
||||
return Robots{}, fmt.Errorf("%w: %s", ErrRobotsValue, d)
|
||||
}
|
||||
r.Index = name
|
||||
case "follow", "nofollow":
|
||||
if hasArg {
|
||||
return Robots{}, fmt.Errorf("%w: %s", ErrRobotsValue, d)
|
||||
}
|
||||
r.Follow = name
|
||||
case "noarchive", "nosnippet", "noimageindex":
|
||||
if hasArg {
|
||||
return Robots{}, fmt.Errorf("%w: %s", ErrRobotsValue, d)
|
||||
}
|
||||
switch name {
|
||||
case "noarchive":
|
||||
r.NoArchive = true
|
||||
case "nosnippet":
|
||||
r.NoSnippet = true
|
||||
default:
|
||||
r.NoImageIndex = true
|
||||
}
|
||||
case "max-snippet", "max-video-preview":
|
||||
n, err := strconv.Atoi(arg)
|
||||
if !hasArg || err != nil || n < -1 {
|
||||
return Robots{}, fmt.Errorf("%w: %s", ErrRobotsValue, d)
|
||||
}
|
||||
if name == "max-snippet" {
|
||||
r.MaxSnippet = &n
|
||||
} else {
|
||||
r.MaxVideoPreview = &n
|
||||
}
|
||||
case "max-image-preview":
|
||||
if arg != "none" && arg != "standard" && arg != "large" {
|
||||
return Robots{}, fmt.Errorf("%w: %s", ErrRobotsValue, d)
|
||||
}
|
||||
r.MaxImagePreview = arg
|
||||
case "unavailable_after":
|
||||
if !robotsDate.MatchString(arg) {
|
||||
return Robots{}, fmt.Errorf("%w: %s", ErrRobotsValue, d)
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", arg); err != nil {
|
||||
return Robots{}, fmt.Errorf("%w: %s", ErrRobotsValue, d)
|
||||
}
|
||||
r.UnavailableAfter = arg
|
||||
default:
|
||||
return Robots{}, fmt.Errorf("%w: unknown directive %s", ErrRobotsValue, name)
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// directives lists the set directives in the fixed order.
|
||||
func (r Robots) directives() []string {
|
||||
var out []string
|
||||
if r.Index != "" {
|
||||
out = append(out, r.Index)
|
||||
}
|
||||
if r.Follow != "" {
|
||||
out = append(out, r.Follow)
|
||||
}
|
||||
if r.NoArchive {
|
||||
out = append(out, "noarchive")
|
||||
}
|
||||
if r.NoSnippet {
|
||||
out = append(out, "nosnippet")
|
||||
}
|
||||
if r.NoImageIndex {
|
||||
out = append(out, "noimageindex")
|
||||
}
|
||||
if r.MaxSnippet != nil {
|
||||
out = append(out, "max-snippet:"+strconv.Itoa(*r.MaxSnippet))
|
||||
}
|
||||
if r.MaxImagePreview != "" {
|
||||
out = append(out, "max-image-preview:"+r.MaxImagePreview)
|
||||
}
|
||||
if r.MaxVideoPreview != nil {
|
||||
out = append(out, "max-video-preview:"+strconv.Itoa(*r.MaxVideoPreview))
|
||||
}
|
||||
if r.UnavailableAfter != "" {
|
||||
out = append(out, "unavailable_after:"+r.UnavailableAfter)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// String serialises the stored form: directives in the fixed order joined
|
||||
// by ",". The empty value inherits everything.
|
||||
func (r Robots) String() string { return strings.Join(r.directives(), ",") }
|
||||
|
||||
// Header serialises the value for a meta robots tag and the X-Robots-Tag
|
||||
// header: the same directives joined by ", ".
|
||||
func (r Robots) Header() string { return strings.Join(r.directives(), ", ") }
|
||||
|
||||
// Empty reports whether no directive is set.
|
||||
func (r Robots) Empty() bool { return len(r.directives()) == 0 }
|
||||
|
||||
// Inherit fills every directive r leaves unset from lower (the next layer
|
||||
// of the precedence chain); a directive r sets wins.
|
||||
func (r Robots) Inherit(lower Robots) Robots {
|
||||
out := r
|
||||
if out.Index == "" {
|
||||
out.Index = lower.Index
|
||||
}
|
||||
if out.Follow == "" {
|
||||
out.Follow = lower.Follow
|
||||
}
|
||||
out.NoArchive = out.NoArchive || lower.NoArchive
|
||||
out.NoSnippet = out.NoSnippet || lower.NoSnippet
|
||||
out.NoImageIndex = out.NoImageIndex || lower.NoImageIndex
|
||||
if out.MaxSnippet == nil {
|
||||
out.MaxSnippet = lower.MaxSnippet
|
||||
}
|
||||
if out.MaxImagePreview == "" {
|
||||
out.MaxImagePreview = lower.MaxImagePreview
|
||||
}
|
||||
if out.MaxVideoPreview == nil {
|
||||
out.MaxVideoPreview = lower.MaxVideoPreview
|
||||
}
|
||||
if out.UnavailableAfter == "" {
|
||||
out.UnavailableAfter = lower.UnavailableAfter
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NormalizeRobots parses and re-serialises a robots value in the fixed
|
||||
// order; a malformed value is an error.
|
||||
func NormalizeRobots(value string) (string, error) {
|
||||
r, err := ParseRobots(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.String(), nil
|
||||
}
|
||||
40
classes/templates.go
Normal file
40
classes/templates.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Template placeholders (D-03): the content's title, its own description
|
||||
// or excerpt, and the site name.
|
||||
const (
|
||||
PlaceholderTitle = "%title%"
|
||||
PlaceholderDescription = "%description%"
|
||||
PlaceholderSite = "%site%"
|
||||
)
|
||||
|
||||
// TemplateVars are the values a template's placeholders take.
|
||||
type TemplateVars struct {
|
||||
Title string
|
||||
Description string
|
||||
Site string
|
||||
}
|
||||
|
||||
// templateTrim are the separator characters left at either end when a
|
||||
// placeholder renders empty ("About | " without a site name).
|
||||
const templateTrim = " \t|-–—·:,"
|
||||
|
||||
// RenderTemplate fills a title or description template. Runs of
|
||||
// whitespace collapse to one space and separators left at either end by an
|
||||
// empty placeholder are trimmed. An empty template renders "".
|
||||
func RenderTemplate(tpl string, vars TemplateVars) string {
|
||||
if strings.TrimSpace(tpl) == "" {
|
||||
return ""
|
||||
}
|
||||
out := strings.NewReplacer(
|
||||
PlaceholderTitle, vars.Title,
|
||||
PlaceholderDescription, vars.Description,
|
||||
PlaceholderSite, vars.Site,
|
||||
).Replace(tpl)
|
||||
out = strings.Join(strings.Fields(out), " ")
|
||||
return strings.Trim(out, templateTrim)
|
||||
}
|
||||
137
classes/urls.go
Normal file
137
classes/urls.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
)
|
||||
|
||||
// globCache keeps compiled glob patterns.
|
||||
var globCache sync.Map
|
||||
|
||||
// globRegexp compiles a pattern where * matches any text and everything
|
||||
// else is literal, anchored at both ends.
|
||||
func globRegexp(pattern string) *regexp.Regexp {
|
||||
if re, ok := globCache.Load(pattern); ok {
|
||||
return re.(*regexp.Regexp)
|
||||
}
|
||||
parts := strings.Split(pattern, "*")
|
||||
for i, p := range parts {
|
||||
parts[i] = regexp.QuoteMeta(p)
|
||||
}
|
||||
re := regexp.MustCompile(`^` + strings.Join(parts, `.*`) + `$`)
|
||||
globCache.Store(pattern, re)
|
||||
return re
|
||||
}
|
||||
|
||||
// MatchGlob reports whether s matches pattern (* matches any text).
|
||||
func MatchGlob(pattern, s string) bool {
|
||||
if !strings.Contains(pattern, "*") {
|
||||
return pattern == s
|
||||
}
|
||||
return globRegexp(pattern).MatchString(s)
|
||||
}
|
||||
|
||||
// MatchPathList reports whether path matches one pattern of a list given
|
||||
// one per line (empty lines ignored).
|
||||
func MatchPathList(list, path string) bool {
|
||||
for _, line := range strings.Split(list, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if MatchGlob(line, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// paramKind returns the kind of query parameter name on path from the D-07
|
||||
// list: a row limited to a path that matches wins over a site-wide row; a
|
||||
// parameter the list does not name is "" (dropped from the canonical,
|
||||
// without noindex).
|
||||
func paramKind(params []models.QueryParam, name, path string) string {
|
||||
kind, scoped := "", false
|
||||
for _, p := range params {
|
||||
pn := strings.TrimSpace(p.Name)
|
||||
if pn == "" {
|
||||
continue
|
||||
}
|
||||
matches := pn == name || (strings.HasSuffix(pn, "*") && strings.HasPrefix(name, strings.TrimSuffix(pn, "*")))
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
if pp := strings.TrimSpace(p.Path); pp != "" {
|
||||
if MatchGlob(pp, path) {
|
||||
kind, scoped = p.Kind, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !scoped {
|
||||
kind = p.Kind
|
||||
}
|
||||
}
|
||||
return kind
|
||||
}
|
||||
|
||||
// splitQuery applies the D-07 list to a page's query: kept holds the
|
||||
// content parameters (sorted, for a stable canonical) and filtered is true
|
||||
// when a filter or sort parameter is present.
|
||||
func splitQuery(params []models.QueryParam, path string, query url.Values) (kept url.Values, filtered bool) {
|
||||
kept = url.Values{}
|
||||
for name, values := range query {
|
||||
switch paramKind(params, name, path) {
|
||||
case models.ParamContent:
|
||||
kept[name] = values
|
||||
case models.ParamFilter:
|
||||
filtered = true
|
||||
}
|
||||
}
|
||||
return kept, filtered
|
||||
}
|
||||
|
||||
// encodeQuery encodes kept parameters in name order; "" when none.
|
||||
func encodeQuery(q url.Values) string {
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(q))
|
||||
for name := range q {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
for _, name := range names {
|
||||
for _, v := range q[name] {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('&')
|
||||
}
|
||||
b.WriteString(url.QueryEscape(name))
|
||||
b.WriteByte('=')
|
||||
b.WriteString(url.QueryEscape(v))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// AbsoluteURL makes a canonical override absolute: an http(s) URL stays
|
||||
// as it is (it may point to another domain), a path is joined to the site
|
||||
// URL.
|
||||
func AbsoluteURL(site, ref string) string {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(ref); err == nil && u.IsAbs() {
|
||||
return ref
|
||||
}
|
||||
if !strings.HasPrefix(ref, "/") {
|
||||
ref = "/" + ref
|
||||
}
|
||||
return site + ref
|
||||
}
|
||||
Reference in New Issue
Block a user