feat: SEO field types and the complete SEO tab
This commit is contained in:
@@ -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
139
classes/field_types.go
Normal 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
123
classes/image.go
Normal 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
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))
|
||||
}
|
||||
@@ -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
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