feat: plugin scaffold, entries and the SEO tab tracer

This commit is contained in:
2026-10-27 10:27:46 +01:00
commit fb9590f099
22 changed files with 2373 additions and 0 deletions

213
classes/entries.go Normal file
View File

@@ -0,0 +1,213 @@
package classes
import (
"context"
"fmt"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// The entry columns the SEO tab edits, in form order. Each one is a field
// named FieldPrefix+column in the SEO tab.
var EntryColumns = []string{
"title", "description", "keyphrase", "canonical", "robots",
"og_title", "og_description", "og_image",
}
// 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_"
// entryValue reads one editable column of e.
func entryValue(e models.Entry, column string) string {
switch column {
case "title":
return e.Title
case "description":
return e.Description
case "keyphrase":
return e.Keyphrase
case "canonical":
return e.Canonical
case "robots":
return e.Robots
case "og_title":
return e.OGTitle
case "og_description":
return e.OGDescription
case "og_image":
return e.OGImage
}
return ""
}
// setEntryValue writes one editable column of e; an unknown column is
// ignored.
func setEntryValue(e *models.Entry, column, value string) {
switch column {
case "title":
e.Title = value
case "description":
e.Description = value
case "keyphrase":
e.Keyphrase = value
case "canonical":
e.Canonical = value
case "robots":
e.Robots = value
case "og_title":
e.OGTitle = value
case "og_description":
e.OGDescription = value
case "og_image":
e.OGImage = value
}
}
// EntryValue is the exported reader of one editable column.
func EntryValue(e models.Entry, column string) string { return entryValue(e, column) }
// LoadEntries returns a record's entries keyed by locale.
func LoadEntries(db *gorm.DB, modelType, recordID string) (map[string]models.Entry, error) {
var rows []models.Entry
if err := db.Where("model_type = ? AND record_id = ?", modelType, recordID).Order("locale ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]models.Entry, len(rows))
for _, row := range rows {
out[row.Locale] = row
}
return out, nil
}
// LoadEntry returns a record's entry in locale; found is false when there is
// none.
func LoadEntry(db *gorm.DB, modelType, recordID, locale string) (models.Entry, bool, error) {
var rows []models.Entry
if err := db.Where("model_type = ? AND record_id = ? AND locale = ?", modelType, recordID, locale).Limit(1).Find(&rows).Error; err != nil {
return models.Entry{}, false, err
}
if len(rows) == 0 {
return models.Entry{}, false, nil
}
return rows[0], true, nil
}
// RecordValues is a set of SEO tab values for one record: column ->
// locale -> value. A column or locale that is absent is left as stored.
type RecordValues map[string]map[string]string
// RecordWrite is one write of a record's SEO values through the service.
type RecordWrite struct {
// Type is the record's content type (it carries the descriptor).
Type pact.MenuItemType
// ID is the record's primary key as text.
ID string
// Values are the submitted values; nil writes none and only refreshes
// the URL snapshot of existing entries.
Values RecordValues
}
// SaveRecord writes a record's SEO values inside db (the caller's
// transaction): each locale that carries a value gets its entry created or
// updated, and every written entry takes the record's current URL in that
// locale (signpost Describe, read through the same transaction). It returns
// the entries before and after the write, keyed by locale, for history.
func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (before, after map[string]models.Entry, err error) {
if in.Type.Content == nil {
return nil, nil, fmt.Errorf("golem15.seo: type %q has no content descriptor", in.Type.Key)
}
modelType := in.Type.Content.Model
existing, err := LoadEntries(db, modelType, in.ID)
if err != nil {
return nil, nil, err
}
before = make(map[string]models.Entry, len(existing))
for locale, e := range existing {
before[locale] = e
}
urls := s.recordURLs(ctx, in.Type.Key, in.ID)
touched := map[string]models.Entry{}
for column, perLocale := range in.Values {
for locale, value := range perLocale {
e, ok := touched[locale]
if !ok {
if e, ok = existing[locale]; !ok {
e = models.Entry{ModelType: modelType, RecordID: in.ID, Locale: locale}
}
}
setEntryValue(&e, column, strings.TrimSpace(value))
touched[locale] = e
}
}
after = make(map[string]models.Entry, len(existing))
for locale, e := range existing {
after[locale] = e
}
for locale, e := range touched {
_, stored := existing[locale]
if !stored && entryEmpty(e) {
continue
}
e.TypeKey = in.Type.Key
if u, ok := urls[locale]; ok {
e.URL = u
}
if e.ID == 0 {
if err := db.Create(&e).Error; err != nil {
return nil, nil, err
}
} else if err := db.Save(&e).Error; err != nil {
return nil, nil, err
}
after[locale] = e
}
return before, after, nil
}
// entryEmpty reports whether no editable column of e carries a value.
func entryEmpty(e models.Entry) bool {
for _, column := range EntryColumns {
if entryValue(e, column) != "" {
return false
}
}
return len(e.Data) == 0
}
// recordURLs returns the record's public path per locale through its
// content descriptor; an error or a missing registry answers none.
func (s *Service) recordURLs(ctx context.Context, key, id string) map[string]string {
out := map[string]string{}
reg := s.Registry()
if reg == nil {
return out
}
rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale())
if err != nil || rec == nil {
return out
}
for _, l := range rec.Locales {
out[l.Locale] = l.URL
}
return out
}
// RecordValuesOf returns the stored values of a record for the SEO tab:
// column -> locale -> value.
func RecordValuesOf(entries map[string]models.Entry) RecordValues {
out := RecordValues{}
for _, column := range EntryColumns {
perLocale := map[string]string{}
for locale, e := range entries {
if v := entryValue(e, column); v != "" {
perLocale[locale] = v
}
}
out[column] = perLocale
}
return out
}

222
classes/resolver.go Normal file
View File

@@ -0,0 +1,222 @@
package classes
import (
"context"
"errors"
"net/url"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Config keys of the resolver.
const (
// SiteURLConfigKey is the absolute origin of the public site (for
// example https://example.com). Canonical and other absolute URLs are
// built from it, falling back to app.url; never from a request's Host.
SiteURLConfigKey = "golem15.seo.site_url"
// UnprefixedLocaleConfigKey names the locale whose public URLs carry no
// locale prefix; empty leaves the choice to each content type.
UnprefixedLocaleConfigKey = "golem15.seo.unprefixed_locale"
)
// MaxURLBytes caps the URL the resolver accepts.
const MaxURLBytes = 2048
// ErrBadURL is returned for an input that is not a path or an http(s) URL.
var ErrBadURL = errors.New("golem15.seo: url must be a path or an absolute http(s) URL")
// The actions of a resolve answer.
const (
ActionOK = "ok"
ActionNotFound = "not_found"
)
// The sources of a resolved value (Result.Sources).
const (
SourceRule = "rule"
SourceRecord = "record"
SourceTemplate = "template"
SourceGlobal = "global"
SourceContent = "content"
SourceDefault = "default"
)
// Result is the effective SEO of one URL. Action is ok or not_found; Status
// is the HTTP status the frontend should answer with. URL is the absolute
// URL of the page (path and kept query on the site URL). Sources maps each
// value name (title, description, canonical, robots, og_title,
// og_description, og_image) to where it came from.
type Result struct {
Action string `json:"action"`
Status int `json:"status"`
URL string `json:"url"`
Locale string `json:"locale"`
Staging bool `json:"staging"`
SEO *SEO `json:"seo"`
Sources map[string]string `json:"-"`
// TypeKey and RecordID name the record behind the URL, "" when none.
TypeKey string `json:"-"`
RecordID string `json:"-"`
}
// SEO is the head metadata of a resolved page.
type SEO struct {
Title string `json:"title"`
Description string `json:"description"`
Canonical string `json:"canonical"`
Robots string `json:"robots"`
XRobotsTag string `json:"x_robots_tag"`
OG OpenGraph `json:"og"`
Twitter TwitterTag `json:"twitter"`
Verification []MetaTag `json:"verification"`
}
// OpenGraph holds the og: values.
type OpenGraph struct {
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
URL string `json:"url"`
}
// TwitterTag holds the twitter: values.
type TwitterTag struct {
Card string `json:"card"`
Site string `json:"site"`
}
// MetaTag is one verification meta tag.
type MetaTag struct {
Name string `json:"name"`
Content string `json:"content"`
}
// Resolver answers the effective SEO of a URL.
type Resolver struct {
Service *Service
}
// Resolver returns the service's resolver.
func (s *Service) Resolver() *Resolver { return &Resolver{Service: s} }
// SiteURL is the configured public origin without a trailing slash:
// golem15.seo.site_url, else app.url, else "".
func (s *Service) SiteURL() string {
cfg := s.Config()
if cfg == nil {
return ""
}
for _, key := range []string{SiteURLConfigKey, "app.url"} {
if u := strings.TrimRight(strings.TrimSpace(cfg.String(key)), "/"); u != "" {
return u
}
}
return ""
}
// UnprefixedLocale is the configured unprefixed locale, "" when unset.
func (s *Service) UnprefixedLocale() string {
cfg := s.Config()
if cfg == nil {
return ""
}
return strings.TrimSpace(cfg.String(UnprefixedLocaleConfigKey))
}
// parseTarget turns the resolver input into a path and its query. A path
// must start with "/"; an absolute URL must be http or https. Fragments are
// dropped.
func parseTarget(raw string) (string, url.Values, error) {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > MaxURLBytes {
return "", nil, ErrBadURL
}
u, err := url.Parse(raw)
if err != nil {
return "", nil, ErrBadURL
}
if u.IsAbs() {
if u.Scheme != "http" && u.Scheme != "https" {
return "", nil, ErrBadURL
}
} else if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
return "", nil, ErrBadURL
}
path := u.Path
if path == "" {
path = "/"
}
return path, u.Query(), nil
}
// lookup finds the published record behind path; key is "" when none.
func (r *Resolver) lookup(ctx context.Context, path string) (key string, match *pact.ContentMatchResult, rec *pact.ContentRecord, err error) {
reg := r.Service.Registry()
if reg == nil {
return "", nil, nil, nil
}
key, match, err = reg.MatchPath(ctx, path, r.Service.UnprefixedLocale())
if err != nil || match == nil {
return "", nil, nil, err
}
rec, err = reg.Describe(ctx, key, match.ID, r.Service.UnprefixedLocale())
if err != nil {
return "", nil, nil, err
}
if rec == nil || !rec.Published {
return "", nil, nil, nil
}
return key, match, rec, nil
}
// contentLocale returns the record's description in locale, else its first
// locale.
func contentLocale(rec *pact.ContentRecord, locale string) pact.ContentLocale {
if rec == nil || len(rec.Locales) == 0 {
return pact.ContentLocale{Locale: locale}
}
for _, l := range rec.Locales {
if l.Locale == locale {
return l
}
}
return rec.Locales[0]
}
// Resolve answers the effective SEO of rawURL, a path or an absolute URL of
// the site. A path no published record owns answers not_found.
func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) {
path, _, err := parseTarget(rawURL)
if err != nil {
return Result{}, err
}
site := r.Service.SiteURL()
res := Result{Action: ActionNotFound, Status: 404, URL: site + path, Sources: map[string]string{}}
key, match, rec, err := r.lookup(ctx, path)
if err != nil {
return Result{}, err
}
if key == "" {
return res, nil
}
typ, _ := r.Service.Registry().ContentType(key)
db, err := r.Service.DB(ctx)
if err != nil {
return Result{}, err
}
entry, _, err := LoadEntry(db, typ.Content.Model, match.ID, match.Locale)
if err != nil {
return Result{}, err
}
content := contentLocale(rec, match.Locale)
seo := &SEO{Canonical: site + path, Verification: []MetaTag{}}
if entry.Title != "" {
seo.Title, res.Sources["title"] = entry.Title, SourceRecord
} else {
seo.Title, res.Sources["title"] = content.Title, SourceContent
}
res.Action, res.Status, res.Locale, res.SEO = ActionOK, 200, match.Locale, seo
res.TypeKey, res.RecordID = key, match.ID
return res, nil
}

79
classes/service.go Normal file
View File

@@ -0,0 +1,79 @@
// Package classes holds the SEO plugin's logic: the service every writer
// goes through, the entry store and the effective-SEO resolver.
package classes
import (
"context"
"errors"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
// ErrNotBooted is returned when the service has no booted application.
var ErrNotBooted = errors.New("golem15.seo: app is not booted")
// Service is the plugin's single entry point for reads and writes of SEO
// data. The admin extension, the admin controllers, the listeners, the job
// and the API all call it, so every write goes through the same checks and
// history.
type Service struct {
app *backpack.App
}
// NewService returns the service of a booted application. It needs the
// application's database; the signpost registry is optional (without it no
// content type takes part).
func NewService(app *backpack.App) (*Service, error) {
if app == nil {
return nil, ErrNotBooted
}
if db, ok := app.Lookup[*gorm.DB](); !ok || db == nil {
return nil, errors.New("database is not configured")
}
return &Service{app: app}, nil
}
// App returns the booted application.
func (s *Service) App() *backpack.App {
if s == nil {
return nil
}
return s.app
}
// DB returns the write transaction carried by ctx (cabana.TxFromContext),
// else the application's database bound to ctx.
func (s *Service) DB(ctx context.Context) (*gorm.DB, error) {
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
return tx, nil
}
if s == nil || s.app == nil {
return nil, ErrNotBooted
}
db, ok := s.app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("golem15.seo: database is not configured")
}
return db.WithContext(ctx), nil
}
// Registry returns the menu item type registry, nil when none is published.
func (s *Service) Registry() *signpost.Registry {
if s == nil || s.app == nil {
return nil
}
reg, _ := s.app.Lookup[*signpost.Registry]()
return reg
}
// Config returns the application config, nil when there is none.
func (s *Service) Config() *compass.Config {
if s == nil || s.app == nil {
return nil
}
return s.app.Config
}