feat: SEO field types and the complete SEO tab
This commit is contained in:
343
classes/snippet.go
Normal file
343
classes/snippet.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"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"
|
||||
)
|
||||
|
||||
// SnippetActionName is the result-preview widget action (E3, UI-SPEC C6).
|
||||
const SnippetActionName = "seo_snippet"
|
||||
|
||||
// snippetDuplicateLimit caps each duplicate list the action answers.
|
||||
const snippetDuplicateLimit = 20
|
||||
|
||||
// SnippetLocale is one locale of the preview context.
|
||||
type SnippetLocale struct {
|
||||
Locale string `json:"locale"`
|
||||
URL *string `json:"url"`
|
||||
H1 *string `json:"h1"`
|
||||
SiteName string `json:"siteName"`
|
||||
TitleTemplate string `json:"titleTemplate"`
|
||||
DefaultTitle string `json:"defaultTitle"`
|
||||
DefaultDescription *string `json:"defaultDescription"`
|
||||
DescriptionSource string `json:"descriptionSource"`
|
||||
BodyWords *int `json:"bodyWords"`
|
||||
}
|
||||
|
||||
// SnippetContext is the answer of op context.
|
||||
type SnippetContext struct {
|
||||
Op string `json:"op"`
|
||||
Host string `json:"host"`
|
||||
Locales []SnippetLocale `json:"locales"`
|
||||
}
|
||||
|
||||
// DuplicateItem is another page with the same title or description: a
|
||||
// label and the admin URL of its form.
|
||||
type DuplicateItem struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// SnippetDuplicates is the answer of op duplicates.
|
||||
type SnippetDuplicates struct {
|
||||
Op string `json:"op"`
|
||||
Locale string `json:"locale"`
|
||||
Title []DuplicateItem `json:"title"`
|
||||
Description []DuplicateItem `json:"description"`
|
||||
}
|
||||
|
||||
type snippetPayload struct {
|
||||
Op string `json:"op"`
|
||||
Locale string `json:"locale"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// errSnippetOp refuses an unknown op.
|
||||
var errSnippetOp = errors.New("golem15.seo: unknown seo_snippet op")
|
||||
|
||||
// Locales returns the site's content locales, the default first.
|
||||
func (s *Service) Locales(ctx context.Context, db *gorm.DB) []string {
|
||||
def := s.DefaultLocale(ctx, db)
|
||||
out := []string{def}
|
||||
if s != nil && s.app != nil {
|
||||
if w, ok := s.app.Lookup[cabana.TranslationWriter](); ok && w != nil {
|
||||
if codes, err := w.EnabledLocales(ctx, db); err == nil {
|
||||
for _, code := range codes {
|
||||
if code != def && code != "" {
|
||||
out = append(out, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// host is the site URL's host, "" when none is configured.
|
||||
func (s *Service) host() string {
|
||||
u, err := url.Parse(s.SiteURL())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
// adminFormURL is the admin SPA path of a record's update form.
|
||||
func (s *Service) adminFormURL(controller, id string) string {
|
||||
prefix := cabana.DefaultAdminPrefix
|
||||
if s != nil && s.app != nil {
|
||||
if p, err := cabana.AdminPrefix(s.app); err == nil && p != "" {
|
||||
prefix = p
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(prefix, "/") + "/" + strings.ReplaceAll(controller, ".", "/") + "/update/" + id
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// RecordSnippetAction is the seo_snippet action of the SEO tab of content
|
||||
// type key: op context answers the preview data per locale (from the saved
|
||||
// record through its descriptor, the per-type and general templates and
|
||||
// the settings), op duplicates the other records and URL rules whose
|
||||
// effective title or description in that locale equals the submitted one.
|
||||
func RecordSnippetAction(svc func() *Service, key, label, permission string) pact.AdminAction {
|
||||
return pact.AdminAction{
|
||||
Name: SnippetActionName,
|
||||
Label: label,
|
||||
Permissions: []string{permission},
|
||||
Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||
s := svc()
|
||||
if s == nil {
|
||||
return pact.AdminActionResult{}, ErrNotBooted
|
||||
}
|
||||
var p snippetPayload
|
||||
if len(in.Payload) > 0 {
|
||||
if err := json.Unmarshal(in.Payload, &p); err != nil {
|
||||
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.snippet.invalid"}}}
|
||||
}
|
||||
}
|
||||
id := ""
|
||||
if in.RecordID != nil {
|
||||
id = strconv.FormatUint(*in.RecordID, 10)
|
||||
}
|
||||
switch p.Op {
|
||||
case "context", "":
|
||||
data, err := s.recordContext(ctx, key, id)
|
||||
return pact.AdminActionResult{Data: data}, err
|
||||
case "duplicates":
|
||||
data, err := s.duplicates(ctx, key, id, p)
|
||||
return pact.AdminActionResult{Data: data}, err
|
||||
}
|
||||
return pact.AdminActionResult{}, errSnippetOp
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// recordContext builds the preview context of a record (id "" on create).
|
||||
func (s *Service) recordContext(ctx context.Context, key, id string) (SnippetContext, error) {
|
||||
out := SnippetContext{Op: "context", Host: s.host(), Locales: []SnippetLocale{}}
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
settings, err := LoadSettings(db)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
ts, err := LoadTypeSetting(db, key)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
described := map[string]pact.ContentLocale{}
|
||||
if id != "" {
|
||||
described = s.recordLocales(ctx, key, id)
|
||||
}
|
||||
tpl := ts.TitleTemplate
|
||||
if strings.TrimSpace(tpl) == "" {
|
||||
tpl = settings.TitleTemplate
|
||||
}
|
||||
for _, locale := range s.Locales(ctx, db) {
|
||||
l := described[locale]
|
||||
site := s.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, locale)
|
||||
fallback := s.translatedSetting(ctx, db, &settings, "default_description", settings.DefaultDescription, locale)
|
||||
desc, source := pick(
|
||||
candidate{RenderTemplate(ts.DescriptionTemplate, TemplateVars{Title: l.Title, Description: l.Description, Site: site}), SourceTemplate},
|
||||
candidate{l.Description, SourceContent},
|
||||
candidate{fallback, SourceGlobal},
|
||||
)
|
||||
if desc == "" {
|
||||
source = ""
|
||||
}
|
||||
out.Locales = append(out.Locales, SnippetLocale{
|
||||
Locale: locale, URL: strPtr(l.URL), H1: strPtr(l.Title), SiteName: site,
|
||||
TitleTemplate: tpl, DefaultTitle: l.Title, DefaultDescription: strPtr(desc),
|
||||
DescriptionSource: source, BodyWords: l.Words,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// duplicates answers the records and URL rules whose effective title or
|
||||
// description in the payload's locale equals the submitted value (or the
|
||||
// value the templates give when it is empty). The record itself is left
|
||||
// out; each list holds at most snippetDuplicateLimit items.
|
||||
func (s *Service) duplicates(ctx context.Context, key, id string, p snippetPayload) (SnippetDuplicates, error) {
|
||||
out := SnippetDuplicates{Op: "duplicates", Locale: p.Locale, Title: []DuplicateItem{}, Description: []DuplicateItem{}}
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
title, description := strings.TrimSpace(p.Title), strings.TrimSpace(p.Description)
|
||||
model := ""
|
||||
if key != "" {
|
||||
if reg := s.Registry(); reg != nil {
|
||||
if typ, ok := reg.ContentType(key); ok && typ.Content != nil {
|
||||
model = typ.Content.Model
|
||||
if id != "" && (title == "" || description == "") {
|
||||
settings, _ := LoadSettings(db)
|
||||
ts, _ := LoadTypeSetting(db, key)
|
||||
l := s.recordLocales(ctx, key, id)[p.Locale]
|
||||
site := s.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, p.Locale)
|
||||
fallback := s.translatedSetting(ctx, db, &settings, "default_description", settings.DefaultDescription, p.Locale)
|
||||
et, ed := EffectiveValues(models.Entry{Title: title, Description: description}, l, settings, ts, site, fallback)
|
||||
if title == "" {
|
||||
title = et
|
||||
}
|
||||
if description == "" {
|
||||
description = ed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
find := func(column, value string, ruleColumn string) ([]DuplicateItem, error) {
|
||||
items := []DuplicateItem{}
|
||||
if value == "" {
|
||||
return items, nil
|
||||
}
|
||||
var entries []models.Entry
|
||||
q := db.Where("locale = ? AND "+column+" = ?", p.Locale, value)
|
||||
if model != "" && id != "" {
|
||||
q = q.Where("NOT (model_type = ? AND record_id = ?)", model, id)
|
||||
}
|
||||
if err := q.Order("id ASC").Limit(snippetDuplicateLimit).Find(&entries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reg := s.Registry()
|
||||
for _, e := range entries {
|
||||
label := e.URL
|
||||
if label == "" {
|
||||
label = e.TypeKey + " #" + e.RecordID
|
||||
}
|
||||
link := ""
|
||||
if reg != nil {
|
||||
if typ, ok := reg.ContentType(e.TypeKey); ok && typ.Content != nil && typ.Content.Controller != "" {
|
||||
link = s.adminFormURL(typ.Content.Controller, e.RecordID)
|
||||
}
|
||||
}
|
||||
items = append(items, DuplicateItem{Label: label, URL: link})
|
||||
}
|
||||
var rules []models.Rule
|
||||
rq := db.Where(ruleColumn+" = ?", value)
|
||||
if key == "" && id != "" {
|
||||
rq = rq.Where("id <> ?", id)
|
||||
}
|
||||
if err := rq.Order("id ASC").Limit(snippetDuplicateLimit).Find(&rules).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rules {
|
||||
items = append(items, DuplicateItem{Label: r.URL, URL: s.adminFormURL("golem15.seo.rules", strconv.FormatUint(uint64(r.ID), 10))})
|
||||
}
|
||||
if len(items) > snippetDuplicateLimit {
|
||||
items = items[:snippetDuplicateLimit]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
if out.Title, err = find("effective_title", title, "title"); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.Description, err = find("effective_description", description, "description"); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RuleSnippetAction is the seo_snippet action of the URL rule form: op
|
||||
// context answers one pseudo-locale with the rule's address (none for a
|
||||
// pattern) and no H1; op duplicates compares with records and other rules.
|
||||
func RuleSnippetAction(svc func() *Service, label, permission string) pact.AdminAction {
|
||||
return pact.AdminAction{
|
||||
Name: SnippetActionName,
|
||||
Label: label,
|
||||
Permissions: []string{permission},
|
||||
Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||
s := svc()
|
||||
if s == nil {
|
||||
return pact.AdminActionResult{}, ErrNotBooted
|
||||
}
|
||||
var p snippetPayload
|
||||
if len(in.Payload) > 0 {
|
||||
if err := json.Unmarshal(in.Payload, &p); err != nil {
|
||||
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.snippet.invalid"}}}
|
||||
}
|
||||
}
|
||||
id := ""
|
||||
if in.RecordID != nil {
|
||||
id = strconv.FormatUint(*in.RecordID, 10)
|
||||
}
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return pact.AdminActionResult{}, err
|
||||
}
|
||||
locale := s.DefaultLocale(ctx, db)
|
||||
switch p.Op {
|
||||
case "context", "":
|
||||
settings, err := LoadSettings(db)
|
||||
if err != nil {
|
||||
return pact.AdminActionResult{}, err
|
||||
}
|
||||
// The rule form's preview has no fill (an owner form may fill
|
||||
// built-in scalar fields only), so it shows the saved rule.
|
||||
var u *string
|
||||
title, description := "", ""
|
||||
if r, ok := in.Record.(*models.Rule); ok && r != nil {
|
||||
if r.Match == models.MatchExact {
|
||||
u = strPtr(r.URL)
|
||||
}
|
||||
title, description = r.Title, r.Description
|
||||
}
|
||||
site := s.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, locale)
|
||||
source := ""
|
||||
if description != "" {
|
||||
source = SourceRule
|
||||
}
|
||||
return pact.AdminActionResult{Data: SnippetContext{Op: "context", Host: s.host(), Locales: []SnippetLocale{{
|
||||
Locale: locale, URL: u, SiteName: site, DefaultTitle: title,
|
||||
DefaultDescription: strPtr(description), DescriptionSource: source,
|
||||
}}}}, nil
|
||||
case "duplicates":
|
||||
if p.Locale == "" {
|
||||
p.Locale = locale
|
||||
}
|
||||
data, err := s.duplicates(ctx, "", id, p)
|
||||
return pact.AdminActionResult{Data: data}, err
|
||||
}
|
||||
return pact.AdminActionResult{}, errSnippetOp
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user