feat: SEO field types and the complete SEO tab

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

View File

@@ -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.

View File

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

139
classes/field_types.go Normal file
View File

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

123
classes/image.go Normal file
View File

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

314
classes/partials.go Normal file
View File

@@ -0,0 +1,314 @@
package classes
import (
"context"
"fmt"
"net/url"
"reflect"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// The view models of the SEO partials. They hold text only (translated in
// the request locale), never a GORM model; html/template escapes every
// value.
// Callout is one P1 warning.
type Callout struct {
Title string
Text string
}
// StatusView is the P1 seo_status view model.
type StatusView struct {
Callouts []Callout
}
// EffectiveRow is one P2 row: a property, its effective value and source.
type EffectiveRow struct {
Property string
Value string
Source string
Mono bool
}
// EffectiveLocale is the P2 table of one locale.
type EffectiveLocale struct {
Heading string
NoURL bool
NoURLText string
Rows []EffectiveRow
}
// EffectiveView is the P2 seo_effective view model.
type EffectiveView struct {
Staging bool
StagingText string
PropertyLabel string
ValueLabel string
SourceLabel string
Locales []EffectiveLocale
}
// PlaceholderRow is one P3 row.
type PlaceholderRow struct {
Code string
Meaning string
}
// PlaceholdersView is the P3 placeholders view model.
type PlaceholdersView struct {
Caption string
CodeLabel string
MeaningLabel string
Rows []PlaceholderRow
}
// PhraseCtx translates key with params in the request locale; without a
// translator it returns the key.
func (s *Service) PhraseCtx(ctx context.Context, key string, params map[string]string) string {
tr := s.translator()
if tr == nil {
return key
}
return tr.Get(ctx, key, params)
}
// effectiveProperties are the P2 rows in order: value name, label key,
// monospace.
var effectiveProperties = []struct {
name string
label string
mono bool
}{
{"title", "golem15.seo::lang.field.title", false},
{"description", "golem15.seo::lang.field.description", false},
{"canonical", "golem15.seo::lang.field.canonical", true},
{"robots", "golem15.seo::lang.field.robots", false},
{"og_title", "golem15.seo::lang.field.og_title", false},
{"og_description", "golem15.seo::lang.field.og_description", false},
{"og_image", "golem15.seo::lang.field.og_image", true},
}
// EffectiveRows turns a resolve answer into P2 rows.
func (s *Service) EffectiveRows(ctx context.Context, res Result) []EffectiveRow {
if res.SEO == nil {
return nil
}
values := map[string]string{
"title": res.SEO.Title, "description": res.SEO.Description, "canonical": res.SEO.Canonical,
"robots": res.SEO.Robots, "og_title": res.SEO.OG.Title, "og_description": res.SEO.OG.Description, "og_image": res.SEO.OG.Image,
}
out := make([]EffectiveRow, 0, len(effectiveProperties))
for _, p := range effectiveProperties {
out = append(out, EffectiveRow{
Property: s.PhraseCtx(ctx, p.label, nil),
Value: values[p.name],
Source: s.PhraseCtx(ctx, "golem15.seo::lang.effective.source_"+res.Sources[p.name], nil),
Mono: p.mono,
})
}
return out
}
// newEffectiveView starts a P2 view with its labels and the staging note.
func (s *Service) newEffectiveView(ctx context.Context) EffectiveView {
return EffectiveView{
Staging: s.Staging(),
StagingText: s.PhraseCtx(ctx, "golem15.seo::lang.status.staging", nil),
PropertyLabel: s.PhraseCtx(ctx, "golem15.seo::lang.effective.property", nil),
ValueLabel: s.PhraseCtx(ctx, "golem15.seo::lang.effective.value", nil),
SourceLabel: s.PhraseCtx(ctx, "golem15.seo::lang.effective.source", nil),
}
}
// RecordEffective builds P2 for a record: one table per locale at the
// record's own address, or the no-address line.
func (s *Service) RecordEffective(ctx context.Context, key, id string) (EffectiveView, error) {
view := s.newEffectiveView(ctx)
db, err := s.DB(ctx)
if err != nil {
return view, err
}
described := s.recordLocales(ctx, key, id)
for _, locale := range s.Locales(ctx, db) {
l := described[locale]
heading := s.PhraseCtx(ctx, "golem15.seo::lang.effective.locale", map[string]string{"locale": strings.ToUpper(locale), "url": l.URL})
res, ok, err := s.Resolver().ResolveRecord(ctx, key, id, locale)
if err != nil {
return view, err
}
if !ok {
view.Locales = append(view.Locales, EffectiveLocale{Heading: strings.ToUpper(locale), NoURL: true, NoURLText: s.PhraseCtx(ctx, "golem15.seo::lang.effective.no_url", nil)})
continue
}
view.Locales = append(view.Locales, EffectiveLocale{Heading: heading, Rows: s.EffectiveRows(ctx, res)})
}
return view, nil
}
// ruleProperties returns the translated labels of the properties a rule
// sets.
func (s *Service) ruleProperties(ctx context.Context, r models.Rule) []string {
var out []string
for _, p := range models.RuleProperties {
if strings.TrimSpace(r.Value(p.Column)) != "" {
out = append(out, s.PhraseCtx(ctx, p.Label, nil))
}
}
return out
}
// RecordStatus builds P1 for a record: the URL rule that overrides part of
// its SEO tab (the first one found over its locales), and a canonical that
// points to a page served noindex (D-06).
func (s *Service) RecordStatus(ctx context.Context, key, id string) (StatusView, error) {
var view StatusView
db, err := s.DB(ctx)
if err != nil {
return view, err
}
for _, l := range s.recordLocales(ctx, key, id) {
if l.URL == "" {
continue
}
path, _, err := parseTarget(l.URL)
if err != nil {
continue
}
rules, err := matchingRules(db, path, rawQueryOf(l.URL))
if err != nil {
return view, err
}
if c, ok := s.ruleCallout(ctx, rules); ok {
view.Callouts = append(view.Callouts, c)
break
}
}
reg := s.Registry()
if reg != nil {
if typ, ok := reg.ContentType(key); ok && typ.Content != nil {
entries, err := LoadEntries(db, typ.Content.Model, id)
if err != nil {
return view, err
}
for _, e := range entries {
if c, ok := s.canonicalCallout(ctx, e.Canonical); ok {
view.Callouts = append(view.Callouts, c)
break
}
}
}
}
return view, nil
}
// ruleCallout is the override callout of the first rule that sets a value.
func (s *Service) ruleCallout(ctx context.Context, rules []models.Rule) (Callout, bool) {
for _, r := range rules {
props := s.ruleProperties(ctx, r)
if len(props) == 0 {
continue
}
return Callout{
Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.rule_title", nil),
Text: s.PhraseCtx(ctx, "golem15.seo::lang.status.rule_text", map[string]string{"url": r.URL, "fields": strings.Join(props, ", ")}),
}, true
}
return Callout{}, false
}
// canonicalCallout warns when a canonical on this site resolves to a page
// served noindex. Outside production every page is noindex, so it says
// nothing there.
func (s *Service) canonicalCallout(ctx context.Context, canonical string) (Callout, bool) {
canonical = strings.TrimSpace(canonical)
if canonical == "" || s.Staging() {
return Callout{}, false
}
target := canonical
if u, err := url.Parse(canonical); err == nil && u.IsAbs() {
site, err := url.Parse(s.SiteURL())
if err != nil || site.Host == "" || !strings.EqualFold(site.Host, u.Host) {
return Callout{}, false
}
target = u.RequestURI()
}
res, err := s.Resolver().Resolve(ctx, target)
if err != nil || res.SEO == nil || !strings.Contains(res.SEO.Robots, "noindex") {
return Callout{}, false
}
return Callout{Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.canonical_noindex", map[string]string{"url": canonical})}, true
}
// RuleStatus builds P1 for a URL rule: the canonical warning.
func (s *Service) RuleStatus(ctx context.Context, r models.Rule) StatusView {
var view StatusView
if c, ok := s.canonicalCallout(ctx, r.Canonical); ok {
view.Callouts = append(view.Callouts, c)
}
return view
}
// RuleEffective builds P2 for a URL rule: an exact rule is resolved at its
// address; a pattern rule shows its own values with the source URL rule.
func (s *Service) RuleEffective(ctx context.Context, r models.Rule) (EffectiveView, error) {
view := s.newEffectiveView(ctx)
if r.Match == models.MatchExact {
res, err := s.Resolver().Resolve(ctx, r.URL)
if err != nil {
return view, err
}
view.Locales = append(view.Locales, EffectiveLocale{Heading: r.URL, Rows: s.EffectiveRows(ctx, res)})
return view, nil
}
source := s.PhraseCtx(ctx, "golem15.seo::lang.effective.source_rule", nil)
var rows []EffectiveRow
for _, p := range effectiveProperties {
if v := strings.TrimSpace(r.Value(p.name)); v != "" {
rows = append(rows, EffectiveRow{Property: s.PhraseCtx(ctx, p.label, nil), Value: v, Source: source, Mono: p.mono})
}
}
view.Locales = append(view.Locales, EffectiveLocale{Heading: r.URL, Rows: rows})
return view, nil
}
// Placeholders builds P3.
func (s *Service) Placeholders(ctx context.Context) PlaceholdersView {
return PlaceholdersView{
Caption: s.PhraseCtx(ctx, "golem15.seo::lang.settings.placeholders", nil),
CodeLabel: s.PhraseCtx(ctx, "golem15.seo::lang.settings.placeholders_code", nil),
MeaningLabel: s.PhraseCtx(ctx, "golem15.seo::lang.settings.placeholders_meaning", nil),
Rows: []PlaceholderRow{
{Code: PlaceholderTitle, Meaning: s.PhraseCtx(ctx, "golem15.seo::lang.placeholder.title", nil)},
{Code: PlaceholderDescription, Meaning: s.PhraseCtx(ctx, "golem15.seo::lang.placeholder.description", nil)},
{Code: PlaceholderSite, Meaning: s.PhraseCtx(ctx, "golem15.seo::lang.placeholder.site", nil)},
},
}
}
// RecordKey returns the primary key of a GORM model as text.
func RecordKey(db *gorm.DB, record any) (string, error) {
if record == nil || db == nil {
return "", fmt.Errorf("golem15.seo: no record")
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(record); err != nil {
return "", err
}
if stmt.Schema == nil || stmt.Schema.PrioritizedPrimaryField == nil {
return "", fmt.Errorf("golem15.seo: %T has no primary key", record)
}
v, zero := stmt.Schema.PrioritizedPrimaryField.ValueOf(context.Background(), reflectValue(record))
if zero {
return "", fmt.Errorf("golem15.seo: %T has an empty primary key", record)
}
return fmt.Sprint(v), nil
}
// reflectValue is the addressable value of a model pointer.
func reflectValue(record any) reflect.Value {
return reflect.Indirect(reflect.ValueOf(record))
}

View File

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

343
classes/snippet.go Normal file
View File

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

View File

@@ -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{}
)

View 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 -}}

View 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 -}}

View File

@@ -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

View 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>

View 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 -}}

View 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 -}}

View 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>

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -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 &note{} }
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
View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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")