feat: SEO field types and the complete SEO tab
This commit is contained in:
6
admin.go
6
admin.go
@@ -11,9 +11,11 @@ import (
|
||||
|
||||
// adminFS holds the admin YAML and partial templates by pattern (the
|
||||
// controllers directory also holds Go sources), so a new screen or partial
|
||||
// needs no edit here.
|
||||
// needs no edit here, the form extension's partials (extension/) and the
|
||||
// element bundle the SEO field types load.
|
||||
//
|
||||
//go:embed controllers/*/*.yaml controllers/*/_*.htm models/*/*.yaml
|
||||
//go:embed controllers/*/*.yaml controllers/*/_*.htm models/*/*.yaml extension/_*.htm
|
||||
//go:embed assets/dist/seo.js assets/dist/seo.css
|
||||
var adminFS embed.FS
|
||||
|
||||
// AdminFS serves the admin YAML and partials.
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,47 @@ func (c rulesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB,
|
||||
return nil
|
||||
}
|
||||
|
||||
// service returns the SEO service of the booted app, nil before boot.
|
||||
func (c rulesAdminController) service() *classes.Service {
|
||||
svc, err := classes.NewService(appOf(c.app))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// AdminJS loads the SEO element bundle on the rules screens (the result
|
||||
// preview widget).
|
||||
func (rulesAdminController) AdminJS() []string { return []string{"assets/dist/seo.js"} }
|
||||
|
||||
// AdminCSS loads the SEO element styles on the rules screens.
|
||||
func (rulesAdminController) AdminCSS() []string { return []string{"assets/dist/seo.css"} }
|
||||
|
||||
// AdminActions registers the result preview's widget action (E3).
|
||||
func (c rulesAdminController) AdminActions() []pact.AdminAction {
|
||||
return []pact.AdminAction{classes.RuleSnippetAction(c.service, "golem15.seo::lang.snippet.action", PermissionManage)}
|
||||
}
|
||||
|
||||
// PartialData serves P1 seo_status (canonical warnings) and P2
|
||||
// seo_effective of a rule.
|
||||
func (c rulesAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
|
||||
svc := c.service()
|
||||
r, ok := record.(*models.Rule)
|
||||
if svc == nil || !ok || r == nil {
|
||||
if name == "seo_effective" {
|
||||
return classes.EffectiveView{}, nil
|
||||
}
|
||||
return classes.StatusView{}, nil
|
||||
}
|
||||
switch name {
|
||||
case "seo_status":
|
||||
return svc.RuleStatus(ctx, *r), nil
|
||||
case "seo_effective":
|
||||
return svc.RuleEffective(ctx, *r)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// FilterOptions serves the match filter's two choices.
|
||||
func (rulesAdminController) FilterOptions(scope string) []pact.Option {
|
||||
if scope != models.FilterMatch {
|
||||
@@ -113,4 +154,7 @@ var (
|
||||
_ pact.ListExtendRecords = rulesAdminController{}
|
||||
_ pact.FilterOptions = rulesAdminController{}
|
||||
_ pact.FormAfterDelete = rulesAdminController{}
|
||||
_ pact.HasAdminActions = rulesAdminController{}
|
||||
_ pact.AdminPartialData = rulesAdminController{}
|
||||
_ pact.AdminClientAssets = rulesAdminController{}
|
||||
)
|
||||
|
||||
24
controllers/rules/_seo_effective.htm
Normal file
24
controllers/rules/_seo_effective.htm
Normal file
@@ -0,0 +1,24 @@
|
||||
{{- if .Data.Staging -}}
|
||||
<div class="summer-callout summer-callout--warning" role="status">
|
||||
<p class="summer-callout__text">{{ .Data.StagingText }}</p>
|
||||
</div>
|
||||
{{- end -}}
|
||||
{{- range .Data.Locales -}}
|
||||
<section>
|
||||
<h4>{{ .Heading }}</h4>
|
||||
{{- if .NoURL -}}
|
||||
<p>{{ .NoURLText }}</p>
|
||||
{{- else -}}
|
||||
<div class="summer-table-wrap">
|
||||
<table class="summer-table">
|
||||
<thead><tr><th>{{ $.Data.PropertyLabel }}</th><th>{{ $.Data.ValueLabel }}</th><th>{{ $.Data.SourceLabel }}</th></tr></thead>
|
||||
<tbody>
|
||||
{{- range .Rows -}}
|
||||
<tr><td>{{ .Property }}</td><td>{{ if .Mono }}<code>{{ .Value }}</code>{{ else }}{{ .Value }}{{ end }}</td><td>{{ .Source }}</td></tr>
|
||||
{{- end -}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{- end -}}
|
||||
</section>
|
||||
{{- end -}}
|
||||
6
controllers/rules/_seo_status.htm
Normal file
6
controllers/rules/_seo_status.htm
Normal file
@@ -0,0 +1,6 @@
|
||||
{{- range .Data.Callouts -}}
|
||||
<div class="summer-callout summer-callout--warning" role="status">
|
||||
<p class="summer-callout__title">{{ .Title }}</p>
|
||||
{{- if .Text }}<p class="summer-callout__text">{{ .Text }}</p>{{ end }}
|
||||
</div>
|
||||
{{- end -}}
|
||||
@@ -101,13 +101,21 @@ type stagingView struct {
|
||||
Staging bool
|
||||
}
|
||||
|
||||
// PartialData serves the staging callout: shown outside production, where
|
||||
// every page is noindex whatever the settings say (D-22).
|
||||
func (c settingsAdminController) PartialData(_ context.Context, name string, _ any) (any, error) {
|
||||
if name != "staging" {
|
||||
return nil, nil
|
||||
// PartialData serves the staging callout, shown outside production where
|
||||
// every page is noindex whatever the settings say (D-22), and the P3
|
||||
// placeholder table.
|
||||
func (c settingsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) {
|
||||
switch name {
|
||||
case "staging":
|
||||
return stagingView{Staging: IsStaging(appOf(c.app))}, nil
|
||||
case "placeholders":
|
||||
svc, err := classes.NewService(appOf(c.app))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.Placeholders(ctx), nil
|
||||
}
|
||||
return stagingView{Staging: IsStaging(appOf(c.app))}, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// IsStaging reports whether the application runs outside production. It
|
||||
|
||||
11
controllers/settings/_placeholders.htm
Normal file
11
controllers/settings/_placeholders.htm
Normal file
@@ -0,0 +1,11 @@
|
||||
<div class="summer-table-wrap">
|
||||
<table class="summer-table">
|
||||
<caption>{{ .Data.Caption }}</caption>
|
||||
<thead><tr><th>{{ .Data.CodeLabel }}</th><th>{{ .Data.MeaningLabel }}</th></tr></thead>
|
||||
<tbody>
|
||||
{{- range .Data.Rows -}}
|
||||
<tr><td><code>{{ .Code }}</code></td><td>{{ .Meaning }}</td></tr>
|
||||
{{- end -}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
24
extension/_seo_effective.htm
Normal file
24
extension/_seo_effective.htm
Normal file
@@ -0,0 +1,24 @@
|
||||
{{- if .Data.Staging -}}
|
||||
<div class="summer-callout summer-callout--warning" role="status">
|
||||
<p class="summer-callout__text">{{ .Data.StagingText }}</p>
|
||||
</div>
|
||||
{{- end -}}
|
||||
{{- range .Data.Locales -}}
|
||||
<section>
|
||||
<h4>{{ .Heading }}</h4>
|
||||
{{- if .NoURL -}}
|
||||
<p>{{ .NoURLText }}</p>
|
||||
{{- else -}}
|
||||
<div class="summer-table-wrap">
|
||||
<table class="summer-table">
|
||||
<thead><tr><th>{{ $.Data.PropertyLabel }}</th><th>{{ $.Data.ValueLabel }}</th><th>{{ $.Data.SourceLabel }}</th></tr></thead>
|
||||
<tbody>
|
||||
{{- range .Rows -}}
|
||||
<tr><td>{{ .Property }}</td><td>{{ if .Mono }}<code>{{ .Value }}</code>{{ else }}{{ .Value }}{{ end }}</td><td>{{ .Source }}</td></tr>
|
||||
{{- end -}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{- end -}}
|
||||
</section>
|
||||
{{- end -}}
|
||||
6
extension/_seo_status.htm
Normal file
6
extension/_seo_status.htm
Normal file
@@ -0,0 +1,6 @@
|
||||
{{- range .Data.Callouts -}}
|
||||
<div class="summer-callout summer-callout--warning" role="status">
|
||||
<p class="summer-callout__title">{{ .Title }}</p>
|
||||
{{- if .Text }}<p class="summer-callout__text">{{ .Text }}</p>{{ end }}
|
||||
</div>
|
||||
{{- end -}}
|
||||
11
extension/_type_placeholders.htm
Normal file
11
extension/_type_placeholders.htm
Normal file
@@ -0,0 +1,11 @@
|
||||
<div class="summer-table-wrap">
|
||||
<table class="summer-table">
|
||||
<caption>{{ .Data.Caption }}</caption>
|
||||
<thead><tr><th>{{ .Data.CodeLabel }}</th><th>{{ .Data.MeaningLabel }}</th></tr></thead>
|
||||
<tbody>
|
||||
{{- range .Data.Rows -}}
|
||||
<tr><td><code>{{ .Code }}</code></td><td>{{ .Meaning }}</td></tr>
|
||||
{{- end -}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
131
extensions.go
131
extensions.go
@@ -31,7 +31,7 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||
}
|
||||
out = append(out, pact.AdminActionExtension{
|
||||
Controller: typ.Content.Controller,
|
||||
Form: p.seoTab(typ.Key),
|
||||
Form: p.seoTab(typ.Key, typ.Label),
|
||||
})
|
||||
}
|
||||
if form := p.typeSettingsTabs(types); form != nil {
|
||||
@@ -76,25 +76,101 @@ func (p *Plugin) registry() *signpost.Registry {
|
||||
return reg
|
||||
}
|
||||
|
||||
// seoTabYAML is the SEO tab's fields document: the tab placed after the
|
||||
// owner's tabs (no anchor) and its fields.
|
||||
// seoTabYAML is the SEO tab's fields document (UI-SPEC C3): the tab placed
|
||||
// after the owner's tabs (no anchor, P-17) and its fields in order. The
|
||||
// names seo_robots and seo_og_image are anchors Phase 33 places fields
|
||||
// after; they must not be renamed.
|
||||
const seoTabYAML = `tabs:
|
||||
- name: golem15.seo::lang.tab.seo
|
||||
fields:
|
||||
seo_status:
|
||||
type: partial
|
||||
path: seo_status
|
||||
span: full
|
||||
context: update
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_snippet:
|
||||
label: golem15.seo::lang.field.snippet
|
||||
type: widget
|
||||
widget: golem15-seo-serp
|
||||
action: seo_snippet
|
||||
fill: [seo_title, seo_description, seo_keyphrase]
|
||||
span: full
|
||||
context: [create, update]
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_title:
|
||||
label: golem15.seo::lang.field.title
|
||||
comment: golem15.seo::lang.field.title_comment
|
||||
type: mlseotext
|
||||
measure: title
|
||||
span: full
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_description:
|
||||
label: golem15.seo::lang.field.description
|
||||
comment: golem15.seo::lang.field.description_comment
|
||||
type: mlseotext
|
||||
measure: description
|
||||
span: full
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_keyphrase:
|
||||
label: golem15.seo::lang.field.keyphrase
|
||||
comment: golem15.seo::lang.field.keyphrase_comment
|
||||
type: mltext
|
||||
span: left
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_canonical:
|
||||
label: golem15.seo::lang.field.canonical
|
||||
comment: golem15.seo::lang.field.canonical_comment
|
||||
type: mltext
|
||||
span: right
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_robots:
|
||||
label: golem15.seo::lang.field.robots
|
||||
comment: golem15.seo::lang.field.robots_comment
|
||||
type: mlseorobots
|
||||
scope: record
|
||||
span: full
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_og_title:
|
||||
label: golem15.seo::lang.field.og_title
|
||||
comment: golem15.seo::lang.field.og_title_comment
|
||||
type: mltext
|
||||
span: full
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_og_description:
|
||||
label: golem15.seo::lang.field.og_description
|
||||
type: mltextarea
|
||||
size: small
|
||||
span: full
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_og_image:
|
||||
label: golem15.seo::lang.field.og_image
|
||||
comment: golem15.seo::lang.field.og_image_comment
|
||||
type: seoimage
|
||||
span: full
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_effective:
|
||||
label: golem15.seo::lang.field.effective
|
||||
type: partial
|
||||
path: seo_effective
|
||||
span: full
|
||||
context: update
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
`
|
||||
|
||||
// seoTab is the form extension of content type key.
|
||||
func (p *Plugin) seoTab(key string) *pact.AdminFormExtension {
|
||||
func (p *Plugin) seoTab(key, label string) *pact.AdminFormExtension {
|
||||
return &pact.AdminFormExtension{
|
||||
FieldsYAML: []byte(seoTabYAML),
|
||||
Permissions: []string{controllers.PermissionManage},
|
||||
Visible: func(ctx context.Context) bool {
|
||||
return p.injected(ctx, key)
|
||||
},
|
||||
Dir: "extension",
|
||||
Actions: []pact.AdminAction{classes.RecordSnippetAction(p.Service, key, "golem15.seo::lang.snippet.action", controllers.PermissionManage)},
|
||||
PartialData: func(ctx context.Context, name string, record any) (any, error) {
|
||||
return p.seoTabPartial(ctx, key, name, record)
|
||||
},
|
||||
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
|
||||
return p.loadSEOTab(ctx, key, in)
|
||||
},
|
||||
@@ -104,6 +180,32 @@ func (p *Plugin) seoTab(key string) *pact.AdminFormExtension {
|
||||
}
|
||||
}
|
||||
|
||||
// seoTabPartial serves P1 seo_status and P2 seo_effective of a record.
|
||||
func (p *Plugin) seoTabPartial(ctx context.Context, key, name string, record any) (any, error) {
|
||||
svc := p.Service()
|
||||
if svc == nil {
|
||||
return nil, classes.ErrNotBooted
|
||||
}
|
||||
db, err := svc.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := classes.RecordKey(db, record)
|
||||
if err != nil {
|
||||
if name == "seo_effective" {
|
||||
return classes.EffectiveView{}, nil
|
||||
}
|
||||
return classes.StatusView{}, nil
|
||||
}
|
||||
switch name {
|
||||
case "seo_status":
|
||||
return svc.RecordStatus(ctx, key, id)
|
||||
case "seo_effective":
|
||||
return svc.RecordEffective(ctx, key, id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// contentType returns the registered content type key with a descriptor.
|
||||
func (p *Plugin) contentType(key string) (pact.MenuItemType, error) {
|
||||
reg := p.registry()
|
||||
@@ -134,6 +236,10 @@ func (p *Plugin) loadSEOTab(ctx context.Context, key string, in pact.AdminFormLo
|
||||
}
|
||||
out := map[string]any{}
|
||||
for column, perLocale := range classes.RecordValuesOf(entries) {
|
||||
if column == classes.SharedColumn {
|
||||
out[classes.FieldPrefix+column] = classes.SharedValue(entries)
|
||||
continue
|
||||
}
|
||||
out[classes.FieldPrefix+column] = perLocale
|
||||
}
|
||||
return out, nil
|
||||
@@ -151,12 +257,27 @@ func (p *Plugin) saveSEOTab(ctx context.Context, key string, in pact.AdminFormSa
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
}
|
||||
values := classes.RecordValues{}
|
||||
refusals := pact.AdminFormErrors{}
|
||||
for _, column := range classes.EntryColumns {
|
||||
raw, ok := in.Values[classes.FieldPrefix+column]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[column] = localeMap(raw)
|
||||
perLocale := localeMap(raw)
|
||||
if column == "canonical" {
|
||||
for _, v := range perLocale {
|
||||
if v = strings.TrimSpace(v); v != "" && !classes.ValidURLRef(v) {
|
||||
refusals[classes.FieldPrefix+column] = []string{svc.PhraseCtx(ctx, "lagoon::validation.url", map[string]string{
|
||||
"attribute": svc.PhraseCtx(ctx, "golem15.seo::lang.field.canonical", nil),
|
||||
})}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
values[column] = perLocale
|
||||
}
|
||||
if len(refusals) > 0 {
|
||||
return pact.AdminFormSaveResult{}, refusals
|
||||
}
|
||||
if _, _, err := svc.SaveRecord(ctx, in.DB, classes.RecordWrite{Type: typ, ID: in.ID, Values: values}); err != nil {
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
|
||||
@@ -89,16 +89,16 @@ func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) {
|
||||
}},
|
||||
yaml.MapItem{Key: base + "robots", Value: yaml.MapSlice{
|
||||
{Key: "label", Value: "golem15.seo::lang.settings.type_robots"},
|
||||
{Key: "type", Value: "dropdown"},
|
||||
{Key: "type", Value: "seorobots"},
|
||||
{Key: "scope", Value: "type"},
|
||||
{Key: "span", Value: "full"},
|
||||
{Key: "tab", Value: tab},
|
||||
}},
|
||||
yaml.MapItem{Key: base + "placeholders", Value: yaml.MapSlice{
|
||||
{Key: "type", Value: "partial"},
|
||||
{Key: "path", Value: "type_placeholders"},
|
||||
{Key: "span", Value: "full"},
|
||||
{Key: "tab", Value: tab},
|
||||
{Key: "options", Value: yaml.MapSlice{
|
||||
{Key: "", Value: "golem15.seo::lang.robots.site_default"},
|
||||
{Key: "index,follow", Value: "index, follow"},
|
||||
{Key: "noindex,follow", Value: "noindex, follow"},
|
||||
{Key: "index,nofollow", Value: "index, nofollow"},
|
||||
{Key: "noindex,nofollow", Value: "noindex, nofollow"},
|
||||
}},
|
||||
}},
|
||||
)
|
||||
}
|
||||
@@ -128,6 +128,14 @@ func (p *Plugin) typeSettingsTabs(types []pact.MenuItemType) *pact.AdminFormExte
|
||||
return &pact.AdminFormExtension{
|
||||
FieldsYAML: doc,
|
||||
Permissions: []string{controllers.PermissionManage},
|
||||
Dir: "extension",
|
||||
PartialData: func(ctx context.Context, name string, _ any) (any, error) {
|
||||
svc := p.Service()
|
||||
if svc == nil || name != "type_placeholders" {
|
||||
return nil, nil
|
||||
}
|
||||
return svc.Placeholders(ctx), nil
|
||||
},
|
||||
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
|
||||
rows, err := classes.LoadTypeSettings(in.DB.WithContext(ctx))
|
||||
if err != nil {
|
||||
@@ -220,7 +228,11 @@ func (p *Plugin) saveTypeSettings(ctx context.Context, in pact.AdminFormSave, fi
|
||||
row.DescriptionTemplate = text
|
||||
}
|
||||
case "robots":
|
||||
row.Robots = strings.TrimSpace(stringOf(raw))
|
||||
normal, err := classes.NormalizeRobots(stringOf(raw))
|
||||
if err != nil {
|
||||
refusals[f.name] = append(refusals[f.name], controllers.Phrase(ctx, tr, "golem15.seo::lang.robots.invalid", nil))
|
||||
}
|
||||
row.Robots = normal
|
||||
}
|
||||
changed[f.key] = row
|
||||
}
|
||||
|
||||
23
field_types.go
Normal file
23
field_types.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/controllers"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// FormFieldTypes registers the SEO field types (classes.FieldTypes):
|
||||
// seotext, mlseotext, seorobots, mlseorobots and seoimage. Their element
|
||||
// bundle loads on every admin page, so the SEO tab works on other plugins'
|
||||
// forms.
|
||||
func (p *Plugin) FormFieldTypes() []pact.FormFieldType {
|
||||
return classes.FieldTypes(func() *backpack.App {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.app
|
||||
}, controllers.PermissionManage)
|
||||
}
|
||||
|
||||
var _ pact.HasFormFieldTypes = (*Plugin)(nil)
|
||||
@@ -26,11 +26,13 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gocloud.dev/blob/memblob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -44,6 +46,9 @@ const (
|
||||
pagesTypeKey = "acme-page"
|
||||
pagesModel = "acme_pages_pages"
|
||||
tagsTypeKey = "acme-tag"
|
||||
notesControllerID = "acme.pages.notes"
|
||||
notesTypeKey = "acme-note"
|
||||
notesModel = "acme_pages_notes"
|
||||
adminJWTSecret = "seo-admin-test-only-hs256-secret-key"
|
||||
siteURL = "https://www.example.test"
|
||||
)
|
||||
@@ -70,6 +75,26 @@ func (pagesController) RequiredPermissions() []string {
|
||||
|
||||
type pagesController struct{}
|
||||
|
||||
// note is the model of the tab-less acme.pages.notes form.
|
||||
type note struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
}
|
||||
|
||||
func (note) TableName() string { return notesModel }
|
||||
func (*note) Fillable() []string { return []string{"title", "slug"} }
|
||||
func (*note) Rules() map[string]string { return map[string]string{} }
|
||||
func (notesController) ID() string { return notesControllerID }
|
||||
func (notesController) ModelName() string { return "Note" }
|
||||
func (notesController) ConfigDir() string { return "controllers/notes" }
|
||||
func (notesController) NewRecord() any { return ¬e{} }
|
||||
func (notesController) RequiredPermissions() []string {
|
||||
return []string{pagesPermission}
|
||||
}
|
||||
|
||||
type notesController struct{}
|
||||
|
||||
// pagesPlugin is acme.pages.
|
||||
type pagesPlugin struct {
|
||||
app *backpack.App
|
||||
@@ -108,10 +133,14 @@ func (*pagesPlugin) AdminFS() fs.FS {
|
||||
"controllers/pages/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/pages/models/page/columns.yaml\nmodelClass: Page\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")},
|
||||
"models/page/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
|
||||
"models/page/fields.yaml": &fstest.MapFile{Data: []byte(pagesFields)},
|
||||
"controllers/notes/config_form.yaml": &fstest.MapFile{Data: []byte("name: Note\nform: ~/plugins/acme/pages/models/note/fields.yaml\nmodelClass: Note\n")},
|
||||
"controllers/notes/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/pages/models/note/columns.yaml\nmodelClass: Note\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")},
|
||||
"models/note/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
|
||||
"models/note/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n title:\n label: Title\n type: text\n slug:\n label: Slug\n type: text\n")},
|
||||
}
|
||||
}
|
||||
func (*pagesPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{pagesController{}}
|
||||
return []pact.AdminController{pagesController{}, notesController{}}
|
||||
}
|
||||
func (*pagesPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{Code: pagesPermission, Tab: "Pages", Label: "Manage pages"}}
|
||||
@@ -128,9 +157,14 @@ func (*pagesPlugin) Migrations() []*gormigrate.Migration {
|
||||
published BOOLEAN NOT NULL DEFAULT FALSE
|
||||
)`).Error
|
||||
},
|
||||
}, {
|
||||
ID: "acme_notes_create",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE acme_pages_notes (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL DEFAULT '', slug TEXT NOT NULL DEFAULT '')`).Error
|
||||
},
|
||||
}}
|
||||
}
|
||||
func (*pagesPlugin) Models() []any { return []any{page{}} }
|
||||
func (*pagesPlugin) Models() []any { return []any{page{}, note{}} }
|
||||
|
||||
// db is the write transaction in ctx, else the application database.
|
||||
func (p *pagesPlugin) db(ctx context.Context) *gorm.DB {
|
||||
@@ -154,7 +188,31 @@ func (p *pagesPlugin) describe(row page) pact.ContentRecord {
|
||||
}
|
||||
|
||||
func (p *pagesPlugin) MenuItemTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{p.pageType(), p.tagType()}
|
||||
return []pact.MenuItemType{p.pageType(), p.tagType(), p.noteType()}
|
||||
}
|
||||
|
||||
// noteType is acme-note (/notes/:slug), edited in a form without tabs.
|
||||
func (p *pagesPlugin) noteType() pact.MenuItemType {
|
||||
return pact.MenuItemType{
|
||||
Key: notesTypeKey,
|
||||
Label: "Notes",
|
||||
DefaultPattern: "/notes/:slug",
|
||||
Placeholders: []string{"slug"},
|
||||
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Content: &pact.ContentDescriptor{
|
||||
Controller: notesControllerID,
|
||||
Model: notesModel,
|
||||
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
var rows []note
|
||||
if err := p.db(ctx).Where("id = ?", in.ID).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &pact.ContentRecord{ID: in.ID, Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/notes/" + rows[0].Slug, Title: rows[0].Title}}}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// tagType is acme-tag (/pages/tag/:slug): a content type without an admin
|
||||
@@ -307,6 +365,11 @@ func bootPlugins(t *testing.T, ids []string, extraEnv ...string) *testEnv {
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := memblob.OpenBucket(nil)
|
||||
t.Cleanup(func() { _ = bucket.Close() })
|
||||
if err := attach.Publish(app, bucket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugins, err := party.Activate(app, ids)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
2
go.mod
2
go.mod
@@ -9,6 +9,7 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/testcontainers/testcontainers-go v0.44.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||
gocloud.dev v0.46.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
@@ -162,7 +163,6 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/atomic v1.12.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
gocloud.dev v0.46.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/image v0.46.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
|
||||
@@ -12,6 +12,14 @@ permissions:
|
||||
tab:
|
||||
seo: SEO
|
||||
field:
|
||||
snippet: Search result preview
|
||||
description_comment: Leave empty to use the description template, then the content's own excerpt.
|
||||
keyphrase: Focus keyphrase
|
||||
keyphrase_comment: Checked in the preview above. Never shown on the site.
|
||||
robots_comment: “Inherit” follows a URL rule for this address, then the content type default from the SEO settings. Also sent as an X-Robots-Tag header.
|
||||
og_title_comment: Used by Facebook, LinkedIn and X. Empty fields use the SEO title, description and the content's image.
|
||||
og_image_comment: Recommended 1200 × 630 px. Shared by all languages of this record.
|
||||
effective: What search engines get
|
||||
title: SEO title
|
||||
title_comment: Leave empty to use the title template from the SEO settings.
|
||||
title_comment_rule: Leave empty to keep the title of the page or record at this address.
|
||||
@@ -24,12 +32,20 @@ field:
|
||||
og_description: Social description
|
||||
og_image: Social image
|
||||
robots:
|
||||
invalid: The robots value is not valid. Use index or noindex, follow or nofollow and the advanced directives, separated by commas.
|
||||
inherit: Inherit
|
||||
site_default: Site default
|
||||
staging_title: This is not the production environment
|
||||
status:
|
||||
rule_title: A URL rule overrides part of this tab
|
||||
rule_text: The rule for “:url” sets :fields. Change them in SEO › URL rules.
|
||||
canonical_noindex: The canonical URL “:url” is set to noindex, so search engines may ignore both pages.
|
||||
canonical_redirected: The canonical URL “:url” redirects to “:target”. Point the canonical to the final address.
|
||||
staging: On this environment every page is served with noindex and robots.txt blocks everything (non-production lock).
|
||||
settings:
|
||||
placeholders: Placeholders
|
||||
placeholders_code: Placeholder
|
||||
placeholders_meaning: Replaced with
|
||||
category: SEO
|
||||
label: SEO settings
|
||||
description: Title templates, default descriptions, indexing rules, address format and search engine verification.
|
||||
@@ -149,3 +165,27 @@ history:
|
||||
list_empty: No SEO changes recorded yet. Every change from the admin, MCP or an import appears here and can be undone.
|
||||
jobs:
|
||||
generate_entries: 'SEO: generate entries'
|
||||
effective:
|
||||
property: Property
|
||||
value: Value
|
||||
source: Source
|
||||
locale: “:locale” — :url
|
||||
source_rule: URL rule
|
||||
source_record: This record
|
||||
source_template: Template
|
||||
source_global: SEO settings
|
||||
source_content: Content
|
||||
source_default: Site default
|
||||
no_url: This language has no address yet. Fill in its slug and save.
|
||||
placeholder:
|
||||
title: The content's title
|
||||
description: The content's own description or excerpt
|
||||
site: The site name
|
||||
image:
|
||||
invalid: The file must be a JPEG, PNG, GIF or WebP image.
|
||||
too_big: The image may not be larger than 10 MB.
|
||||
url_invalid: The image must be a path or an http(s) address.
|
||||
forbidden: You may not upload SEO images.
|
||||
snippet:
|
||||
action: Check duplicates
|
||||
invalid: The preview request is not valid.
|
||||
|
||||
@@ -12,6 +12,14 @@ permissions:
|
||||
tab:
|
||||
seo: SEO
|
||||
field:
|
||||
snippet: Podgląd w wynikach wyszukiwania
|
||||
description_comment: Zostaw puste, aby użyć szablonu opisu, a potem zajawki treści.
|
||||
keyphrase: Fraza kluczowa
|
||||
keyphrase_comment: Sprawdzana w podglądzie powyżej. Nigdy nie jest wyświetlana na stronie.
|
||||
robots_comment: „Dziedzicz” stosuje regułę adresu, a potem domyślne ustawienie typu treści z ustawień SEO. Wysyłane także jako nagłówek X-Robots-Tag.
|
||||
og_title_comment: Używany przez Facebooka, LinkedIn i X. Puste pola przejmują tytuł SEO, opis i obraz treści.
|
||||
og_image_comment: Zalecane 1200 × 630 px. Wspólny dla wszystkich wersji językowych tego rekordu.
|
||||
effective: Co otrzymują wyszukiwarki
|
||||
title: Tytuł SEO
|
||||
title_comment: Zostaw puste, aby użyć szablonu tytułu z ustawień SEO.
|
||||
title_comment_rule: Zostaw puste, aby zachować tytuł strony lub rekordu pod tym adresem.
|
||||
@@ -24,12 +32,20 @@ field:
|
||||
og_description: Opis w mediach społecznościowych
|
||||
og_image: Obraz w mediach społecznościowych
|
||||
robots:
|
||||
invalid: Wartość robots jest nieprawidłowa. Użyj index lub noindex, follow lub nofollow oraz dyrektyw zaawansowanych, rozdzielonych przecinkami.
|
||||
inherit: Dziedzicz
|
||||
site_default: Domyślne
|
||||
staging_title: To nie jest środowisko produkcyjne
|
||||
status:
|
||||
rule_title: Reguła adresu nadpisuje część tej zakładki
|
||||
rule_text: 'Reguła dla „:url” ustawia: :fields. Zmień je w SEO › Reguły adresów.'
|
||||
canonical_noindex: Adres kanoniczny „:url” ma ustawione noindex, więc wyszukiwarki mogą zignorować obie strony.
|
||||
canonical_redirected: Adres kanoniczny „:url” przekierowuje na „:target”. Ustaw adres kanoniczny na adres docelowy.
|
||||
staging: W tym środowisku każda strona ma noindex, a robots.txt blokuje wszystko (blokada środowiska nieprodukcyjnego).
|
||||
settings:
|
||||
placeholders: Znaczniki
|
||||
placeholders_code: Znacznik
|
||||
placeholders_meaning: Zastępowany przez
|
||||
category: SEO
|
||||
label: Ustawienia SEO
|
||||
description: Szablony tytułów, domyślne opisy, zasady indeksowania, format adresów i weryfikacja w wyszukiwarkach.
|
||||
@@ -153,3 +169,27 @@ history:
|
||||
list_empty: Nie zarejestrowano jeszcze zmian SEO. Każda zmiana z panelu, MCP lub importu pojawi się tutaj i będzie można ją cofnąć.
|
||||
jobs:
|
||||
generate_entries: 'SEO: generowanie wpisów'
|
||||
effective:
|
||||
property: Właściwość
|
||||
value: Wartość
|
||||
source: Źródło
|
||||
locale: „:locale” — :url
|
||||
source_rule: Reguła adresu
|
||||
source_record: Ten rekord
|
||||
source_template: Szablon
|
||||
source_global: Ustawienia SEO
|
||||
source_content: Treść
|
||||
source_default: Domyślne dla witryny
|
||||
no_url: Ta wersja językowa nie ma jeszcze adresu. Uzupełnij jej slug i zapisz.
|
||||
placeholder:
|
||||
title: Tytuł treści
|
||||
description: Własny opis lub zajawka treści
|
||||
site: Nazwa witryny
|
||||
image:
|
||||
invalid: Plik musi być obrazem JPEG, PNG, GIF lub WebP.
|
||||
too_big: Obraz nie może być większy niż 10 MB.
|
||||
url_invalid: Obraz musi być ścieżką lub adresem http(s).
|
||||
forbidden: Nie masz uprawnień do wgrywania obrazów SEO.
|
||||
snippet:
|
||||
action: Sprawdź duplikaty
|
||||
invalid: Żądanie podglądu jest nieprawidłowe.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# URL rule form (UI-SPEC C7). The SEO field types (seotext, seorobots,
|
||||
# seoimage) and the preview partials come with the field elements.
|
||||
# URL rule form (UI-SPEC C7): address and match, the P1 callouts and the
|
||||
# result preview, the SEO values with the SEO field types, the note and the
|
||||
# P2 effective values. The preview has no fill: an owner form's widget may
|
||||
# fill built-in scalar fields only, so it previews the saved rule.
|
||||
fields:
|
||||
url:
|
||||
label: golem15.seo::lang.rules.url
|
||||
@@ -17,16 +19,30 @@ fields:
|
||||
exact: golem15.seo::lang.rules.match_exact
|
||||
pattern: golem15.seo::lang.rules.match_pattern
|
||||
|
||||
_seo_status:
|
||||
type: partial
|
||||
path: seo_status
|
||||
span: full
|
||||
context: update
|
||||
|
||||
_seo_snippet:
|
||||
label: golem15.seo::lang.field.snippet
|
||||
type: widget
|
||||
widget: golem15-seo-serp
|
||||
action: seo_snippet
|
||||
span: full
|
||||
|
||||
title:
|
||||
label: golem15.seo::lang.field.title
|
||||
comment: golem15.seo::lang.field.title_comment_rule
|
||||
type: text
|
||||
type: seotext
|
||||
measure: title
|
||||
span: full
|
||||
|
||||
description:
|
||||
label: golem15.seo::lang.field.description
|
||||
type: textarea
|
||||
size: small
|
||||
type: seotext
|
||||
measure: description
|
||||
span: full
|
||||
|
||||
canonical:
|
||||
@@ -38,14 +54,9 @@ fields:
|
||||
robots:
|
||||
label: golem15.seo::lang.field.robots
|
||||
comment: golem15.seo::lang.field.robots_comment_rule
|
||||
type: dropdown
|
||||
span: right
|
||||
options:
|
||||
'': golem15.seo::lang.robots.inherit
|
||||
'index,follow': 'index, follow'
|
||||
'noindex,follow': 'noindex, follow'
|
||||
'index,nofollow': 'index, nofollow'
|
||||
'noindex,nofollow': 'noindex, nofollow'
|
||||
type: seorobots
|
||||
scope: rule
|
||||
span: full
|
||||
|
||||
og_title:
|
||||
label: golem15.seo::lang.field.og_title
|
||||
@@ -60,7 +71,7 @@ fields:
|
||||
|
||||
og_image:
|
||||
label: golem15.seo::lang.field.og_image
|
||||
type: text
|
||||
type: seoimage
|
||||
span: full
|
||||
|
||||
note:
|
||||
@@ -69,3 +80,10 @@ fields:
|
||||
type: textarea
|
||||
size: tiny
|
||||
span: full
|
||||
|
||||
_seo_effective:
|
||||
label: golem15.seo::lang.field.effective
|
||||
type: partial
|
||||
path: seo_effective
|
||||
span: full
|
||||
context: update
|
||||
|
||||
@@ -52,7 +52,13 @@ fields:
|
||||
|
||||
default_og_image:
|
||||
label: golem15.seo::lang.settings.default_og_image
|
||||
type: text
|
||||
type: seoimage
|
||||
span: full
|
||||
tab: golem15.seo::lang.settings.tab_general
|
||||
|
||||
_placeholders:
|
||||
type: partial
|
||||
path: placeholders
|
||||
span: full
|
||||
tab: golem15.seo::lang.settings.tab_general
|
||||
|
||||
|
||||
296
seo_tab_test.go
296
seo_tab_test.go
@@ -1,16 +1,25 @@
|
||||
package seo_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
seo "git.golem15.com/golem15/sm-seo-plugin"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// seoTabLabel is the English label of seo.TabSEO.
|
||||
@@ -307,3 +316,290 @@ func TestGenerateEntries(t *testing.T) {
|
||||
t.Fatal("unknown type: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
// pngBytes is a 1x1 PNG.
|
||||
var pngBytes = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
// upload posts one file to a plugin field's upload route.
|
||||
func (e *testEnv) upload(t *testing.T, token, path, filename string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, path, &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
req.RemoteAddr = "198.51.100.7:4000"
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestSEOTab checks the complete SEO tab (UI-SPEC C3): the field order in
|
||||
// the tab placed last on a tabbed form and after the default tab on a
|
||||
// tab-less one, the robots and canonical refusals, the social image upload
|
||||
// and its one value for every locale, the result-preview widget action and
|
||||
// the P1/P2 partials.
|
||||
func TestSEOTab(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
|
||||
want := "seo_status seo_snippet seo_title seo_description seo_keyphrase seo_canonical seo_robots seo_og_title seo_og_description seo_og_image seo_effective"
|
||||
fields := env.formSchema(t, token, "acme/pages/pages")
|
||||
var seoFields []string
|
||||
for _, f := range fields {
|
||||
if strings.HasPrefix(f.Name, "seo_") {
|
||||
seoFields = append(seoFields, f.Name)
|
||||
if f.Tab != seoTabLabel {
|
||||
t.Fatalf("%s is in tab %q", f.Name, f.Tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Join(seoFields, " ") != want {
|
||||
t.Fatalf("SEO tab fields = %v, want %s", seoFields, want)
|
||||
}
|
||||
if tabs := tabsOf(fields); tabs[len(tabs)-1] != seoTabLabel || len(tabs) != 4 {
|
||||
t.Fatalf("tabs = %q, want the owner's three tabs then SEO", tabs)
|
||||
}
|
||||
if tabs := tabsOf(env.formSchema(t, token, "acme/pages/notes")); len(tabs) != 2 || tabs[1] != seoTabLabel {
|
||||
t.Fatalf("tab-less form tabs = %q, want default then SEO", tabs)
|
||||
}
|
||||
|
||||
id := env.createPage(t, token, map[string]any{"title": "Grzyby leśne", "slug": "grzyby", "excerpt": "O grzybach", "published": true})
|
||||
other := env.createPage(t, token, map[string]any{"title": "Other", "slug": "other", "published": true, "seo_title": map[string]any{"en": "Duplicate title"}})
|
||||
path := adminAPI("/acme/pages/pages/" + id)
|
||||
base := map[string]any{"title": "Grzyby leśne", "slug": "grzyby", "excerpt": "O grzybach", "published": true}
|
||||
with := func(extra map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
rec := env.call(t, http.MethodPut, path, token, with(map[string]any{"seo_robots": map[string]any{"en": "index,banana"}}))
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "seo_robots") {
|
||||
t.Fatalf("malformed robots = %d %s, want 422 on seo_robots", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = env.call(t, http.MethodPut, path, token, with(map[string]any{"seo_canonical": map[string]any{"en": "javascript:alert(1)"}}))
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "seo_canonical") {
|
||||
t.Fatalf("bad canonical = %d %s, want 422 on seo_canonical", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
uploadPath := adminAPI("/acme/pages/pages/" + id + "/fields/seo_og_image/upload")
|
||||
if rec := env.upload(t, token, uploadPath, "evil.png", []byte("<svg onload=alert(1)>")); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("non-image upload = %d %s, want 422", rec.Code, rec.Body.String())
|
||||
}
|
||||
editor := env.admin(t, false, pagesPermission)
|
||||
if rec := env.upload(t, editor, uploadPath, "ok.png", pngBytes); rec.Code == http.StatusCreated {
|
||||
t.Fatalf("upload without golem15.seo.manage = %d, want a refusal", rec.Code)
|
||||
}
|
||||
rec = env.upload(t, token, uploadPath, "Social Image.PNG", pngBytes)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("image upload = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var uploaded struct {
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil || !strings.Contains(uploaded.Data.URL, "/media/seo/") || !strings.HasSuffix(uploaded.Data.URL, "-social-image.png") {
|
||||
t.Fatalf("upload answer %s err=%v", rec.Body.String(), err)
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPut, path, token, with(map[string]any{
|
||||
"seo_title": map[string]any{"en": "Grzyby leśne z Podlasia"},
|
||||
"seo_description": map[string]any{"en": "Duplicate description", "pl": "Opis"},
|
||||
"seo_robots": map[string]any{"en": "NOINDEX, max-snippet:50"},
|
||||
"seo_canonical": map[string]any{"en": "/canonical"},
|
||||
"seo_og_image": uploaded.Data.URL,
|
||||
}))
|
||||
var images []string
|
||||
if err := env.db.Raw(`SELECT og_image FROM golem15_seo_entries WHERE record_id = ? ORDER BY locale`, id).Scan(&images).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(images) != 2 || images[0] != uploaded.Data.URL || images[1] != uploaded.Data.URL {
|
||||
t.Fatalf("og_image per locale = %v, want the one image in en and pl", images)
|
||||
}
|
||||
var robots string
|
||||
if err := env.db.Raw(`SELECT robots FROM golem15_seo_entries WHERE record_id = ? AND locale = 'en'`, id).Scan(&robots).Error; err != nil || robots != "noindex,max-snippet:50" {
|
||||
t.Fatalf("stored robots = %q err=%v, want the normalised value", robots, err)
|
||||
}
|
||||
show := env.expect(t, http.StatusOK, http.MethodGet, path, token, nil)
|
||||
if !strings.Contains(show.Body.String(), `"seo_og_image":"`+uploaded.Data.URL+`"`) {
|
||||
t.Fatalf("show lacks the social image: %s", show.Body.String())
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+other), token, map[string]any{
|
||||
"title": "Other", "slug": "other", "published": true, "seo_description": map[string]any{"en": "Duplicate description"},
|
||||
})
|
||||
numericID := func(s string) int { n, _ := strconv.Atoi(s); return n }
|
||||
widget := func(payload map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/acme/pages/pages/widgets/seo_snippet"), token, map[string]any{"record_id": numericID(id), "payload": payload})
|
||||
var out struct {
|
||||
Data struct {
|
||||
Data map[string]any `json:"data"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Data.Data == nil {
|
||||
t.Fatalf("widget answer %s", rec.Body.String())
|
||||
}
|
||||
return out.Data.Data
|
||||
}
|
||||
ctxData := widget(map[string]any{"op": "context"})
|
||||
locales, _ := ctxData["locales"].([]any)
|
||||
first, _ := locales[0].(map[string]any)
|
||||
if ctxData["host"] != "www.example.test" || len(locales) != 2 || first["locale"] != "en" || first["url"] != "/pages/grzyby" || first["titleTemplate"] != "%title% | %site%" || first["descriptionSource"] != "content" {
|
||||
t.Fatalf("context = %v", ctxData)
|
||||
}
|
||||
dups := widget(map[string]any{"op": "duplicates", "locale": "en", "title": "Grzyby leśne z Podlasia", "description": "Duplicate description"})
|
||||
if d, _ := dups["description"].([]any); len(d) != 1 || !strings.Contains(fmtJSON(d), "/pages/other") || !strings.Contains(fmtJSON(d), "/backend/acme/pages/pages/update/"+other) {
|
||||
t.Fatalf("duplicates = %v, want the other page", dups)
|
||||
}
|
||||
if d, _ := dups["title"].([]any); len(d) != 0 {
|
||||
t.Fatalf("title duplicates = %v, want none", d)
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/pages/grzyby", "match": "exact", "title": "Rule title", "robots": "index"})
|
||||
status := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/partials/seo_status?id="+id), token, nil)
|
||||
if !strings.Contains(status.Body.String(), "A URL rule overrides part of this tab") || !strings.Contains(status.Body.String(), "SEO title, Search engine indexing") {
|
||||
t.Fatalf("seo_status partial = %s", status.Body.String())
|
||||
}
|
||||
effective := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/partials/seo_effective?id="+id), token, nil)
|
||||
for _, part := range []string{"Rule title", "URL rule", "/canonical", "This record"} {
|
||||
if !strings.Contains(effective.Body.String(), part) {
|
||||
t.Fatalf("seo_effective partial lacks %q: %s", part, effective.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleFormPreview checks the URL rule form's partials and preview
|
||||
// widget: an exact rule resolves at its address, a pattern rule lists its
|
||||
// own values, and the preview context carries the saved rule.
|
||||
func TestRuleFormPreview(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
create := func(body map[string]any) string {
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, body)
|
||||
var out struct {
|
||||
Data struct {
|
||||
ID json.Number `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Data.ID.String()
|
||||
}
|
||||
exact := create(map[string]any{"url": "/", "match": "exact", "title": "Home rule", "robots": "noindex,follow"})
|
||||
pattern := create(map[string]any{"url": "/blog?tag=*", "match": "pattern", "description": "Tag pages"})
|
||||
rec := env.call(t, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/x", "match": "exact", "robots": "index,banana"})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "robots") {
|
||||
t.Fatalf("malformed rule robots = %d %s, want 422", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/rules/partials/seo_effective?id="+exact), token, nil).Body.String()
|
||||
if !strings.Contains(body, "Home rule") || !strings.Contains(body, "noindex, follow") {
|
||||
t.Fatalf("exact rule P2 = %s", body)
|
||||
}
|
||||
body = env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/rules/partials/seo_effective?id="+pattern), token, nil).Body.String()
|
||||
if !strings.Contains(body, "Tag pages") || !strings.Contains(body, "URL rule") {
|
||||
t.Fatalf("pattern rule P2 = %s", body)
|
||||
}
|
||||
n, _ := strconv.Atoi(exact)
|
||||
body = env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/golem15/seo/rules/widgets/_seo_snippet"), token, map[string]any{"record_id": n, "payload": map[string]any{"op": "context"}}).Body.String()
|
||||
if !strings.Contains(body, `"defaultTitle":"Home rule"`) || !strings.Contains(body, `"url":"/"`) {
|
||||
t.Fatalf("rule preview context = %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// langKeyPattern finds golem15.seo::lang.<key> references.
|
||||
var langKeyPattern = regexp.MustCompile(`golem15\.seo::lang\.([a-z0-9_.]+[a-z0-9_])`)
|
||||
|
||||
// TestLangKeysComplete checks that every golem15.seo::lang key the plugin's
|
||||
// Go, YAML and template files name exists in both phrasebooks.
|
||||
func TestLangKeysComplete(t *testing.T) {
|
||||
books := map[string]map[string]any{}
|
||||
for _, locale := range []string{"en", "pl"} {
|
||||
raw, err := os.ReadFile(filepath.Join("lang", locale, "lang.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var book map[string]any
|
||||
if err := yaml.Unmarshal(raw, &book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
books[locale] = book
|
||||
}
|
||||
has := func(book map[string]any, key string) bool {
|
||||
var cur any = book
|
||||
for _, part := range strings.Split(key, ".") {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if cur, ok = m[part]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return cur != nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && (path == "node_modules" || path == ".git" || path == "assets") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
ext := filepath.Ext(path)
|
||||
if d.IsDir() || (ext != ".go" && ext != ".yaml" && ext != ".htm") || strings.HasPrefix(path, "lang") {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range langKeyPattern.FindAllStringSubmatch(string(raw), -1) {
|
||||
key := m[1]
|
||||
if strings.HasSuffix(key, "via_") || strings.HasSuffix(key, "source_") {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
for locale, book := range books {
|
||||
if !has(book, key) {
|
||||
t.Errorf("%s: golem15.seo::lang.%s is missing from lang/%s/lang.yaml", path, key, locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(seen) < 50 {
|
||||
t.Fatalf("found only %d keys; the scan is broken", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ func tabsOf(fields []schemaField) []string {
|
||||
// General and Indexing, their values in golem15_seo_type_settings with the
|
||||
// D-21 defaults, template checks, and the inject switch hiding the SEO tab.
|
||||
func TestSettingsTypeTabs(t *testing.T) {
|
||||
t.Run("two content types", func(t *testing.T) {
|
||||
t.Run("three content types", func(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
fields := env.formSchema(t, token, "golem15/seo/settings")
|
||||
if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Indexing | Addresses | Verification"; got != want {
|
||||
if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Indexing | Addresses | Verification"; got != want {
|
||||
t.Fatalf("settings tabs = %q, want %q", got, want)
|
||||
}
|
||||
findField(t, fields, "type_acme_page_inject")
|
||||
|
||||
Reference in New Issue
Block a user