feat: SEO field types and the complete SEO tab
This commit is contained in:
314
classes/partials.go
Normal file
314
classes/partials.go
Normal file
@@ -0,0 +1,314 @@
|
||||
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 when a canonical on this site resolves to a page
|
||||
// served noindex. Outside production every page is noindex, so it says
|
||||
// nothing there.
|
||||
func (s *Service) canonicalCallout(ctx context.Context, canonical string) (Callout, bool) {
|
||||
canonical = strings.TrimSpace(canonical)
|
||||
if canonical == "" || s.Staging() {
|
||||
return Callout{}, false
|
||||
}
|
||||
target := canonical
|
||||
if u, err := url.Parse(canonical); err == nil && u.IsAbs() {
|
||||
site, err := url.Parse(s.SiteURL())
|
||||
if err != nil || site.Host == "" || !strings.EqualFold(site.Host, u.Host) {
|
||||
return Callout{}, false
|
||||
}
|
||||
target = u.RequestURI()
|
||||
}
|
||||
res, err := s.Resolver().Resolve(ctx, target)
|
||||
if err != nil || 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))
|
||||
}
|
||||
Reference in New Issue
Block a user