feat: full precedence resolver with value sources

This commit is contained in:
2026-10-27 10:39:55 +01:00
parent 30edb8adee
commit 9f1082d62d
6 changed files with 907 additions and 22 deletions

View File

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

View File

@@ -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
View 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
View 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
View 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
}

View File

@@ -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)
}