feat: full precedence resolver with value sources
This commit is contained in:
@@ -5,9 +5,9 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// Permissions declares the one SEO permission (D-15): the SEO tab of every
|
||||
// content type, URL rules, the settings and history, in the admin and
|
||||
// through MCP.
|
||||
// Permissions declares the one SEO permission, golem15.seo.manage (D-15):
|
||||
// the SEO tab of every content type, URL rules, the settings and history,
|
||||
// in the admin and through MCP.
|
||||
func (*Plugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{
|
||||
Code: controllers.PermissionManage,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
192
settings_test.go
192
settings_test.go
@@ -84,3 +84,195 @@ func TestSettingsTypeTabs(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestResolverPrecedence covers each P-14 chain of the resolver: the value
|
||||
// and its source per case, with URL rules (exact and pattern), records,
|
||||
// per-type templates and defaults, content, the settings, the D-07
|
||||
// parameter list, the never-indexed and search paths, the home page and
|
||||
// the non-production lock.
|
||||
func TestResolverPrecedence(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{
|
||||
"site_name": map[string]any{"en": "Acme", "pl": "Akme"},
|
||||
"default_description": map[string]any{"en": "Site default description"},
|
||||
"default_og_image": "/media/default.png",
|
||||
"twitter_site": "@acme",
|
||||
"verify_google": "g-code",
|
||||
"noindex_paths": "/konto/*\n/logowanie",
|
||||
"search_paths": "/szukaj",
|
||||
"query_params": []map[string]any{
|
||||
{"name": "page", "kind": "content", "path": ""},
|
||||
{"name": "utm_*", "kind": "tracking", "path": ""},
|
||||
{"name": "sort", "kind": "filter", "path": ""},
|
||||
{"name": "color", "kind": "filter", "path": "/pages/*"},
|
||||
},
|
||||
"type_acme_tag_description_template": "Posts tagged %title%",
|
||||
})
|
||||
about := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "excerpt": "Who we are", "published": true})
|
||||
custom := env.createPage(t, token, map[string]any{"title": "Custom", "slug": "custom", "excerpt": "Custom excerpt", "published": true})
|
||||
env.createPage(t, token, map[string]any{"title": "Bare", "slug": "bare", "published": true})
|
||||
env.createPage(t, token, map[string]any{"title": "Draft", "slug": "draft", "excerpt": "Secret", "published": false})
|
||||
if err := env.db.Exec(`INSERT INTO golem15_seo_entries (model_type, record_id, locale, type_key, title, description, canonical, robots, og_image, created_at, updated_at)
|
||||
VALUES (?, ?, 'en', ?, 'Custom SEO title', 'Custom SEO description', '/canonical-custom', 'noindex', '/media/custom.png', NOW(), NOW())`, pagesModel, custom, pagesTypeKey).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range []map[string]any{
|
||||
{"url": "/pages/custom", "match": "exact", "og_title": "Rule social title"},
|
||||
{"url": "/blog?tag=*", "match": "pattern", "title": "Tag archive", "robots": "noindex,follow"},
|
||||
{"url": "/landing", "match": "exact", "title": "Landing", "description": "Landing description", "canonical": "https://other.example/landing", "og_image": "https://cdn.example/landing.png"},
|
||||
{"url": "/konto/profile", "match": "exact", "title": "Profile"},
|
||||
{"url": "/szukaj", "match": "exact", "title": "Search"},
|
||||
{"url": "/pages/about", "match": "exact", "robots": "max-snippet:50"},
|
||||
} {
|
||||
env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, rule)
|
||||
}
|
||||
_ = about
|
||||
|
||||
resolver := env.seoPlugin(t).Service().Resolver()
|
||||
type expectation struct {
|
||||
value, source string
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want map[string]expectation // title, description, canonical, robots, og_title, og_description, og_image
|
||||
}{
|
||||
{"title from the general template, description from content, self canonical, default robots", "/pages/about", map[string]expectation{
|
||||
"title": {"About | Acme", "template"},
|
||||
"description": {"Who we are", "content"},
|
||||
"canonical": {siteURL + "/pages/about", "default"},
|
||||
"robots": {"index, follow, max-snippet:50", "default"},
|
||||
"og_title": {"About | Acme", "template"},
|
||||
"og_image": {siteURL + "/media/default.png", "global"},
|
||||
}},
|
||||
{"record values win over templates; a rule wins over the record", "/pages/custom", map[string]expectation{
|
||||
"title": {"Custom SEO title", "record"},
|
||||
"description": {"Custom SEO description", "record"},
|
||||
"canonical": {siteURL + "/canonical-custom", "record"},
|
||||
"robots": {"noindex, follow", "record"},
|
||||
"og_title": {"Rule social title", "rule"},
|
||||
"og_description": {"Custom SEO description", "record"},
|
||||
"og_image": {siteURL + "/media/custom.png", "record"},
|
||||
}},
|
||||
{"description falls back to the settings", "/pages/bare", map[string]expectation{
|
||||
"description": {"Site default description", "global"},
|
||||
}},
|
||||
{"type description template and the D-21 tag default", "/pages/tag/go", map[string]expectation{
|
||||
"title": {"Tag go | Acme", "template"},
|
||||
"description": {"Posts tagged Tag go", "template"},
|
||||
"robots": {"noindex, follow", "template"},
|
||||
}},
|
||||
{"pattern rule on a URL without a record", "/blog?tag=go", map[string]expectation{
|
||||
"title": {"Tag archive", "rule"},
|
||||
"robots": {"noindex, follow", "rule"},
|
||||
}},
|
||||
{"rule-only page with an absolute canonical on another domain", "/landing", map[string]expectation{
|
||||
"title": {"Landing", "rule"},
|
||||
"description": {"Landing description", "rule"},
|
||||
"canonical": {"https://other.example/landing", "rule"},
|
||||
"og_image": {"https://cdn.example/landing.png", "rule"},
|
||||
}},
|
||||
{"tracking dropped, content parameter kept", "/pages/about?utm_source=news&page=2", map[string]expectation{
|
||||
"canonical": {siteURL + "/pages/about?page=2", "default"},
|
||||
"robots": {"index, follow, max-snippet:50", "default"},
|
||||
}},
|
||||
{"a filter parameter: dropped and noindex,follow", "/pages/about?sort=price&page=2", map[string]expectation{
|
||||
"canonical": {siteURL + "/pages/about?page=2", "default"},
|
||||
"robots": {"noindex, follow, max-snippet:50", "global"},
|
||||
}},
|
||||
{"a filter parameter limited to a path", "/pages/bare?color=red", map[string]expectation{
|
||||
"canonical": {siteURL + "/pages/bare", "default"},
|
||||
"robots": {"noindex, follow", "global"},
|
||||
}},
|
||||
{"never-indexed path", "/konto/profile", map[string]expectation{
|
||||
"robots": {"noindex, nofollow", "global"},
|
||||
}},
|
||||
{"internal search path", "/szukaj", map[string]expectation{
|
||||
"robots": {"noindex, follow", "global"},
|
||||
}},
|
||||
{"home page template", "/", map[string]expectation{
|
||||
"title": {"Acme", "template"},
|
||||
"description": {"Site default description", "global"},
|
||||
}},
|
||||
{"translated site name", "/pl/pages/about", map[string]expectation{
|
||||
"title": {"About | Akme", "template"},
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res, err := resolver.Resolve(t.Context(), tc.url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Action != "ok" || res.SEO == nil {
|
||||
t.Fatalf("Resolve(%s) = %+v, want ok", tc.url, res)
|
||||
}
|
||||
got := map[string]string{
|
||||
"title": res.SEO.Title, "description": res.SEO.Description, "canonical": res.SEO.Canonical,
|
||||
"robots": res.SEO.Robots, "og_title": res.SEO.OG.Title, "og_description": res.SEO.OG.Description, "og_image": res.SEO.OG.Image,
|
||||
}
|
||||
for name, want := range tc.want {
|
||||
if got[name] != want.value || res.Sources[name] != want.source {
|
||||
t.Errorf("%s = %q (%s), want %q (%s)", name, got[name], res.Sources[name], want.value, want.source)
|
||||
}
|
||||
}
|
||||
if res.SEO.XRobotsTag != res.SEO.Robots {
|
||||
t.Errorf("x_robots_tag %q differs from robots %q", res.SEO.XRobotsTag, res.SEO.Robots)
|
||||
}
|
||||
if res.Staging {
|
||||
t.Error("staging true in production")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("social and verification values", func(t *testing.T) {
|
||||
got := env.resolve(t, "/")
|
||||
seoBody := got["seo"].(map[string]any)
|
||||
verification, _ := seoBody["verification"].([]any)
|
||||
if len(verification) != 1 || !strings.Contains(fmtJSON(verification), `"google-site-verification"`) || !strings.Contains(fmtJSON(verification), `"g-code"`) {
|
||||
t.Fatalf("home verification = %v", verification)
|
||||
}
|
||||
got = env.resolve(t, "/pages/about")
|
||||
seoBody = got["seo"].(map[string]any)
|
||||
if v, _ := seoBody["verification"].([]any); len(v) != 0 {
|
||||
t.Fatalf("verification on a non-home page = %v", v)
|
||||
}
|
||||
twitter := seoBody["twitter"].(map[string]any)
|
||||
og := seoBody["og"].(map[string]any)
|
||||
if twitter["card"] != "summary_large_image" || twitter["site"] != "@acme" || og["url"] != siteURL+"/pages/about" {
|
||||
t.Fatalf("social = %v %v", twitter, og)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("drafts and unknown paths are not found", func(t *testing.T) {
|
||||
for _, u := range []string{"/pages/draft", "/pages/nope", "/nowhere"} {
|
||||
res, err := resolver.Resolve(t.Context(), u)
|
||||
if err != nil || res.Action != "not_found" || res.Status != 404 || res.SEO != nil {
|
||||
t.Fatalf("Resolve(%s) = %+v err=%v, want not_found without SEO", u, res, err)
|
||||
}
|
||||
}
|
||||
rec := env.call(t, http.MethodGet, "/_seo/api/v1/resolve?url=javascript:alert(1)", "", nil)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("bad url = %d, want 422", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("staging lock", func(t *testing.T) {
|
||||
staging := bootEnv(t, "SUMMER_ENV=staging")
|
||||
stoken := staging.admin(t, true)
|
||||
staging.createPage(t, stoken, map[string]any{"title": "About", "slug": "about", "published": true})
|
||||
got := staging.resolve(t, "/pages/about")
|
||||
seoBody := got["seo"].(map[string]any)
|
||||
if got["staging"] != true || seoBody["robots"] != "noindex, nofollow" || seoBody["x_robots_tag"] != "noindex, nofollow" {
|
||||
t.Fatalf("staging resolve = %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fmtJSON encodes v for substring checks.
|
||||
func fmtJSON(v any) string {
|
||||
raw, _ := json.Marshal(v)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user