- Normalize: https, www or bare host, double slashes and index files, trailing slash add/remove, optional ASCII lowercasing (defaults change nothing but scheme, slashes and index files) - strict URLs through signpost MatchPath: a path that names a record without being its address 301s to the record's address - resolve runs normalisation, redirects, strict URLs, then SEO; the location is the final address, so a visitor makes one hop - P1 warns when a record's canonical URL redirects
322 lines
10 KiB
Go
322 lines
10 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"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 warning.
|
|
type Callout struct {
|
|
Title string
|
|
Text string
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return view, nil
|
|
}
|
|
|
|
// 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))
|
|
}
|