diff --git a/admin_permissions.go b/admin_permissions.go index b0fbb66..386d8ef 100644 --- a/admin_permissions.go +++ b/admin_permissions.go @@ -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, diff --git a/classes/resolver.go b/classes/resolver.go index 78d7bbb..06159da 100644 --- a/classes/resolver.go +++ b/classes/resolver.go @@ -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 +} diff --git a/classes/robots_value.go b/classes/robots_value.go new file mode 100644 index 0000000..bd14f6f --- /dev/null +++ b/classes/robots_value.go @@ -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 +} diff --git a/classes/templates.go b/classes/templates.go new file mode 100644 index 0000000..ddf08e3 --- /dev/null +++ b/classes/templates.go @@ -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) +} diff --git a/classes/urls.go b/classes/urls.go new file mode 100644 index 0000000..3c254fb --- /dev/null +++ b/classes/urls.go @@ -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 +} diff --git a/settings_test.go b/settings_test.go index aaf6763..01ebc57 100644 --- a/settings_test.go +++ b/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) +}