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

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