From 93ddbcc9ef67e9170cfbb6d93e2c2f0677fd10d2 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 27 Oct 2026 11:04:03 +0100 Subject: [PATCH] feat: SEO field types and the complete SEO tab --- admin.go | 6 +- classes/entries.go | 60 ++++- classes/field_types.go | 139 ++++++++++ classes/image.go | 123 +++++++++ classes/partials.go | 314 ++++++++++++++++++++++ classes/resolver.go | 57 +++- classes/snippet.go | 343 +++++++++++++++++++++++++ controllers/rules.go | 44 ++++ controllers/rules/_seo_effective.htm | 24 ++ controllers/rules/_seo_status.htm | 6 + controllers/settings.go | 20 +- controllers/settings/_placeholders.htm | 11 + extension/_seo_effective.htm | 24 ++ extension/_seo_status.htm | 6 + extension/_type_placeholders.htm | 11 + extensions.go | 131 +++++++++- extensions_settings.go | 30 ++- field_types.go | 23 ++ fixture_test.go | 69 ++++- go.mod | 2 +- lang/en/lang.yaml | 40 +++ lang/pl/lang.yaml | 40 +++ models/rule/fields.yaml | 46 +++- models/settings/fields.yaml | 8 +- seo_tab_test.go | 296 +++++++++++++++++++++ settings_test.go | 4 +- 26 files changed, 1827 insertions(+), 50 deletions(-) create mode 100644 classes/field_types.go create mode 100644 classes/image.go create mode 100644 classes/partials.go create mode 100644 classes/snippet.go create mode 100644 controllers/rules/_seo_effective.htm create mode 100644 controllers/rules/_seo_status.htm create mode 100644 controllers/settings/_placeholders.htm create mode 100644 extension/_seo_effective.htm create mode 100644 extension/_seo_status.htm create mode 100644 extension/_type_placeholders.htm create mode 100644 field_types.go diff --git a/admin.go b/admin.go index 3db6a92..d5437c3 100644 --- a/admin.go +++ b/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. diff --git a/classes/entries.go b/classes/entries.go index 8c5583e..b1f43d6 100644 --- a/classes/entries.go +++ b/classes/entries.go @@ -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 “” (<locale>)". func (s *Service) recordWhat(typ pact.MenuItemType, id string, l pact.ContentLocale, locale string) string { diff --git a/classes/field_types.go b/classes/field_types.go new file mode 100644 index 0000000..9c49f50 --- /dev/null +++ b/classes/field_types.go @@ -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 != "" +} diff --git a/classes/image.go b/classes/image.go new file mode 100644 index 0000000..ee0123c --- /dev/null +++ b/classes/image.go @@ -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) +} diff --git a/classes/partials.go b/classes/partials.go new file mode 100644 index 0000000..81c87a6 --- /dev/null +++ b/classes/partials.go @@ -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)) +} diff --git a/classes/resolver.go b/classes/resolver.go index 06159da..1551816 100644 --- a/classes/resolver.go +++ b/classes/resolver.go @@ -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 } diff --git a/classes/snippet.go b/classes/snippet.go new file mode 100644 index 0000000..6c14232 --- /dev/null +++ b/classes/snippet.go @@ -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 + }, + } +} diff --git a/controllers/rules.go b/controllers/rules.go index dc3a8c7..71cebd3 100644 --- a/controllers/rules.go +++ b/controllers/rules.go @@ -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{} ) diff --git a/controllers/rules/_seo_effective.htm b/controllers/rules/_seo_effective.htm new file mode 100644 index 0000000..14dcc8e --- /dev/null +++ b/controllers/rules/_seo_effective.htm @@ -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 -}} diff --git a/controllers/rules/_seo_status.htm b/controllers/rules/_seo_status.htm new file mode 100644 index 0000000..de192cc --- /dev/null +++ b/controllers/rules/_seo_status.htm @@ -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 -}} diff --git a/controllers/settings.go b/controllers/settings.go index cce14b4..4eee58c 100644 --- a/controllers/settings.go +++ b/controllers/settings.go @@ -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 diff --git a/controllers/settings/_placeholders.htm b/controllers/settings/_placeholders.htm new file mode 100644 index 0000000..eb2a580 --- /dev/null +++ b/controllers/settings/_placeholders.htm @@ -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> diff --git a/extension/_seo_effective.htm b/extension/_seo_effective.htm new file mode 100644 index 0000000..14dcc8e --- /dev/null +++ b/extension/_seo_effective.htm @@ -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 -}} diff --git a/extension/_seo_status.htm b/extension/_seo_status.htm new file mode 100644 index 0000000..de192cc --- /dev/null +++ b/extension/_seo_status.htm @@ -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 -}} diff --git a/extension/_type_placeholders.htm b/extension/_type_placeholders.htm new file mode 100644 index 0000000..eb2a580 --- /dev/null +++ b/extension/_type_placeholders.htm @@ -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> diff --git a/extensions.go b/extensions.go index 7a3f5c8..2892987 100644 --- a/extensions.go +++ b/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 diff --git a/extensions_settings.go b/extensions_settings.go index 9900ec4..00c8920 100644 --- a/extensions_settings.go +++ b/extensions_settings.go @@ -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 } diff --git a/field_types.go b/field_types.go new file mode 100644 index 0000000..1118ca9 --- /dev/null +++ b/field_types.go @@ -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) diff --git a/fixture_test.go b/fixture_test.go index 33f429e..d575214 100644 --- a/fixture_test.go +++ b/fixture_test.go @@ -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) diff --git a/go.mod b/go.mod index 7cf8cb9..24fc64b 100644 --- a/go.mod +++ b/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 diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 7467184..1081f3d 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 65b663a..977d6b2 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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. diff --git a/models/rule/fields.yaml b/models/rule/fields.yaml index b7a58a7..e442b12 100644 --- a/models/rule/fields.yaml +++ b/models/rule/fields.yaml @@ -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 diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml index 4fba07a..536c71e 100644 --- a/models/settings/fields.yaml +++ b/models/settings/fields.yaml @@ -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 diff --git a/seo_tab_test.go b/seo_tab_test.go index 5ca60be..23ccacb 100644 --- a/seo_tab_test.go +++ b/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)) + } +} diff --git a/settings_test.go b/settings_test.go index 412c148..6ec8999 100644 --- a/settings_test.go +++ b/settings_test.go @@ -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")