Files
sm-seo-plugin/classes/partials.go
Jakub Zych 59f0457c69 feat: sitemap-aware batch conflict and the sitemap_blocked issue
- noindex_published fires only when a sitemap file lists the address now
  (read once per batch through contract.Listing)
- CMS issue sitemap_blocked (severity error, tool seo_update_robots,
  details line and rule): a listed address the production robots.txt
  keeps Googlebot from
- the SEO tab's P1 shows status.excluded_title with the reason (noindex,
  canonical elsewhere, redirect, or the type switched off in the sitemap
  settings via ListingSettings.TypesOff) as a plain callout
- robots.txt is composed once per check of many addresses
2026-11-04 13:39:31 +01:00

349 lines
11 KiB
Go

package classes
import (
"context"
"fmt"
"net/url"
"reflect"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
"gorm.io/gorm"
)
// The view models of the SEO partials. They hold text only (translated in
// the request locale), never a GORM model; html/template escapes every
// value.
// Callout is one P1 callout: a warning, or a plain note when Plain is
// set (the sitemap status).
type Callout struct {
Title string
Text string
Plain bool
}
// StatusView is the P1 seo_status view model.
type StatusView struct {
Callouts []Callout
}
// EffectiveRow is one P2 row: a property, its effective value and source.
type EffectiveRow struct {
Property string
Value string
Source string
Mono bool
}
// EffectiveLocale is the P2 table of one locale.
type EffectiveLocale struct {
Heading string
NoURL bool
NoURLText string
Rows []EffectiveRow
}
// EffectiveView is the P2 seo_effective view model.
type EffectiveView struct {
Staging bool
StagingText string
PropertyLabel string
ValueLabel string
SourceLabel string
Locales []EffectiveLocale
}
// PlaceholderRow is one P3 row.
type PlaceholderRow struct {
Code string
Meaning string
}
// PlaceholdersView is the P3 placeholders view model.
type PlaceholdersView struct {
Caption string
CodeLabel string
MeaningLabel string
Rows []PlaceholderRow
}
// PhraseCtx translates key with params in the request locale; without a
// translator it returns the key.
func (s *Service) PhraseCtx(ctx context.Context, key string, params map[string]string) string {
tr := s.translator()
if tr == nil {
return key
}
return tr.Get(ctx, key, params)
}
// effectiveProperties are the P2 rows in order: value name, label key,
// monospace.
var effectiveProperties = []struct {
name string
label string
mono bool
}{
{"title", "golem15.seo::lang.field.title", false},
{"description", "golem15.seo::lang.field.description", false},
{"canonical", "golem15.seo::lang.field.canonical", true},
{"robots", "golem15.seo::lang.field.robots", false},
{"og_title", "golem15.seo::lang.field.og_title", false},
{"og_description", "golem15.seo::lang.field.og_description", false},
{"og_image", "golem15.seo::lang.field.og_image", true},
}
// EffectiveRows turns a resolve answer into P2 rows.
func (s *Service) EffectiveRows(ctx context.Context, res Result) []EffectiveRow {
if res.SEO == nil {
return nil
}
values := 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,
}
out := make([]EffectiveRow, 0, len(effectiveProperties))
for _, p := range effectiveProperties {
out = append(out, EffectiveRow{
Property: s.PhraseCtx(ctx, p.label, nil),
Value: values[p.name],
Source: s.PhraseCtx(ctx, "golem15.seo::lang.effective.source_"+res.Sources[p.name], nil),
Mono: p.mono,
})
}
return out
}
// newEffectiveView starts a P2 view with its labels and the staging note.
func (s *Service) newEffectiveView(ctx context.Context) EffectiveView {
return EffectiveView{
Staging: s.Staging(),
StagingText: s.PhraseCtx(ctx, "golem15.seo::lang.status.staging", nil),
PropertyLabel: s.PhraseCtx(ctx, "golem15.seo::lang.effective.property", nil),
ValueLabel: s.PhraseCtx(ctx, "golem15.seo::lang.effective.value", nil),
SourceLabel: s.PhraseCtx(ctx, "golem15.seo::lang.effective.source", nil),
}
}
// RecordEffective builds P2 for a record: one table per locale at the
// record's own address, or the no-address line.
func (s *Service) RecordEffective(ctx context.Context, key, id string) (EffectiveView, error) {
view := s.newEffectiveView(ctx)
db, err := s.DB(ctx)
if err != nil {
return view, err
}
described := s.recordLocales(ctx, key, id)
for _, locale := range s.Locales(ctx, db) {
l := described[locale]
heading := s.PhraseCtx(ctx, "golem15.seo::lang.effective.locale", map[string]string{"locale": strings.ToUpper(locale), "url": l.URL})
res, ok, err := s.Resolver().ResolveRecord(ctx, key, id, locale)
if err != nil {
return view, err
}
if !ok {
view.Locales = append(view.Locales, EffectiveLocale{Heading: strings.ToUpper(locale), NoURL: true, NoURLText: s.PhraseCtx(ctx, "golem15.seo::lang.effective.no_url", nil)})
continue
}
view.Locales = append(view.Locales, EffectiveLocale{Heading: heading, Rows: s.EffectiveRows(ctx, res)})
}
return view, nil
}
// ruleProperties returns the translated labels of the properties a rule
// sets.
func (s *Service) ruleProperties(ctx context.Context, r models.Rule) []string {
var out []string
for _, p := range models.RuleProperties {
if strings.TrimSpace(r.Value(p.Column)) != "" {
out = append(out, s.PhraseCtx(ctx, p.Label, nil))
}
}
return out
}
// RecordStatus builds P1 for a record: the URL rule that overrides part of
// its SEO tab (the first one found over its locales), and a canonical that
// points to a page served noindex (D-06).
func (s *Service) RecordStatus(ctx context.Context, key, id string) (StatusView, error) {
var view StatusView
db, err := s.DB(ctx)
if err != nil {
return view, err
}
for _, l := range s.recordLocales(ctx, key, id) {
if l.URL == "" {
continue
}
path, _, err := parseTarget(l.URL)
if err != nil {
continue
}
rules, err := matchingRules(db, path, rawQueryOf(l.URL))
if err != nil {
return view, err
}
if c, ok := s.ruleCallout(ctx, rules); ok {
view.Callouts = append(view.Callouts, c)
break
}
}
reg := s.Registry()
if reg != nil {
if typ, ok := reg.ContentType(key); ok && typ.Content != nil {
entries, err := LoadEntries(db, typ.Content.Model, id)
if err != nil {
return view, err
}
for _, e := range entries {
if c, ok := s.canonicalCallout(ctx, e.Canonical); ok {
view.Callouts = append(view.Callouts, c)
break
}
}
}
}
exclusions, err := s.RecordSitemapExclusions(ctx, key, id)
if err != nil {
return view, err
}
for _, x := range exclusions {
key, ok := excludedTexts[x.Reason]
if !ok {
continue
}
view.Callouts = append(view.Callouts, Callout{
Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.excluded_title", nil),
Text: s.PhraseCtx(ctx, key, map[string]string{"url": x.Target}),
Plain: true,
})
}
return view, nil
}
// excludedTexts are the P1 texts of the sitemap exclusion reasons (UI-SPEC
// C16).
var excludedTexts = map[string]string{
contract.ReasonNoindex: "golem15.seo::lang.status.excluded_noindex",
contract.ReasonCanonical: "golem15.seo::lang.status.excluded_canonical",
contract.ReasonRedirect: "golem15.seo::lang.status.excluded_redirect",
contract.ReasonType: "golem15.seo::lang.status.excluded_type",
}
// ruleCallout is the override callout of the first rule that sets a value.
func (s *Service) ruleCallout(ctx context.Context, rules []models.Rule) (Callout, bool) {
for _, r := range rules {
props := s.ruleProperties(ctx, r)
if len(props) == 0 {
continue
}
return Callout{
Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.rule_title", nil),
Text: s.PhraseCtx(ctx, "golem15.seo::lang.status.rule_text", map[string]string{"url": r.URL, "fields": strings.Join(props, ", ")}),
}, true
}
return Callout{}, false
}
// canonicalCallout warns about a canonical on this site (D-06): one that
// redirects (status.canonical_redirected, naming the final address), or
// one that resolves to a page served noindex (status.canonical_noindex).
// Outside production every page is noindex, so only the redirect warning
// applies there.
func (s *Service) canonicalCallout(ctx context.Context, canonical string) (Callout, bool) {
canonical = strings.TrimSpace(canonical)
if canonical == "" {
return Callout{}, false
}
target := canonical
if u, err := url.Parse(canonical); err == nil && u.IsAbs() {
if !sameSite(s.SiteURL(), u.Host) {
return Callout{}, false
}
target = u.String()
}
res, err := s.Resolver().Resolve(ctx, target)
if err != nil {
return Callout{}, false
}
if res.Action == ActionRedirect {
return Callout{Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.canonical_redirected", map[string]string{"url": canonical, "target": res.Location})}, true
}
if s.Staging() || res.SEO == nil || !strings.Contains(res.SEO.Robots, "noindex") {
return Callout{}, false
}
return Callout{Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.canonical_noindex", map[string]string{"url": canonical})}, true
}
// RuleStatus builds P1 for a URL rule: the canonical warning.
func (s *Service) RuleStatus(ctx context.Context, r models.Rule) StatusView {
var view StatusView
if c, ok := s.canonicalCallout(ctx, r.Canonical); ok {
view.Callouts = append(view.Callouts, c)
}
return view
}
// RuleEffective builds P2 for a URL rule: an exact rule is resolved at its
// address; a pattern rule shows its own values with the source URL rule.
func (s *Service) RuleEffective(ctx context.Context, r models.Rule) (EffectiveView, error) {
view := s.newEffectiveView(ctx)
if r.Match == models.MatchExact {
res, err := s.Resolver().Resolve(ctx, r.URL)
if err != nil {
return view, err
}
view.Locales = append(view.Locales, EffectiveLocale{Heading: r.URL, Rows: s.EffectiveRows(ctx, res)})
return view, nil
}
source := s.PhraseCtx(ctx, "golem15.seo::lang.effective.source_rule", nil)
var rows []EffectiveRow
for _, p := range effectiveProperties {
if v := strings.TrimSpace(r.Value(p.name)); v != "" {
rows = append(rows, EffectiveRow{Property: s.PhraseCtx(ctx, p.label, nil), Value: v, Source: source, Mono: p.mono})
}
}
view.Locales = append(view.Locales, EffectiveLocale{Heading: r.URL, Rows: rows})
return view, nil
}
// Placeholders builds P3.
func (s *Service) Placeholders(ctx context.Context) PlaceholdersView {
return PlaceholdersView{
Caption: s.PhraseCtx(ctx, "golem15.seo::lang.settings.placeholders", nil),
CodeLabel: s.PhraseCtx(ctx, "golem15.seo::lang.settings.placeholders_code", nil),
MeaningLabel: s.PhraseCtx(ctx, "golem15.seo::lang.settings.placeholders_meaning", nil),
Rows: []PlaceholderRow{
{Code: PlaceholderTitle, Meaning: s.PhraseCtx(ctx, "golem15.seo::lang.placeholder.title", nil)},
{Code: PlaceholderDescription, Meaning: s.PhraseCtx(ctx, "golem15.seo::lang.placeholder.description", nil)},
{Code: PlaceholderSite, Meaning: s.PhraseCtx(ctx, "golem15.seo::lang.placeholder.site", nil)},
},
}
}
// RecordKey returns the primary key of a GORM model as text.
func RecordKey(db *gorm.DB, record any) (string, error) {
if record == nil || db == nil {
return "", fmt.Errorf("golem15.seo: no record")
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(record); err != nil {
return "", err
}
if stmt.Schema == nil || stmt.Schema.PrioritizedPrimaryField == nil {
return "", fmt.Errorf("golem15.seo: %T has no primary key", record)
}
v, zero := stmt.Schema.PrioritizedPrimaryField.ValueOf(context.Background(), reflectValue(record))
if zero {
return "", fmt.Errorf("golem15.seo: %T has an empty primary key", record)
}
return fmt.Sprint(v), nil
}
// reflectValue is the addressable value of a model pointer.
func reflectValue(record any) reflect.Value {
return reflect.Indirect(reflect.ValueOf(record))
}