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