feat: SEO field types and the complete SEO tab

This commit is contained in:
2026-10-27 11:04:03 +01:00
parent deebd9aa65
commit 93ddbcc9ef
26 changed files with 1827 additions and 50 deletions

View File

@@ -3,6 +3,7 @@ package classes
import (
"context"
"fmt"
"sort"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
@@ -17,6 +18,26 @@ var EntryColumns = []string{
"og_title", "og_description", "og_image",
}
// SharedColumn is the column whose value is one per record, shared by
// every locale row (Q-5): the social image.
const SharedColumn = "og_image"
// SharedValue returns a record's shared social image: the first non-empty
// value in locale order.
func SharedValue(entries map[string]models.Entry) string {
locales := make([]string, 0, len(entries))
for locale := range entries {
locales = append(locales, locale)
}
sort.Strings(locales)
for _, locale := range locales {
if v := entries[locale].OGImage; v != "" {
return v
}
}
return ""
}
// FieldPrefix is the prefix of the SEO tab's field names (seo_title, ...),
// so they never collide with an owner's fields.
const FieldPrefix = "seo_"
@@ -130,8 +151,29 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (
before[locale] = e
}
described := s.recordLocales(ctx, in.Type.Key, in.ID)
values := in.Values
if shared, ok := values[SharedColumn]; ok {
// One value for every locale of the record (Q-5).
v := ""
for _, text := range shared {
v = text
}
all := map[string]string{}
for locale := range existing {
all[locale] = v
}
for locale := range described {
all[locale] = v
}
copied := RecordValues{}
for column, perLocale := range values {
copied[column] = perLocale
}
copied[SharedColumn] = all
values = copied
}
touched := map[string]models.Entry{}
for column, perLocale := range in.Values {
for column, perLocale := range values {
for locale, value := range perLocale {
e, ok := touched[locale]
if !ok {
@@ -156,6 +198,7 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (
if l, ok := described[locale]; ok {
e.URL = l.URL
}
e.EffectiveTitle, e.EffectiveDescription = s.effectiveOf(ctx, db, in.Type.Key, e, described[locale], locale)
if e.ID == 0 {
if err := db.Create(&e).Error; err != nil {
return nil, nil, err
@@ -181,6 +224,21 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (
return before, after, nil
}
// effectiveOf computes an entry's effective title and description cache.
func (s *Service) effectiveOf(ctx context.Context, db *gorm.DB, key string, e models.Entry, l pact.ContentLocale, locale string) (string, string) {
settings, err := LoadSettings(db)
if err != nil {
return e.EffectiveTitle, e.EffectiveDescription
}
ts, err := LoadTypeSetting(db, key)
if err != nil {
return e.EffectiveTitle, e.EffectiveDescription
}
site := s.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, locale)
fallback := s.translatedSetting(ctx, db, &settings, "default_description", settings.DefaultDescription, locale)
return EffectiveValues(e, l, settings, ts, site, fallback)
}
// recordWhat is the history description of a record's SEO in a locale:
// "SEO of <type> “<title>” (<locale>)".
func (s *Service) recordWhat(typ pact.MenuItemType, id string, l pact.ContentLocale, locale string) string {

139
classes/field_types.go Normal file
View File

@@ -0,0 +1,139 @@
package classes
import (
"context"
"fmt"
"net/url"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// The elements of the SEO field types (assets/src/elements).
const (
ElementText = "golem15-seo-text"
ElementRobots = "golem15-seo-robots"
ElementSERP = "golem15-seo-serp"
ElementImage = "golem15-seo-image"
)
// The element bundle every SEO field type loads.
var (
elementScripts = []string{"assets/dist/seo.js"}
elementStyles = []string{"assets/dist/seo.css"}
)
// errRobotsInvalid is the refusal of a malformed robots value.
const errRobotsInvalid = "golem15.seo::lang.robots.invalid"
// FieldTypes returns the SEO admin field types:
// - seotext and mlseotext (E1): text with a live length counter; option
// measure: title (input) or description (textarea);
// - seorobots and mlseorobots (E2): the robots directives editor; option
// scope: record, rule or type; Decode refuses a malformed value;
// - seoimage (E7): an uploaded social image stored in the media bucket,
// its URL as the value (D-100); uploads need permission.
//
// app is asked for the booted application at request time.
func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldType {
text := func(name string, translatable bool) pact.FormFieldType {
return pact.FormFieldType{
Name: name, Element: ElementText, Translatable: translatable,
Scripts: elementScripts, Styles: elementStyles,
Options: []string{"measure"}, CheckOptions: checkMeasure, Decode: decodeText,
}
}
robots := func(name string, translatable bool) pact.FormFieldType {
return pact.FormFieldType{
Name: name, Element: ElementRobots, Translatable: translatable,
Scripts: elementScripts, Styles: elementStyles,
Options: []string{"scope"}, CheckOptions: checkScope, Decode: decodeRobots,
}
}
return []pact.FormFieldType{
text("seotext", false),
text("mlseotext", true),
robots("seorobots", false),
robots("mlseorobots", true),
{
Name: "seoimage", Element: ElementImage,
Scripts: elementScripts, Styles: elementStyles,
Decode: decodeImage, Upload: UploadImage(app, permission),
},
}
}
func checkMeasure(options map[string]any) error {
if raw, ok := options["measure"]; ok {
if s, _ := raw.(string); s != "title" && s != "description" {
return fmt.Errorf("measure must be title or description, got %v", raw)
}
}
return nil
}
func checkScope(options map[string]any) error {
if raw, ok := options["scope"]; ok {
if s, _ := raw.(string); s != "record" && s != "rule" && s != "type" {
return fmt.Errorf("scope must be record, rule or type, got %v", raw)
}
}
return nil
}
// stringValue reads a submitted value as text; nil is "".
func stringValue(v any) (string, bool) {
switch s := v.(type) {
case nil:
return "", true
case string:
return s, true
}
return "", false
}
func decodeText(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
s, ok := stringValue(in.Value)
if !ok {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"The " + in.Field + " field must be text."}}
}
return pact.FormFieldDecoded{Value: strings.TrimSpace(s)}, nil
}
// decodeRobots stores a robots value in the fixed order; a malformed value
// (reachable only through MCP or an import) is refused.
func decodeRobots(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
s, ok := stringValue(in.Value)
if !ok {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errRobotsInvalid}}
}
normal, err := NormalizeRobots(s)
if err != nil {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errRobotsInvalid}}
}
return pact.FormFieldDecoded{Value: normal}, nil
}
// decodeImage accepts an empty value, a path or an http(s) URL.
func decodeImage(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
s, ok := stringValue(in.Value)
s = strings.TrimSpace(s)
if !ok || !ValidImageURL(s) {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errImageURL}}
}
return pact.FormFieldDecoded{Value: s}, nil
}
// ValidURLRef reports whether v is a path ("/x", not "//x") or an absolute
// http(s) URL with a host, without spaces or control characters.
func ValidURLRef(v string) bool {
if v == "" || strings.ContainsFunc(v, func(r rune) bool { return r <= ' ' || r == 0x7f }) {
return false
}
if strings.HasPrefix(v, "/") {
return !strings.HasPrefix(v, "//")
}
u, err := url.Parse(v)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}

123
classes/image.go Normal file
View File

@@ -0,0 +1,123 @@
package classes
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"path"
"regexp"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"gocloud.dev/blob"
)
// ImageFolder is the media folder social images are stored in.
const ImageFolder = "media/seo"
// ImageMaxBytes caps a social image upload (10 MiB, as the media library).
const ImageMaxBytes = 10 << 20
// The phrase keys of the image refusals.
const (
errImageInvalid = "golem15.seo::lang.image.invalid"
errImageTooBig = "golem15.seo::lang.image.too_big"
errImageURL = "golem15.seo::lang.image.url_invalid"
errImageForbidden = "golem15.seo::lang.image.forbidden"
)
// imageExtensions maps the sniffed image types to their file extensions.
var imageExtensions = map[string]string{
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
var unsafeNameChars = regexp.MustCompile(`[^a-z0-9._-]+`)
// safeImageName is the stored base name: lowercased, characters outside
// a-z, 0-9, '.', '_' and '-' turned into '-', at most 80 characters, with
// the sniffed extension.
func safeImageName(filename, ext string) string {
base := path.Base(strings.ReplaceAll(filename, `\`, "/"))
base = strings.TrimSuffix(base, path.Ext(base))
base = strings.Trim(unsafeNameChars.ReplaceAllString(strings.ToLower(base), "-"), "-.")
if base == "" {
base = "image"
}
if len(base) > 80 {
base = base[:80]
}
return base + "." + ext
}
// UploadImage is the seoimage field type's Upload (D-100, P-16): the
// administrator needs golem15.seo.manage; the first bytes must sniff as a
// JPEG, PNG, GIF or WebP image; the file is stored in the media bucket
// under media/seo/<random>-<safe name> (a name that is safe to leave behind
// when the save never happens) and its public URL is returned.
func UploadImage(app func() *backpack.App, permission string) func(context.Context, pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
return func(ctx context.Context, in pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
if p, ok := bouncer.User(ctx); !ok || !cabana.Allows(p, []string{permission}) {
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageForbidden}}
}
var a *backpack.App
if app != nil {
a = app()
}
if a == nil {
return pact.FormFieldUploadResult{}, errors.New("golem15.seo: image upload: application is not configured")
}
bucket, ok := a.Lookup[*blob.Bucket]()
if !ok || bucket == nil {
return pact.FormFieldUploadResult{}, errors.New("golem15.seo: image upload: media bucket is not configured")
}
if in.Body == nil {
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageInvalid}}
}
data, err := io.ReadAll(io.LimitReader(in.Body, ImageMaxBytes+1))
if err != nil {
return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: image upload: read: %w", err)
}
if len(data) > ImageMaxBytes {
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageTooBig}}
}
ext, known := imageExtensions[http.DetectContentType(data)]
if !known {
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageInvalid}}
}
random := make([]byte, 6)
if _, err := rand.Read(random); err != nil {
return pact.FormFieldUploadResult{}, err
}
key := ImageFolder + "/" + hex.EncodeToString(random) + "-" + safeImageName(in.Filename, ext)
w, err := bucket.NewWriter(ctx, key, &blob.WriterOptions{IfNotExist: true, ContentType: "image/" + strings.Replace(ext, "jpg", "jpeg", 1)})
if err != nil {
return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: image writer: %w", err)
}
if _, err := io.Copy(w, bytes.NewReader(data)); err != nil {
_ = w.Close()
return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: write image: %w", err)
}
if err := w.Close(); err != nil {
return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: close image: %w", err)
}
return pact.FormFieldUploadResult{URL: attach.PublicURL(key)}, nil
}
}
// ValidImageURL reports whether a stored social image value is empty, a
// path or an absolute http(s) URL.
func ValidImageURL(v string) bool {
return v == "" || ValidURLRef(v)
}

314
classes/partials.go Normal file
View 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))
}

View File

@@ -357,6 +357,55 @@ func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) {
if err != nil {
return Result{}, err
}
key, match, rec, err := r.lookup(ctx, path)
if err != nil {
return Result{}, err
}
return r.resolveAt(ctx, db, settings, path, query, rawQueryOf(rawURL), key, match, rec, false)
}
// ResolveRecord answers the effective SEO of a record in one locale at its
// own address, published or not (the SEO tab's effective-values partial).
// ok is false when the record has no address in that locale.
func (r *Resolver) ResolveRecord(ctx context.Context, key, id, locale string) (Result, bool, error) {
svc := r.Service
reg := svc.Registry()
if reg == nil {
return Result{}, false, nil
}
rec, err := reg.Describe(ctx, key, id, svc.UnprefixedLocale())
if err != nil || rec == nil {
return Result{}, false, err
}
var path string
for _, l := range rec.Locales {
if l.Locale == locale {
path = l.URL
}
}
if strings.TrimSpace(path) == "" {
return Result{}, false, nil
}
target, query, err := parseTarget(path)
if err != nil {
return Result{}, false, nil
}
db, err := svc.DB(ctx)
if err != nil {
return Result{}, false, err
}
settings, err := LoadSettings(db)
if err != nil {
return Result{}, false, err
}
res, err := r.resolveAt(ctx, db, settings, target, query, rawQueryOf(path), key, &pact.ContentMatchResult{ID: id, Locale: locale, Exact: true}, rec, true)
return res, err == nil, err
}
// resolveAt computes the answer for a path whose owner lookup is done:
// key, match and rec name the record behind it (key "" when none).
func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.Settings, path string, query url.Values, rawQuery, key string, match *pact.ContentMatchResult, rec *pact.ContentRecord, force bool) (Result, error) {
svc := r.Service
site := svc.SiteURL()
kept, filtered := splitQuery(settings.Params(), path, query)
self := site + path
@@ -365,16 +414,12 @@ func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) {
}
res := Result{Action: ActionNotFound, Status: 404, URL: self, Staging: svc.Staging(), Sources: map[string]string{}}
rules, err := matchingRules(db, path, rawQueryOf(rawURL))
if err != nil {
return Result{}, err
}
key, match, rec, err := r.lookup(ctx, path)
rules, err := matchingRules(db, path, rawQuery)
if err != nil {
return Result{}, err
}
home := path == "/"
if key == "" && len(rules) == 0 && !home {
if key == "" && len(rules) == 0 && !home && !force {
return res, nil
}

343
classes/snippet.go Normal file
View 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
},
}
}