feat: plugin scaffold, entries and the SEO tab tracer
This commit is contained in:
213
classes/entries.go
Normal file
213
classes/entries.go
Normal 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
222
classes/resolver.go
Normal 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
79
classes/service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user