feat: page registry with its admin screen and signpost type
- golem15_seo_pages (migration 202610280002): the site's own frontend pages
with an address and a name per locale, kind, origin, parent, content date
and note; settings routes_reported_at
- SEO > Pages (golem15.seo.pages) after URL rules: list with kind, origin
and missing filters and the missing row state, form with the P9 partial;
the plugin's form extension saves the address and name per locale and
refuses url_required, url_invalid, url_taken and url_owned
- menu item type seo-page with a content descriptor, so each page gets
the SEO tab, resolves ok and is listed in the sitemaps grouped by kind
(pages, offers, services, landing-pages, titled per locale)
- management API GET/POST /_seo/api/v1/pages, PUT/DELETE /pages/{id} and
the MCP tools seo_list_pages, seo_create_page, seo_update_page,
seo_delete_page (in this commit because the operations parity test maps
every Pages operation to its tool)
This commit is contained in:
@@ -38,6 +38,13 @@ func (*Plugin) Navigation() []pact.NavigationItem {
|
||||
Permissions: []string{controllers.PermissionManage},
|
||||
Controller: controllers.RulesControllerID,
|
||||
},
|
||||
{
|
||||
Code: "pages",
|
||||
Label: "golem15.seo::lang.nav.pages",
|
||||
Icon: "files",
|
||||
Permissions: []string{controllers.PermissionManage},
|
||||
Controller: controllers.PagesControllerID,
|
||||
},
|
||||
{
|
||||
Code: "robots",
|
||||
Label: "golem15.seo::lang.nav.robots",
|
||||
|
||||
733
classes/registry.go
Normal file
733
classes/registry.go
Normal file
@@ -0,0 +1,733 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The page registry (D-33): the site's own frontend pages (home, contact,
|
||||
// offer pages) as a signpost content type of the SEO plugin, so each page
|
||||
// gets the SEO tab, resolves ok and is listed in the sitemaps.
|
||||
const (
|
||||
// PageTypeKey is the registry's menu item type.
|
||||
PageTypeKey = "seo-page"
|
||||
// PagesController is the Pages screen's admin controller.
|
||||
PagesController = "golem15.seo.pages"
|
||||
// PagePattern is the registry type's pattern. A registry page stores
|
||||
// its own address per locale, so the pattern only satisfies signpost.
|
||||
PagePattern = "/:page"
|
||||
// PageLabel is the registry type's label (a content type's label names
|
||||
// its tab in the SEO settings, so it is not the plain "Page" another
|
||||
// content type may use).
|
||||
PageLabel = "golem15.seo::lang.pages.type"
|
||||
)
|
||||
|
||||
// PageModel is the registry page's morph type, the model its SEO entries
|
||||
// and record events name.
|
||||
var PageModel = models.Page{}.MorphName()
|
||||
|
||||
// The route report's limits (P33-20).
|
||||
const (
|
||||
MaxReportedRoutes = 2000
|
||||
MaxRouteBytes = 2048
|
||||
)
|
||||
|
||||
// PageOrigin is the origin of a page created through the management API:
|
||||
// mcp through MCP, else manual.
|
||||
func PageOrigin(ctx context.Context) string {
|
||||
if ViaOf(ctx) == models.ViaMCP {
|
||||
return models.PageOriginMCP
|
||||
}
|
||||
return models.PageOriginManual
|
||||
}
|
||||
|
||||
// The phrase keys of the registry refusals (UI-SPEC C21).
|
||||
const (
|
||||
errPageURLRequired = "golem15.seo::lang.pages.url_required"
|
||||
errPageURLInvalid = "golem15.seo::lang.pages.url_invalid"
|
||||
errPageURLTaken = "golem15.seo::lang.pages.url_taken"
|
||||
errPageURLOwned = "golem15.seo::lang.pages.url_owned"
|
||||
errPageParentLoop = "golem15.seo::lang.pages.parent_loop"
|
||||
errValueInvalid = "golem15.seo::lang.api.value_invalid"
|
||||
)
|
||||
|
||||
// pageGroups are the sitemap file groups of the page kinds (P33-18).
|
||||
var pageGroups = map[string]string{
|
||||
models.PageKindPage: "pages",
|
||||
models.PageKindOffer: "offers",
|
||||
models.PageKindService: "services",
|
||||
models.PageKindLanding: "landing-pages",
|
||||
}
|
||||
|
||||
// PageKindLabels are the phrase keys of the page kinds.
|
||||
var PageKindLabels = map[string]string{
|
||||
models.PageKindPage: "golem15.seo::lang.pages.kind_page",
|
||||
models.PageKindOffer: "golem15.seo::lang.pages.kind_offer",
|
||||
models.PageKindService: "golem15.seo::lang.pages.kind_service",
|
||||
models.PageKindLanding: "golem15.seo::lang.pages.kind_landing",
|
||||
}
|
||||
|
||||
// PageGroup is the sitemap file group of a page kind; an unknown kind is
|
||||
// listed with the pages.
|
||||
func PageGroup(kind string) string {
|
||||
if g, ok := pageGroups[kind]; ok {
|
||||
return g
|
||||
}
|
||||
return pageGroups[models.PageKindPage]
|
||||
}
|
||||
|
||||
// LoadPage returns registry page id; ErrNoRecord when there is none.
|
||||
func LoadPage(db *gorm.DB, id uint) (models.Page, error) {
|
||||
var rows []models.Page
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Page{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.Page{}, ErrNoRecord
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
// pageByID reads a registry page by its id as text, nil when none.
|
||||
func pageByID(db *gorm.DB, id string) (*models.Page, error) {
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64)
|
||||
if err != nil || n == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
p, err := LoadPage(db, uint(n))
|
||||
if errors.Is(err, ErrNoRecord) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// PageAt returns the registry page whose address in any locale is path,
|
||||
// with that locale; nil when none.
|
||||
func (s *Service) PageAt(ctx context.Context, db *gorm.DB, path string) (*models.Page, string, error) {
|
||||
var rows []models.Page
|
||||
if err := db.Where("url = ?", path).Limit(1).Find(&rows).Error; err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
return &rows[0], s.DefaultLocale(ctx, db), nil
|
||||
}
|
||||
var hit []struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
Locale string `gorm:"column:locale"`
|
||||
}
|
||||
if err := db.Raw(`SELECT p.id, e.key AS locale FROM golem15_seo_pages p, jsonb_each_text(p.urls) e WHERE e.value = ? ORDER BY p.id LIMIT 1`, path).Scan(&hit).Error; err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(hit) == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
p, err := LoadPage(db, hit[0].ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if def := s.DefaultLocale(ctx, db); hit[0].Locale == def {
|
||||
return nil, "", nil
|
||||
}
|
||||
return &p, hit[0].Locale, nil
|
||||
}
|
||||
|
||||
// PageRecord describes a registry page as a signpost content record: its
|
||||
// address and name per locale (the default locale first, then the other
|
||||
// enabled locales with an address), its content date as lastmod and its
|
||||
// parent page.
|
||||
func (s *Service) PageRecord(ctx context.Context, db *gorm.DB, p models.Page) pact.ContentRecord {
|
||||
def := s.DefaultLocale(ctx, db)
|
||||
urls := p.LocaleURLs(def)
|
||||
rec := pact.ContentRecord{ID: p.IDText(), Published: true, LastMod: p.UpdatedAt}
|
||||
if p.ContentUpdatedAt != nil && !p.ContentUpdatedAt.IsZero() {
|
||||
day := p.ContentUpdatedAt.Time(time.UTC)
|
||||
rec.LastMod = &day
|
||||
}
|
||||
for _, locale := range s.pageLocales(ctx, db, urls) {
|
||||
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: locale, URL: urls[locale], Title: p.TitleIn(locale)})
|
||||
}
|
||||
if p.ParentID != nil && *p.ParentID != 0 {
|
||||
rec.Parents = []pact.ContentRef{{Type: PageTypeKey, ID: strconv.FormatUint(uint64(*p.ParentID), 10)}}
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// pageLocales orders the locales of urls: the site's locales first (the
|
||||
// default one leading), then any other stored locale by code.
|
||||
func (s *Service) pageLocales(ctx context.Context, db *gorm.DB, urls map[string]string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, l := range s.Locales(ctx, db) {
|
||||
if urls[l] != "" && !seen[l] {
|
||||
out = append(out, l)
|
||||
seen[l] = true
|
||||
}
|
||||
}
|
||||
var rest []string
|
||||
for l := range urls {
|
||||
if !seen[l] {
|
||||
rest = append(rest, l)
|
||||
}
|
||||
}
|
||||
sort.Strings(rest)
|
||||
return append(out, rest...)
|
||||
}
|
||||
|
||||
// PageMenuItemType is the registry's menu item type seo-page: a menu item
|
||||
// may point at a registry page, and its content descriptor lists, describes
|
||||
// and matches the pages for signpost's consumers (the SEO tab, the
|
||||
// resolver, the sitemaps). service is asked on every call.
|
||||
func PageMenuItemType(service func() *Service) pact.MenuItemType {
|
||||
svc := func() (*Service, error) {
|
||||
if service == nil {
|
||||
return nil, ErrNotBooted
|
||||
}
|
||||
s := service()
|
||||
if s == nil {
|
||||
return nil, ErrNotBooted
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
return pact.MenuItemType{
|
||||
Key: PageTypeKey,
|
||||
Label: PageLabel,
|
||||
DefaultPattern: PagePattern,
|
||||
References: func(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
s, err := svc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.pageReferences(ctx)
|
||||
},
|
||||
Resolve: func(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
s, err := svc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.resolvePageItem(ctx, in.Reference)
|
||||
},
|
||||
Content: &pact.ContentDescriptor{
|
||||
Controller: PagesController,
|
||||
Model: PageModel,
|
||||
Kind: pact.ContentKindPage,
|
||||
Records: func(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
s, err := svc()
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
return s.pageRecords(ctx, in)
|
||||
},
|
||||
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
s, err := svc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := pageByID(db, in.ID)
|
||||
if err != nil || p == nil {
|
||||
return nil, err
|
||||
}
|
||||
rec := s.PageRecord(ctx, db, *p)
|
||||
return &rec, nil
|
||||
},
|
||||
Match: func(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||
s, err := svc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, locale, err := s.PageAt(ctx, db, in.Path)
|
||||
if err != nil || p == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pact.ContentMatchResult{ID: p.IDText(), Locale: locale, Exact: true}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pageRecords is one page of registry records by id.
|
||||
func (s *Service) pageRecords(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
after, _ := strconv.ParseUint(strings.TrimSpace(in.After), 10, 64)
|
||||
var rows []models.Page
|
||||
if err := db.Where("id > ? AND url <> ''", after).Order("id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
out := pact.ContentPage{}
|
||||
for _, p := range rows {
|
||||
out.Records = append(out.Records, s.PageRecord(ctx, db, p))
|
||||
}
|
||||
if len(rows) == limit {
|
||||
out.Next = rows[len(rows)-1].IDText()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pageReferences lists the registry pages a menu item can point at, as a
|
||||
// tree by parent, each level by address.
|
||||
func (s *Service) pageReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []models.Page
|
||||
if err := db.Where("url <> ''").Order("url ASC").Limit(5000).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children := map[uint][]models.Page{}
|
||||
known := map[uint]bool{}
|
||||
for _, p := range rows {
|
||||
known[p.ID] = true
|
||||
}
|
||||
for _, p := range rows {
|
||||
parent := p.Parent()
|
||||
if !known[parent] {
|
||||
parent = 0
|
||||
}
|
||||
children[parent] = append(children[parent], p)
|
||||
}
|
||||
visited := map[uint]bool{}
|
||||
var build func(parent uint, depth int) []pact.MenuItemReference
|
||||
build = func(parent uint, depth int) []pact.MenuItemReference {
|
||||
var out []pact.MenuItemReference
|
||||
for _, p := range children[parent] {
|
||||
if visited[p.ID] || depth > 50 {
|
||||
continue
|
||||
}
|
||||
visited[p.ID] = true
|
||||
label := p.Title
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = p.URL
|
||||
}
|
||||
out = append(out, pact.MenuItemReference{Value: p.IDText(), Label: label, Items: build(p.ID, depth+1)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
return build(0, 0), nil
|
||||
}
|
||||
|
||||
// resolvePageItem resolves a menu item pointing at registry page id: its
|
||||
// default-locale address and name, and one alternate per locale.
|
||||
func (s *Service) resolvePageItem(ctx context.Context, id string) (*pact.ResolvedMenuItem, error) {
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := pageByID(db, id)
|
||||
if err != nil || p == nil {
|
||||
return nil, err
|
||||
}
|
||||
rec := s.PageRecord(ctx, db, *p)
|
||||
item := &pact.ResolvedMenuItem{Title: p.Title, URL: p.URL, MTime: rec.LastMod}
|
||||
for _, l := range rec.Locales {
|
||||
item.Alternates = append(item.Alternates, pact.MenuItemAlternate{Locale: l.Locale, URL: l.URL, Title: l.Title})
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// PageWrite is one change of a registry page through the API or MCP: a
|
||||
// nil field is left as it is. URL and Title map locales to text; a locale
|
||||
// sent empty removes that locale's address or name, and the "" key stands
|
||||
// for the default locale. ParentSet with a nil ParentID moves the page to
|
||||
// the top level.
|
||||
type PageWrite struct {
|
||||
URL map[string]string
|
||||
Title map[string]string
|
||||
Kind *string
|
||||
ParentSet bool
|
||||
ParentID *uint
|
||||
ContentUpdatedAt *string
|
||||
Note *string
|
||||
}
|
||||
|
||||
// ApplyPageTexts merges submitted addresses and names into p: def is the
|
||||
// default locale, whose values go to the url and title columns. Every
|
||||
// address is trimmed and normalised with the site's address rules, so it
|
||||
// is stored in the form the resolver matches.
|
||||
func (s *Service) ApplyPageTexts(ctx context.Context, db *gorm.DB, p *models.Page, urls, titles map[string]string) error {
|
||||
def := s.DefaultLocale(ctx, db)
|
||||
settings, err := LoadSettings(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts := NormalizeOptionsOf(settings)
|
||||
if urls != nil {
|
||||
others := map[string]string{}
|
||||
for l, v := range p.URLs.Get() {
|
||||
others[l] = v
|
||||
}
|
||||
for locale, path := range urls {
|
||||
if locale == "" {
|
||||
locale = def
|
||||
}
|
||||
path = normalPagePath(path, opts)
|
||||
if locale == def {
|
||||
p.URL = path
|
||||
continue
|
||||
}
|
||||
if path == "" {
|
||||
delete(others, locale)
|
||||
} else {
|
||||
others[locale] = path
|
||||
}
|
||||
}
|
||||
p.URLs = lagoon.Jsonable[map[string]string]{Data: others, Valid: true}
|
||||
}
|
||||
if titles != nil {
|
||||
others := map[string]string{}
|
||||
for l, v := range p.Titles.Get() {
|
||||
others[l] = v
|
||||
}
|
||||
for locale, title := range titles {
|
||||
title = strings.TrimSpace(title)
|
||||
if locale == "" {
|
||||
locale = def
|
||||
}
|
||||
if locale == def {
|
||||
p.Title = title
|
||||
continue
|
||||
}
|
||||
if title == "" {
|
||||
delete(others, locale)
|
||||
} else {
|
||||
others[locale] = title
|
||||
}
|
||||
}
|
||||
p.Titles = lagoon.Jsonable[map[string]string]{Data: others, Valid: true}
|
||||
}
|
||||
if !p.URLs.Valid {
|
||||
p.URLs = lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}
|
||||
}
|
||||
if !p.Titles.Valid {
|
||||
p.Titles = lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalPagePath trims an address and, when it is a valid path, applies
|
||||
// the site's address rules (lowercase, trailing slash) to it.
|
||||
func normalPagePath(path string, opts NormalizeOptions) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if !ValidPagePath(path) {
|
||||
return path
|
||||
}
|
||||
norm, _ := Normalize(&url.URL{Path: path}, opts)
|
||||
if norm.Path == "" {
|
||||
return path
|
||||
}
|
||||
return norm.Path
|
||||
}
|
||||
|
||||
// ValidPagePath reports whether path is a registry address: a path
|
||||
// starting with a single "/", without a domain, query or fragment, of at
|
||||
// most MaxRouteBytes bytes of valid UTF-8 without control characters.
|
||||
func ValidPagePath(path string) bool {
|
||||
if path == "" || len(path) > MaxRouteBytes || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
|
||||
return false
|
||||
}
|
||||
if !utf8.ValidString(path) || strings.ContainsAny(path, "?#\\ ") {
|
||||
return false
|
||||
}
|
||||
for _, r := range path {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(path)
|
||||
return err == nil && !u.IsAbs() && u.Host == ""
|
||||
}
|
||||
|
||||
// ApplyPageFields sets the scalar fields of w on p; an invalid kind or
|
||||
// date is a problem.
|
||||
func ApplyPageFields(p *models.Page, w PageWrite) map[string][]models.Problem {
|
||||
problems := map[string][]models.Problem{}
|
||||
if w.Kind != nil {
|
||||
p.Kind = strings.TrimSpace(*w.Kind)
|
||||
}
|
||||
if w.ParentSet {
|
||||
p.ParentID = nil
|
||||
if w.ParentID != nil && *w.ParentID != 0 {
|
||||
ref := models.PageRef(*w.ParentID)
|
||||
p.ParentID = &ref
|
||||
}
|
||||
}
|
||||
if w.ContentUpdatedAt != nil {
|
||||
v := strings.TrimSpace(*w.ContentUpdatedAt)
|
||||
if v == "" {
|
||||
p.ContentUpdatedAt = nil
|
||||
} else if d, err := lagoon.ParseDate(v); err == nil {
|
||||
p.ContentUpdatedAt = &d
|
||||
} else {
|
||||
problems["content_updated_at"] = append(problems["content_updated_at"], models.Problem{Key: errValueInvalid})
|
||||
}
|
||||
}
|
||||
if w.Note != nil {
|
||||
p.Note = strings.TrimSpace(*w.Note)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// CheckPage lists the refusals of a registry page before a save (UI-SPEC
|
||||
// C21), keyed by field (url, kind, parent_id): a missing default-locale
|
||||
// address, an invalid address, an address another registry page uses, an
|
||||
// address another content type owns (naming the type and the record), an
|
||||
// unknown kind, and a parent that is unknown or would make a loop.
|
||||
func (s *Service) CheckPage(ctx context.Context, db *gorm.DB, p models.Page) (map[string][]models.Problem, error) {
|
||||
problems := map[string][]models.Problem{}
|
||||
add := func(field string, pr models.Problem) {
|
||||
for _, have := range problems[field] {
|
||||
if have.Key == pr.Key {
|
||||
return
|
||||
}
|
||||
}
|
||||
problems[field] = append(problems[field], pr)
|
||||
}
|
||||
if !models.KnownPageKind(p.Kind) {
|
||||
add("kind", models.Problem{Key: errValueInvalid})
|
||||
}
|
||||
def := s.DefaultLocale(ctx, db)
|
||||
if strings.TrimSpace(p.URL) == "" {
|
||||
add("url", models.Problem{Key: errPageURLRequired})
|
||||
}
|
||||
urls := p.LocaleURLs(def)
|
||||
var paths []string
|
||||
for _, locale := range s.pageLocales(ctx, db, urls) {
|
||||
path := urls[locale]
|
||||
if !ValidPagePath(path) {
|
||||
add("url", models.Problem{Key: errPageURLInvalid})
|
||||
continue
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
if len(paths) > 0 {
|
||||
var taken int64
|
||||
q := db.Model(&models.Page{}).Where(`(url IN ? OR EXISTS (SELECT 1 FROM jsonb_each_text(golem15_seo_pages.urls) e WHERE e.value IN ?))`, paths, paths)
|
||||
if p.ID != 0 {
|
||||
q = q.Where("id <> ?", p.ID)
|
||||
}
|
||||
if err := q.Count(&taken).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken > 0 {
|
||||
add("url", models.Problem{Key: errPageURLTaken})
|
||||
}
|
||||
for _, path := range paths {
|
||||
owner, err := s.ownerOutsideRegistry(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner != nil {
|
||||
add("url", *owner)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if parent := p.Parent(); parent != 0 {
|
||||
loop, known, err := parentLoop(db, p.ID, parent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case !known:
|
||||
add("parent_id", models.Problem{Key: errValueInvalid})
|
||||
case loop:
|
||||
add("parent_id", models.Problem{Key: errPageParentLoop})
|
||||
}
|
||||
}
|
||||
return problems, nil
|
||||
}
|
||||
|
||||
// parentLoop walks up from parent: loop is true when it reaches id (or
|
||||
// goes round), known false when parent does not exist.
|
||||
func parentLoop(db *gorm.DB, id, parent uint) (loop, known bool, err error) {
|
||||
seen := map[uint]bool{}
|
||||
cur := parent
|
||||
for depth := 0; cur != 0; depth++ {
|
||||
if (id != 0 && cur == id) || seen[cur] || depth > 100 {
|
||||
return true, true, nil
|
||||
}
|
||||
seen[cur] = true
|
||||
p, err := LoadPage(db, cur)
|
||||
if errors.Is(err, ErrNoRecord) {
|
||||
return false, depth > 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if p.Parent() == 0 {
|
||||
return false, true, nil
|
||||
}
|
||||
cur = p.Parent()
|
||||
}
|
||||
return false, true, nil
|
||||
}
|
||||
|
||||
// ownerOutsideRegistry returns the url_owned refusal when a content type
|
||||
// other than the registry owns path exactly: the type's label and the
|
||||
// record's title as params. nil when none does.
|
||||
func (s *Service) ownerOutsideRegistry(ctx context.Context, path string) (*models.Problem, error) {
|
||||
reg := s.Registry()
|
||||
if reg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
unprefixed := s.UnprefixedLocale()
|
||||
for _, typ := range reg.ContentTypes() {
|
||||
if typ.Key == PageTypeKey || typ.Content == nil || typ.Content.Match == nil {
|
||||
continue
|
||||
}
|
||||
got, err := typ.Content.Match(ctx, pact.ContentMatch{Path: trimSlash(path), Pattern: reg.Pattern(typ.Key, ""), UnprefixedLocale: unprefixed})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if got == nil || !got.Exact {
|
||||
continue
|
||||
}
|
||||
title := path
|
||||
if rec, err := reg.Describe(ctx, typ.Key, got.ID, unprefixed); err == nil && rec != nil {
|
||||
if l := contentLocale(rec, got.Locale); strings.TrimSpace(l.Title) != "" {
|
||||
title = l.Title
|
||||
}
|
||||
}
|
||||
label := typ.Label
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = typ.Key
|
||||
}
|
||||
return &models.Problem{Key: errPageURLOwned, Params: map[string]string{"type": s.Phrase(label, nil), "title": title}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// PageValues are a registry page's values for history.
|
||||
func PageValues(p models.Page) map[string]any {
|
||||
parent := any(nil)
|
||||
if p.Parent() != 0 {
|
||||
parent = p.Parent()
|
||||
}
|
||||
date := ""
|
||||
if p.ContentUpdatedAt != nil {
|
||||
date = p.ContentUpdatedAt.String()
|
||||
}
|
||||
return map[string]any{
|
||||
"url": p.URL, "urls": plainValue(p.URLs.Get()), "title": p.Title, "titles": plainValue(p.Titles.Get()),
|
||||
"kind": p.Kind, "origin": p.Origin, "parent_id": parent, "content_updated_at": date, "note": p.Note,
|
||||
}
|
||||
}
|
||||
|
||||
// pageWhat is a registry page's history description.
|
||||
func (s *Service) pageWhat(p models.Page) string {
|
||||
return s.Phrase("golem15.seo::lang.history.what_page", map[string]string{"url": p.URL})
|
||||
}
|
||||
|
||||
// RecordPage records a registry page's change in history.
|
||||
func (s *Service) RecordPage(ctx context.Context, tx *gorm.DB, before map[string]any, p models.Page, deleted bool) error {
|
||||
c := Change{Kind: models.SubjectPage, SubjectID: p.IDText(), What: s.pageWhat(p), Before: before}
|
||||
if !deleted {
|
||||
c.After = PageValues(p)
|
||||
}
|
||||
return s.Record(ctx, tx, c)
|
||||
}
|
||||
|
||||
// SavePage checks and writes a registry page inside tx, records it in
|
||||
// history, and fires the record event after commit, so the SEO tab's
|
||||
// address tracking and the sitemap follow as they do for an admin save.
|
||||
func (s *Service) SavePage(ctx context.Context, tx *gorm.DB, p *models.Page, before map[string]any) error {
|
||||
problems, err := s.CheckPage(ctx, tx, *p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &ProblemsError{Problems: problems}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
created := p.ID == 0
|
||||
if created {
|
||||
p.CreatedAt = &now
|
||||
}
|
||||
p.UpdatedAt = &now
|
||||
if err := tx.Save(p).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.RecordPage(ctx, tx, before, *p, false); err != nil {
|
||||
return err
|
||||
}
|
||||
cabana.EmitRecordSaved(ctx, s.app, tx, pact.RecordSaved{Controller: PagesController, Model: PageModel, ID: p.IDText(), Created: created})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePage deletes a registry page inside tx, records it in history and
|
||||
// fires the record event after commit: the SEO plugin then deletes the
|
||||
// page's SEO entries and the sitemap drops its addresses.
|
||||
func (s *Service) DeletePage(ctx context.Context, tx *gorm.DB, p models.Page) error {
|
||||
if err := tx.Delete(&models.Page{}, p.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.RecordPage(ctx, tx, PageValues(p), p, true); err != nil {
|
||||
return err
|
||||
}
|
||||
cabana.EmitRecordDeleted(ctx, s.app, tx, pact.RecordDeleted{Controller: PagesController, Model: PageModel, ID: p.IDText()})
|
||||
return nil
|
||||
}
|
||||
|
||||
// PageMissing reports whether p is a reported page that the newest route
|
||||
// report (at reported) did not name.
|
||||
func PageMissing(p models.Page, reported *time.Time) bool {
|
||||
if p.Origin != models.PageOriginReported || reported == nil {
|
||||
return false
|
||||
}
|
||||
return p.ReportedAt == nil || p.ReportedAt.Before(*reported)
|
||||
}
|
||||
|
||||
// pageKinds reads the kind of each registry page id (as text).
|
||||
func pageKinds(db *gorm.DB, ids []string) (map[string]string, error) {
|
||||
var nums []uint64
|
||||
for _, id := range ids {
|
||||
if n, err := strconv.ParseUint(id, 10, 64); err == nil {
|
||||
nums = append(nums, n)
|
||||
}
|
||||
}
|
||||
out := map[string]string{}
|
||||
if len(nums) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID uint64 `gorm:"column:id"`
|
||||
Kind string `gorm:"column:kind"`
|
||||
}
|
||||
if err := db.Raw(`SELECT id, kind FROM golem15_seo_pages WHERE id IN ?`, nums).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
out[strconv.FormatUint(r.ID, 10)] = r.Kind
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -51,19 +51,60 @@ func (f SitemapFilter) Decide(ctx context.Context, batch []contract.Candidate) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pageIDs []string
|
||||
for _, c := range batch {
|
||||
if c.TypeKey == PageTypeKey {
|
||||
pageIDs = append(pageIDs, c.RecordID)
|
||||
}
|
||||
}
|
||||
kinds, err := pageKinds(b.db, pageIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, c := range batch {
|
||||
d, err := b.decide(ctx, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.TypeKey == PageTypeKey && !d.Exclude {
|
||||
d.Group = PageGroup(kinds[c.RecordID])
|
||||
}
|
||||
out[i] = d
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Groups lists the file groups the decisions name.
|
||||
func (f SitemapFilter) Groups(context.Context) ([]contract.Group, error) {
|
||||
return nil, nil
|
||||
// Groups lists the file groups the decisions name: the registry's page
|
||||
// kinds (pages, offers, services, landing-pages), titled in every locale
|
||||
// of the site with the kind's name (P33-18).
|
||||
func (f SitemapFilter) Groups(ctx context.Context) ([]contract.Group, error) {
|
||||
var svc *Service
|
||||
if f.Service != nil {
|
||||
svc = f.Service()
|
||||
}
|
||||
if svc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := svc.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locales := svc.Locales(ctx, db)
|
||||
tr := svc.translator()
|
||||
out := make([]contract.Group, 0, len(models.PageKinds))
|
||||
for _, kind := range models.PageKinds {
|
||||
titles := map[string]string{}
|
||||
key := PageKindLabels[kind]
|
||||
for _, locale := range locales {
|
||||
if tr == nil {
|
||||
titles[locale] = key
|
||||
continue
|
||||
}
|
||||
titles[locale] = tr.GetIn(locale, key, nil)
|
||||
}
|
||||
out = append(out, contract.Group{Key: PageGroup(kind), Titles: titles})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newFilterBatch reads the settings and the redirects once for a batch.
|
||||
|
||||
@@ -32,6 +32,7 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
redirectsAdminController{app: app},
|
||||
notFoundAdminController{app: app},
|
||||
rulesAdminController{app: app},
|
||||
pagesAdminController{app: app},
|
||||
robotsAdminController{app: app},
|
||||
historyAdminController{app: app},
|
||||
transferAdminController{app: app},
|
||||
|
||||
298
controllers/api/registry.go
Normal file
298
controllers/api/registry.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListPagesInput is the query of GET /_seo/api/v1/pages.
|
||||
type ListPagesInput struct {
|
||||
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
|
||||
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"`
|
||||
Search string `json:"search,omitempty" query:"search" jsonschema:"text the address, another locale's address or the name contains"`
|
||||
ID uint `json:"id,omitempty" query:"id" jsonschema:"one page by id"`
|
||||
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"page, offer, service or landing"`
|
||||
Origin string `json:"origin,omitempty" query:"origin" jsonschema:"manual, reported or mcp"`
|
||||
Missing string `json:"missing,omitempty" query:"missing" jsonschema:"only: reported pages the site's newest route report did not include; hide: all others"`
|
||||
}
|
||||
|
||||
// PageIDInput names one registry page.
|
||||
type PageIDInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the registry page id"`
|
||||
}
|
||||
|
||||
// CreatePageInput is the body of POST /_seo/api/v1/pages.
|
||||
type CreatePageInput struct {
|
||||
URL map[string]string `json:"url" jsonschema:"the page's path per locale code, e.g. {\"pl\": \"/kontakt\", \"en\": \"/contact\"}; the default locale is required, a locale left out or empty has no version of the page; a plain string is the default locale's path"`
|
||||
Title map[string]string `json:"title,omitempty" jsonschema:"the page's name per locale code (default SEO title, HTML sitemap, breadcrumbs); a plain string is the default locale's"`
|
||||
Kind *string `json:"kind,omitempty" jsonschema:"page (the default), offer, service or landing: decides the sitemap file and the default structured data"`
|
||||
ParentID *uint `json:"parent_id,omitempty" jsonschema:"the parent registry page's id (breadcrumbs); null or 0 for the top level"`
|
||||
ContentUpdatedAt *string `json:"content_updated_at,omitempty" jsonschema:"the date the page's content last changed, YYYY-MM-DD (sitemap lastmod); today by default"`
|
||||
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
||||
}
|
||||
|
||||
// UpdatePageInput is PUT /_seo/api/v1/pages/{id}: only the fields sent are
|
||||
// changed; in url and title only the locales sent.
|
||||
type UpdatePageInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the registry page id"`
|
||||
URL map[string]string `json:"url,omitempty" jsonschema:"paths per locale code to change; an empty path removes that locale's version (the default locale's cannot be removed)"`
|
||||
Title map[string]string `json:"title,omitempty" jsonschema:"names per locale code to change"`
|
||||
Kind *string `json:"kind,omitempty" jsonschema:"page, offer, service or landing"`
|
||||
ParentID *uint `json:"parent_id,omitempty" jsonschema:"the parent registry page's id; null or 0 for the top level"`
|
||||
ContentUpdatedAt *string `json:"content_updated_at,omitempty" jsonschema:"YYYY-MM-DD; empty clears it"`
|
||||
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
||||
}
|
||||
|
||||
// pageBody is a decoded create or update body; a translatable value is a
|
||||
// locale map or a plain string (the default locale's).
|
||||
type pageBody struct {
|
||||
write classes.PageWrite
|
||||
}
|
||||
|
||||
func (b *pageBody) UnmarshalJSON(raw []byte) error {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
texts := func(name string) (map[string]string, error) {
|
||||
v, ok := fields[name]
|
||||
if !ok || bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
|
||||
return nil, nil
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(v, &s) == nil {
|
||||
return map[string]string{"": s}, nil
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(v, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
var err error
|
||||
if b.write.URL, err = texts("url"); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.write.Title, err = texts("title"); err != nil {
|
||||
return err
|
||||
}
|
||||
str := func(name string) (*string, error) {
|
||||
v, ok := fields[name]
|
||||
if !ok || bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
|
||||
return nil, nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(v, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
if b.write.Kind, err = str("kind"); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.write.ContentUpdatedAt, err = str("content_updated_at"); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.write.Note, err = str("note"); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, ok := fields["parent_id"]; ok {
|
||||
b.write.ParentSet = true
|
||||
if !bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
|
||||
var n uint
|
||||
if err := json.Unmarshal(v, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 0 {
|
||||
b.write.ParentID = &n
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PageJSON is a registry page in an answer: its default-locale address and
|
||||
// name, every locale's address and name, and whether the newest route
|
||||
// report missed it.
|
||||
func PageJSON(p models.Page, def string, reported *bool) map[string]any {
|
||||
urls := p.LocaleURLs(def)
|
||||
titles := map[string]string{def: p.Title}
|
||||
for l, t := range p.Titles.Get() {
|
||||
if l != def && t != "" {
|
||||
titles[l] = t
|
||||
}
|
||||
}
|
||||
var parent any
|
||||
if p.Parent() != 0 {
|
||||
parent = p.Parent()
|
||||
}
|
||||
var date any
|
||||
if p.ContentUpdatedAt != nil && !p.ContentUpdatedAt.IsZero() {
|
||||
date = p.ContentUpdatedAt.String()
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": p.ID, "url": p.URL, "urls": urls, "title": p.Title, "titles": titles,
|
||||
"kind": p.Kind, "origin": p.Origin, "parent_id": parent, "content_updated_at": date,
|
||||
"reported_at": p.ReportedAt, "note": p.Note, "created_at": p.CreatedAt, "updated_at": p.UpdatedAt,
|
||||
}
|
||||
if reported != nil {
|
||||
out["missing"] = *reported
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pageAnswer is a page in an answer with its missing state.
|
||||
func pageAnswer(ctx context.Context, svc *classes.Service, db *gorm.DB, p models.Page) (map[string]any, error) {
|
||||
settings, err := classes.LoadSettings(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
missing := classes.PageMissing(p, settings.RoutesReportedAt)
|
||||
return PageJSON(p, svc.DefaultLocale(ctx, db), &missing), nil
|
||||
}
|
||||
|
||||
// ListPages serves GET /_seo/api/v1/pages: the registry by address,
|
||||
// filtered by search text, id, kind, origin and the missing state.
|
||||
func ListPages(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
db, err := read(ctx, svc)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
in := listParams(r)
|
||||
query := r.URL.Query()
|
||||
q := db.Model(&models.Page{}).Where("url <> ''").Order("url ASC, id ASC")
|
||||
if in.Search != "" {
|
||||
p := like(in.Search)
|
||||
q = q.Where(`lower(url) LIKE ? OR lower(title) LIKE ? OR lower(urls::text) LIKE ? OR lower(titles::text) LIKE ?`, p, p, p, p)
|
||||
}
|
||||
if id, err := strconv.ParseUint(query.Get("id"), 10, 64); err == nil && id > 0 {
|
||||
q = q.Where("id = ?", id)
|
||||
}
|
||||
for param, scope := range map[string]string{"kind": models.FilterPageKind, "origin": models.FilterPageOrigin, "missing": models.FilterPageMissing} {
|
||||
if v := strings.TrimSpace(query.Get(param)); v != "" {
|
||||
q = models.Page{}.FilterScope(scope, q, v)
|
||||
}
|
||||
}
|
||||
var rows []models.Page
|
||||
total, err := paginate(q, in, &rows)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
settings, err := classes.LoadSettings(db)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
def := svc.DefaultLocale(ctx, db)
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
missing := classes.PageMissing(row, settings.RoutesReportedAt)
|
||||
out = append(out, PageJSON(row, def, &missing))
|
||||
}
|
||||
return http.StatusOK, listBody(out, in, total), nil
|
||||
})
|
||||
}
|
||||
|
||||
// savePage applies a body to p and saves it in tx.
|
||||
func savePage(ctx context.Context, svc *classes.Service, tx *gorm.DB, p *models.Page, w classes.PageWrite, before map[string]any) error {
|
||||
problems := classes.ApplyPageFields(p, w)
|
||||
if len(problems) > 0 {
|
||||
return &classes.ProblemsError{Problems: problems}
|
||||
}
|
||||
if err := svc.ApplyPageTexts(ctx, tx, p, w.URL, w.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.SavePage(ctx, tx, p, before)
|
||||
}
|
||||
|
||||
// CreatePage serves POST /_seo/api/v1/pages: 201 with the page, 422
|
||||
// validation_failed with the C21 refusals (url_required, url_invalid,
|
||||
// url_taken, url_owned, parent_loop). The origin is mcp through MCP.
|
||||
func CreatePage(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
var body pageBody
|
||||
if err := decode(r, maxBody, &body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
row := models.NewPage()
|
||||
row.Origin = classes.PageOrigin(ctx)
|
||||
var answer map[string]any
|
||||
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := savePage(ctx, svc, tx, &row, body.write, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
answer, err = pageAnswer(ctx, svc, tx, row)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusCreated, map[string]any{"data": answer}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePage serves PUT /_seo/api/v1/pages/{id}: the fields sent change
|
||||
// and are checked like a create.
|
||||
func UpdatePage(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var body pageBody
|
||||
if err := decode(r, maxBody, &body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var answer map[string]any
|
||||
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
row, err := classes.LoadPage(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := savePage(ctx, svc, tx, &row, body.write, classes.PageValues(row)); err != nil {
|
||||
return err
|
||||
}
|
||||
answer, err = pageAnswer(ctx, svc, tx, row)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusOK, map[string]any{"data": answer}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeletePage serves DELETE /_seo/api/v1/pages/{id}: the page and its SEO
|
||||
// entries go (history keeps them); a page the site reports comes back with
|
||||
// its next route report.
|
||||
func DeletePage(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
row, err := classes.LoadPage(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.DeletePage(ctx, tx, row)
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusOK, map[string]any{"deleted": id}, nil
|
||||
})
|
||||
}
|
||||
287
controllers/pages.go
Normal file
287
controllers/pages.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PagesControllerID is the Pages screen, the page registry (UI-SPEC C21).
|
||||
const PagesControllerID = classes.PagesController
|
||||
|
||||
// pagesAdminController lists and edits the page registry (D-33). The
|
||||
// address and the name per locale, their refusals and history come from
|
||||
// the plugin's own form extension on this controller, which runs inside
|
||||
// the save transaction once the row is written; the SEO tab is added
|
||||
// because the registry is the content type seo-page.
|
||||
type pagesAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (pagesAdminController) ID() string { return PagesControllerID }
|
||||
func (pagesAdminController) ModelName() string { return `Golem15\SEO\Models\Page` }
|
||||
func (pagesAdminController) ConfigDir() string { return "controllers/pages" }
|
||||
func (pagesAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionManage}
|
||||
}
|
||||
|
||||
// NewRecord is a page with the form's defaults: kind page, entered by
|
||||
// hand, its content changed today.
|
||||
func (pagesAdminController) NewRecord() any {
|
||||
p := models.NewPage()
|
||||
return &p
|
||||
}
|
||||
|
||||
// FormBeforeCreate dates a page without a content date today and refuses
|
||||
// an unknown kind or a parent that is unknown or makes a loop.
|
||||
func (c pagesAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
p, ok := model.(*models.Page)
|
||||
if !ok || p == nil {
|
||||
return nil
|
||||
}
|
||||
if p.ContentUpdatedAt == nil || p.ContentUpdatedAt.IsZero() {
|
||||
today := lagoon.DateOf(time.Now().UTC())
|
||||
p.ContentUpdatedAt = &today
|
||||
}
|
||||
if p.Origin == "" {
|
||||
p.Origin = models.PageOriginManual
|
||||
}
|
||||
return c.check(ctx, p)
|
||||
}
|
||||
|
||||
// FormBeforeUpdate checks an update's kind and parent.
|
||||
func (c pagesAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
p, ok := model.(*models.Page)
|
||||
if !ok || p == nil {
|
||||
return nil
|
||||
}
|
||||
return c.check(ctx, p)
|
||||
}
|
||||
|
||||
// check refuses the owner fields' problems (kind, parent_id); the address
|
||||
// is checked by the form extension, which receives it.
|
||||
func (c pagesAdminController) check(ctx context.Context, p *models.Page) error {
|
||||
if p.ParentID != nil && *p.ParentID == 0 {
|
||||
p.ParentID = nil
|
||||
}
|
||||
svc, err := classes.NewService(appOf(c.app))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := database(ctx, c.app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
problems, err := svc.CheckPage(ctx, db, *p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owner := map[string][]models.Problem{}
|
||||
for _, field := range []string{"kind", "parent_id"} {
|
||||
if list := problems[field]; len(list) > 0 {
|
||||
owner[field] = list
|
||||
}
|
||||
}
|
||||
return problemsError(ctx, translator(c.app), owner)
|
||||
}
|
||||
|
||||
// FormAfterDelete records the deleted page in history inside the delete's
|
||||
// transaction; the record event then deletes its SEO entries.
|
||||
func (c pagesAdminController) FormAfterDelete(ctx context.Context, model any) error {
|
||||
p, ok := model.(*models.Page)
|
||||
if !ok || p == nil {
|
||||
return nil
|
||||
}
|
||||
svc, err := classes.NewService(appOf(c.app))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := database(ctx, c.app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.RecordPage(ctx, db, classes.PageValues(*p), *p, true)
|
||||
}
|
||||
|
||||
// reportedAt is the settings' newest route report time, nil when none.
|
||||
func reportedAt(db *gorm.DB) (*time.Time, error) {
|
||||
var rows []models.Settings
|
||||
if err := db.Select("id", "routes_reported_at").Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return rows[0].RoutesReportedAt, nil
|
||||
}
|
||||
|
||||
// ListRowStates marks a reported page that the newest route report did not
|
||||
// name (UI-SPEC C21): negative, labelled pages.state_missing. The state is
|
||||
// read from the table, since ListExtendRecords shows the origin as its
|
||||
// label.
|
||||
func (pagesAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
|
||||
var ids []uint
|
||||
for _, record := range records {
|
||||
if p, ok := record.(*models.Page); ok && p != nil {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
}
|
||||
missing := map[uint]bool{}
|
||||
if len(ids) > 0 {
|
||||
var rows []uint
|
||||
if err := db.WithContext(ctx).Model(&models.Page{}).Where("id IN ?", ids).Where(models.MissingSQL).Pluck("id", &rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range rows {
|
||||
missing[id] = true
|
||||
}
|
||||
}
|
||||
out := make([][]pact.RowState, len(records))
|
||||
for i, record := range records {
|
||||
if p, ok := record.(*models.Page); ok && p != nil && missing[p.ID] {
|
||||
out[i] = []pact.RowState{pact.RowStateNegative}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pageKindLabels and pageOriginLabels are the phrase keys of the kinds and
|
||||
// origins.
|
||||
var (
|
||||
pageKindLabels = classes.PageKindLabels
|
||||
pageOriginLabels = map[string]string{
|
||||
models.PageOriginManual: "golem15.seo::lang.pages.origin_manual",
|
||||
models.PageOriginReported: "golem15.seo::lang.pages.origin_reported",
|
||||
models.PageOriginMCP: "golem15.seo::lang.pages.origin_mcp",
|
||||
}
|
||||
)
|
||||
|
||||
// FilterOptions serves the choices of the kind, origin and missing
|
||||
// filters.
|
||||
func (pagesAdminController) FilterOptions(scope string) []pact.Option {
|
||||
switch scope {
|
||||
case models.FilterPageKind:
|
||||
out := make([]pact.Option, 0, len(models.PageKinds))
|
||||
for _, k := range models.PageKinds {
|
||||
out = append(out, pact.Option{Value: k, Label: pageKindLabels[k]})
|
||||
}
|
||||
return out
|
||||
case models.FilterPageOrigin:
|
||||
out := make([]pact.Option, 0, len(models.PageOrigins))
|
||||
for _, o := range models.PageOrigins {
|
||||
out = append(out, pact.Option{Value: o, Label: pageOriginLabels[o]})
|
||||
}
|
||||
return out
|
||||
case models.FilterPageMissing:
|
||||
return []pact.Option{
|
||||
{Value: models.MissingOnly, Label: "golem15.seo::lang.pages.filter_missing_only"},
|
||||
{Value: models.MissingHide, Label: "golem15.seo::lang.pages.filter_missing_hide"},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListExtendRecords shows the kind and the origin of the list's rows as
|
||||
// their labels in the request locale. The rows are list values only and
|
||||
// never saved; the columns still sort by the stored values.
|
||||
func (c pagesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
|
||||
tr := translator(c.app)
|
||||
for _, record := range records {
|
||||
p, ok := record.(*models.Page)
|
||||
if !ok || p == nil {
|
||||
continue
|
||||
}
|
||||
if key, ok := pageKindLabels[p.Kind]; ok {
|
||||
p.Kind = Phrase(ctx, tr, key, nil)
|
||||
}
|
||||
if key, ok := pageOriginLabels[p.Origin]; ok {
|
||||
p.Origin = Phrase(ctx, tr, key, nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DropdownOptions serves the parent dropdown: the registry pages by name
|
||||
// (their address when they have none).
|
||||
func (c pagesAdminController) DropdownOptions(field string) []pact.Option {
|
||||
if field != "parent_id" {
|
||||
return nil
|
||||
}
|
||||
db, err := database(context.Background(), c.app)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var rows []models.Page
|
||||
if err := db.Where("url <> ''").Order("title ASC, url ASC").Limit(1000).Find(&rows).Error; err != nil {
|
||||
log.Warn("golem15.seo: parent options", "error", err)
|
||||
return nil
|
||||
}
|
||||
out := make([]pact.Option, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
label := p.Title
|
||||
if label == "" {
|
||||
label = p.URL
|
||||
} else {
|
||||
label += " (" + p.URL + ")"
|
||||
}
|
||||
out = append(out, pact.Option{Value: p.IDText(), Label: label})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pageStatusView is the view model of P9 page_status: the reported or
|
||||
// missing line, nothing for a page entered by hand.
|
||||
type pageStatusView struct {
|
||||
Text string
|
||||
Warning bool
|
||||
}
|
||||
|
||||
// PartialData serves P9 page_status (UI-SPEC C21).
|
||||
func (c pagesAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
|
||||
if name != "page_status" {
|
||||
return nil, nil
|
||||
}
|
||||
p, ok := record.(*models.Page)
|
||||
if !ok || p == nil || p.Origin != models.PageOriginReported {
|
||||
return pageStatusView{}, nil
|
||||
}
|
||||
db, err := database(ctx, c.app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reported, err := reportedAt(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := translator(c.app)
|
||||
stamp := func(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "—"
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
if classes.PageMissing(*p, reported) {
|
||||
return pageStatusView{Text: Phrase(ctx, tr, "golem15.seo::lang.pages.status_missing", map[string]string{"time": stamp(reported)}), Warning: true}, nil
|
||||
}
|
||||
return pageStatusView{Text: Phrase(ctx, tr, "golem15.seo::lang.pages.status_reported", map[string]string{"time": stamp(p.ReportedAt)})}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = pagesAdminController{}
|
||||
_ pact.AdminPermissioned = pagesAdminController{}
|
||||
_ pact.AdminRecordSource = pagesAdminController{}
|
||||
_ pact.FormBeforeCreate = pagesAdminController{}
|
||||
_ pact.FormBeforeUpdate = pagesAdminController{}
|
||||
_ pact.FormAfterDelete = pagesAdminController{}
|
||||
_ pact.ListRowStates = pagesAdminController{}
|
||||
_ pact.FilterOptions = pagesAdminController{}
|
||||
_ pact.ListExtendRecords = pagesAdminController{}
|
||||
_ pact.DropdownOptionsProvider = pagesAdminController{}
|
||||
_ pact.AdminPartialData = pagesAdminController{}
|
||||
)
|
||||
7
controllers/pages/_page_status.htm
Normal file
7
controllers/pages/_page_status.htm
Normal file
@@ -0,0 +1,7 @@
|
||||
{{- with .Data -}}
|
||||
{{- if .Text -}}
|
||||
<div class="summer-callout{{ if .Warning }} summer-callout--warning{{ end }}" role="status">
|
||||
<p class="summer-callout__text">{{ .Text }}</p>
|
||||
</div>
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
18
controllers/pages/config_filter.yaml
Normal file
18
controllers/pages/config_filter.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# Pages filters (UI-SPEC C21): kind, origin and missing from the newest
|
||||
# route report.
|
||||
scopes:
|
||||
kind:
|
||||
label: golem15.seo::lang.pages.kind
|
||||
modelClass: Golem15\SEO\Models\Page
|
||||
nameFrom: kind
|
||||
scope: FilterPageKind
|
||||
origin:
|
||||
label: golem15.seo::lang.pages.origin
|
||||
modelClass: Golem15\SEO\Models\Page
|
||||
nameFrom: origin
|
||||
scope: FilterPageOrigin
|
||||
missing:
|
||||
label: golem15.seo::lang.pages.filter_missing
|
||||
modelClass: Golem15\SEO\Models\Page
|
||||
nameFrom: url
|
||||
scope: FilterPageMissing
|
||||
20
controllers/pages/config_form.yaml
Normal file
20
controllers/pages/config_form.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Page form (UI-SPEC C21). The address and the name per locale come from
|
||||
# the plugin's own form extension on this controller; the SEO tab is added
|
||||
# because the registry is a content type (seo-page).
|
||||
name: golem15.seo::lang.pages.name
|
||||
form: ~/plugins/golem15/seo/models/page/fields.yaml
|
||||
modelClass: Golem15\SEO\Models\Page
|
||||
defaultRedirect: golem15/seo/pages
|
||||
|
||||
create:
|
||||
redirect: golem15/seo/pages/update/:id
|
||||
redirectClose: golem15/seo/pages
|
||||
|
||||
update:
|
||||
redirect: golem15/seo/pages
|
||||
redirectClose: golem15/seo/pages
|
||||
|
||||
messages:
|
||||
create: golem15.seo::lang.pages.new
|
||||
update: golem15.seo::lang.pages.name
|
||||
deleteConfirm: golem15.seo::lang.pages.delete_confirm
|
||||
26
controllers/pages/config_list.yaml
Normal file
26
controllers/pages/config_list.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Pages list (UI-SPEC C21): the page registry by address; reported pages
|
||||
# missing from the newest route report are marked.
|
||||
list: ~/plugins/golem15/seo/models/page/columns.yaml
|
||||
modelClass: Golem15\SEO\Models\Page
|
||||
title: golem15.seo::lang.pages.list_title
|
||||
recordUrl: golem15/seo/pages/update/:id
|
||||
noRecordsMessage: golem15.seo::lang.pages.list_empty
|
||||
recordsPerPage: 20
|
||||
showCheckboxes: true
|
||||
showSetup: true
|
||||
filter: config_filter.yaml
|
||||
|
||||
defaultSort:
|
||||
column: url
|
||||
direction: asc
|
||||
|
||||
toolbar:
|
||||
buttons: [create, delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
messages:
|
||||
create: golem15.seo::lang.pages.new
|
||||
empty: golem15.seo::lang.pages.list_empty
|
||||
deleteConfirm: golem15.seo::lang.pages.delete_selected_confirm
|
||||
rowStateNegative: golem15.seo::lang.pages.state_missing
|
||||
@@ -23,7 +23,10 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
var out []pact.AdminActionExtension
|
||||
// The Pages form's address and name come first: the SEO tab of a
|
||||
// registry page (added below, the registry being a content type) reads
|
||||
// the address its save wrote.
|
||||
out := []pact.AdminActionExtension{{Controller: controllers.PagesControllerID, Form: p.pagesForm()}}
|
||||
types := reg.ContentTypes()
|
||||
for _, typ := range types {
|
||||
if typ.Content == nil || strings.TrimSpace(typ.Content.Controller) == "" {
|
||||
|
||||
142
extensions_pages.go
Normal file
142
extensions_pages.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// pagesFormYAML adds the translatable address and name to the Pages form
|
||||
// (UI-SPEC C21): url after page_status, title after kind.
|
||||
const pagesFormYAML = `fields:
|
||||
url:
|
||||
label: golem15.seo::lang.pages.url
|
||||
comment: golem15.seo::lang.pages.url_comment
|
||||
type: mltext
|
||||
span: left
|
||||
after: page_status
|
||||
title:
|
||||
label: golem15.seo::lang.pages.title
|
||||
comment: golem15.seo::lang.pages.title_comment
|
||||
type: mltext
|
||||
span: left
|
||||
after: kind
|
||||
`
|
||||
|
||||
// pagesForm is the plugin's form extension on its own Pages controller:
|
||||
// it loads and saves a page's address and name per locale (the default
|
||||
// locale in the url and title columns, the others in urls and titles),
|
||||
// refuses a page with the C21 copy (url_required, url_invalid, url_taken,
|
||||
// url_owned) inside the save transaction, and records the save in
|
||||
// history. It is listed before the SEO tab, so the tab's address tracking
|
||||
// sees the saved address.
|
||||
func (p *Plugin) pagesForm() *pact.AdminFormExtension {
|
||||
return &pact.AdminFormExtension{
|
||||
FieldsYAML: []byte(pagesFormYAML),
|
||||
Permissions: []string{controllers.PermissionManage},
|
||||
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
|
||||
svc := p.Service()
|
||||
if svc == nil {
|
||||
return nil, classes.ErrNotBooted
|
||||
}
|
||||
page, ok := in.Record.(*models.Page)
|
||||
if !ok || page == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
db := in.DB.WithContext(ctx)
|
||||
def := svc.DefaultLocale(ctx, db)
|
||||
urls := map[string]string{}
|
||||
titles := map[string]string{}
|
||||
for _, locale := range svc.Locales(ctx, db) {
|
||||
urls[locale], titles[locale] = "", ""
|
||||
}
|
||||
for locale, path := range page.LocaleURLs(def) {
|
||||
urls[locale] = path
|
||||
}
|
||||
titles[def] = page.Title
|
||||
for locale, title := range page.Titles.Get() {
|
||||
if locale != def {
|
||||
titles[locale] = title
|
||||
}
|
||||
}
|
||||
return map[string]any{"url": urls, "title": titles}, nil
|
||||
},
|
||||
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
||||
svc := p.Service()
|
||||
if svc == nil {
|
||||
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
|
||||
}
|
||||
page, ok := in.Record.(*models.Page)
|
||||
if !ok || page == nil {
|
||||
return pact.AdminFormSaveResult{}, nil
|
||||
}
|
||||
var before map[string]any
|
||||
if !in.Created {
|
||||
before = classes.PageValues(*page)
|
||||
old, _ := classes.ChangesValues(in.Changes, false)
|
||||
for k, v := range old {
|
||||
before[k] = v
|
||||
}
|
||||
}
|
||||
urls, urlsSent := localeTexts(in.Values["url"])
|
||||
titles, titlesSent := localeTexts(in.Values["title"])
|
||||
if !urlsSent {
|
||||
urls = nil
|
||||
}
|
||||
if !titlesSent {
|
||||
titles = nil
|
||||
}
|
||||
if err := svc.ApplyPageTexts(ctx, in.DB, page, urls, titles); err != nil {
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
}
|
||||
problems, err := svc.CheckPage(ctx, in.DB, *page)
|
||||
if err != nil {
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
}
|
||||
if list := problems["url"]; len(list) > 0 {
|
||||
refusals := pact.AdminFormErrors{}
|
||||
for _, pr := range list {
|
||||
refusals["url"] = append(refusals["url"], svc.PhraseCtx(ctx, pr.Key, pr.Params))
|
||||
}
|
||||
return pact.AdminFormSaveResult{}, refusals
|
||||
}
|
||||
if err := in.DB.Model(&models.Page{}).Where("id = ?", page.ID).Updates(map[string]any{
|
||||
"url": page.URL, "urls": page.URLs, "title": page.Title, "titles": page.Titles,
|
||||
}).Error; err != nil {
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
}
|
||||
return pact.AdminFormSaveResult{}, svc.RecordPage(ctx, in.DB, before, *page, false)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// localeTexts reads a submitted translatable value as locale -> text;
|
||||
// sent is false when the request did not send the field. A plain string
|
||||
// is the default locale's ("" key).
|
||||
func localeTexts(raw any) (map[string]string, bool) {
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
return nil, false
|
||||
case map[string]string:
|
||||
return v, true
|
||||
case map[string]any:
|
||||
out := make(map[string]string, len(v))
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
s, _ := v[k].(string)
|
||||
out[k] = s
|
||||
}
|
||||
return out, true
|
||||
case string:
|
||||
return map[string]string{"": v}, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -2,6 +2,7 @@ plugin:
|
||||
name: SEO
|
||||
description: Search engine metadata for every content type, URL rules and global SEO settings.
|
||||
nav:
|
||||
pages: Pages
|
||||
robots: robots.txt
|
||||
notfound: 404 log
|
||||
redirects: Redirects
|
||||
@@ -171,6 +172,7 @@ history:
|
||||
what_robots: robots.txt
|
||||
what_settings: SEO settings
|
||||
what_notfound: 404 log entry “:url”
|
||||
what_page: Page “:url”
|
||||
change_cell:
|
||||
one: ':field: :before → :after'
|
||||
other: ':count fields changed'
|
||||
@@ -202,6 +204,7 @@ history:
|
||||
kind_robots: robots.txt
|
||||
kind_settings: SEO settings
|
||||
kind_notfound: 404 log entry
|
||||
kind_page: registry page
|
||||
jobs:
|
||||
generate_entries: 'SEO: generate entries'
|
||||
effective:
|
||||
@@ -346,3 +349,46 @@ transfer:
|
||||
row_canonical: "The canonical URL redirects to “:target”"
|
||||
row_rule: "No record at this address: a URL rule is created"
|
||||
row_invalid: ":field: :message"
|
||||
pages:
|
||||
type: Site page
|
||||
list_title: Pages
|
||||
name: Page
|
||||
new: New page
|
||||
list_empty: No pages yet. Add the site's own pages here, such as the home page, contact or offer pages, so they get an SEO tab and a place in the sitemaps. The site can also report its pages automatically.
|
||||
url: Address
|
||||
title: Name
|
||||
kind: Type
|
||||
origin: Origin
|
||||
content_updated: Content changed
|
||||
reported: Last reported
|
||||
kind_page: Page
|
||||
kind_offer: Offer
|
||||
kind_service: Service
|
||||
kind_landing: Landing page
|
||||
origin_manual: Manual
|
||||
origin_reported: Reported by the site
|
||||
origin_mcp: MCP
|
||||
state_missing: Not on the site
|
||||
filter_missing: Not on the site
|
||||
filter_missing_only: Only missing
|
||||
filter_missing_hide: Hide missing
|
||||
url_comment: The page's path in each language, e.g. /kontakt. Leave a language empty when the page has no version in it.
|
||||
title_comment: Used for the default SEO title, the HTML sitemap and breadcrumbs.
|
||||
kind_comment: "Decides the sitemap file and the default structured data: offers and services become Service."
|
||||
parent: Parent page
|
||||
parent_none: None (top level)
|
||||
parent_comment: Places the page under another one in breadcrumbs.
|
||||
content_updated_comment: Sent as lastmod in the sitemaps. Change it when the page's content changes on the site.
|
||||
note: Note
|
||||
note_comment: Not shown on the site.
|
||||
status_reported: Reported by the site on :time.
|
||||
status_missing: The site's last report (:time) did not include this address. Check whether the page still exists, then delete it here or redirect it.
|
||||
url_required: Enter the address in the default language.
|
||||
url_invalid: Start the address with / and leave out the domain.
|
||||
url_taken: Another page already uses this address.
|
||||
url_owned: This address belongs to :type “:title”. Set its SEO there.
|
||||
parent_loop: A page cannot be placed under itself or one of its subpages.
|
||||
delete_confirm: Delete this page from the registry? Its SEO tab is deleted with it, and the address leaves the sitemaps. A page the site reports comes back with its next report.
|
||||
delete_selected_confirm:
|
||||
one: "Delete :count page from the registry? Its SEO tab is deleted with it."
|
||||
other: "Delete :count pages from the registry? Their SEO tabs are deleted with them."
|
||||
|
||||
@@ -2,6 +2,7 @@ plugin:
|
||||
name: SEO
|
||||
description: Metadane dla wyszukiwarek we wszystkich typach treści, reguły adresów i globalne ustawienia SEO.
|
||||
nav:
|
||||
pages: Strony
|
||||
robots: robots.txt
|
||||
notfound: Dziennik 404
|
||||
redirects: Przekierowania
|
||||
@@ -175,6 +176,7 @@ history:
|
||||
what_robots: robots.txt
|
||||
what_settings: Ustawienia SEO
|
||||
what_notfound: Wpis dziennika 404 „:url”
|
||||
what_page: Strona „:url”
|
||||
change_cell:
|
||||
one: ':field: :before → :after'
|
||||
few: Zmieniono :count pola
|
||||
@@ -208,6 +210,7 @@ history:
|
||||
kind_robots: robots.txt
|
||||
kind_settings: Ustawienia SEO
|
||||
kind_notfound: Wpis dziennika 404
|
||||
kind_page: strona rejestru
|
||||
jobs:
|
||||
generate_entries: 'SEO: generowanie wpisów'
|
||||
effective:
|
||||
@@ -358,3 +361,48 @@ transfer:
|
||||
row_canonical: "Adres kanoniczny przekierowuje na „:target”"
|
||||
row_rule: "Pod tym adresem nie ma rekordu: zostanie utworzona reguła adresu"
|
||||
row_invalid: ":field: :message"
|
||||
pages:
|
||||
type: Strona witryny
|
||||
list_title: Strony
|
||||
name: Strona
|
||||
new: Nowa strona
|
||||
list_empty: Nie ma jeszcze stron. Dodaj tu własne strony witryny, np. stronę główną, kontakt lub strony ofertowe, aby miały zakładkę SEO i miejsce w mapach witryny. Witryna może też zgłaszać swoje strony automatycznie.
|
||||
url: Adres
|
||||
title: Nazwa
|
||||
kind: Typ
|
||||
origin: Pochodzenie
|
||||
content_updated: Zmiana treści
|
||||
reported: Ostatnie zgłoszenie
|
||||
kind_page: Strona
|
||||
kind_offer: Oferta
|
||||
kind_service: Usługa
|
||||
kind_landing: Landing page
|
||||
origin_manual: Ręcznie
|
||||
origin_reported: Zgłoszona przez witrynę
|
||||
origin_mcp: MCP
|
||||
state_missing: Brak w witrynie
|
||||
filter_missing: Brak w witrynie
|
||||
filter_missing_only: Tylko brakujące
|
||||
filter_missing_hide: Ukryj brakujące
|
||||
url_comment: Ścieżka strony w każdym języku, np. /kontakt. Zostaw język pusty, jeśli strona nie ma w nim wersji.
|
||||
title_comment: Używana w domyślnym tytule SEO, mapie HTML i ścieżce nawigacji.
|
||||
kind_comment: "Decyduje o pliku mapy witryny i domyślnych danych strukturalnych: oferty i usługi stają się typem Service."
|
||||
parent: Strona nadrzędna
|
||||
parent_none: Brak (najwyższy poziom)
|
||||
parent_comment: Umieszcza stronę pod inną w ścieżce nawigacji.
|
||||
content_updated_comment: Wysyłana jako lastmod w mapach witryny. Zmień ją, gdy treść strony zmieni się w witrynie.
|
||||
note: Notatka
|
||||
note_comment: Nie jest wyświetlana na stronie.
|
||||
status_reported: Zgłoszona przez witrynę :time.
|
||||
status_missing: Ostatnie zgłoszenie witryny (:time) nie zawierało tego adresu. Sprawdź, czy strona nadal istnieje, a potem usuń ją tutaj lub ustaw przekierowanie.
|
||||
url_required: Wpisz adres w języku domyślnym.
|
||||
url_invalid: Zacznij adres od / i pomiń domenę.
|
||||
url_taken: Inna strona już używa tego adresu.
|
||||
url_owned: "Ten adres należy do: :type „:title”. Ustaw jego SEO tam."
|
||||
parent_loop: Strony nie można umieścić pod nią samą ani pod jej podstroną.
|
||||
delete_confirm: Usunąć tę stronę z rejestru? Jej zakładka SEO zostanie usunięta, a adres zniknie z map witryny. Strona zgłaszana przez witrynę wróci przy następnym zgłoszeniu.
|
||||
delete_selected_confirm:
|
||||
one: "Usunąć :count stronę z rejestru? Jej zakładka SEO zostanie usunięta."
|
||||
few: "Usunąć :count strony z rejestru? Ich zakładki SEO zostaną usunięte."
|
||||
many: "Usunąć :count stron z rejestru? Ich zakładki SEO zostaną usunięte."
|
||||
other: "Usunąć :count strony z rejestru? Ich zakładki SEO zostaną usunięte."
|
||||
|
||||
43
mcp.go
43
mcp.go
@@ -154,6 +154,49 @@ func (p *Plugin) MCP() pact.MCP {
|
||||
Destructive: &destructive,
|
||||
},
|
||||
|
||||
// The page registry (D-33).
|
||||
{
|
||||
Name: "list_pages",
|
||||
Title: "List registry pages",
|
||||
Description: "Lists the page registry (the site's own frontend pages such as the home page, contact or offer pages) by address, with every locale's address and name, the kind, the origin (manual, reported by the site, MCP) and whether the site's newest route report missed the page. Filters: search text, id, kind, origin, missing.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/pages",
|
||||
Input: apicontrollers.ListPagesInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "create_page",
|
||||
Title: "Create a registry page",
|
||||
Description: "Adds a frontend page to the registry, so it gets SEO values (set them with set_url_seo on its address) and a place in the sitemaps: its path per locale, name, kind (page, offer, service, landing), parent and content date. Refused for an address another page or another content type (a post, a category) already has.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPost,
|
||||
Path: "/_seo/api/v1/pages",
|
||||
Input: apicontrollers.CreatePageInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "update_page",
|
||||
Title: "Update a registry page",
|
||||
Description: "Changes the fields sent of one registry page by id; in url and title only the locales sent. A changed address gets a 301 from the old one.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodPut,
|
||||
Path: "/_seo/api/v1/pages/{id}",
|
||||
Input: apicontrollers.UpdatePageInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
{
|
||||
Name: "delete_page",
|
||||
Title: "Delete a registry page",
|
||||
Description: "Deletes one registry page by id with its SEO values; its address leaves the sitemaps on the next rebuild. A page the site reports comes back with its next route report.",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodDelete,
|
||||
Path: "/_seo/api/v1/pages/{id}",
|
||||
Input: apicontrollers.PageIDInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
Destructive: &destructive,
|
||||
},
|
||||
|
||||
// 404 log (D-20).
|
||||
{
|
||||
Name: "list_not_found",
|
||||
|
||||
16
menu_item_types.go
Normal file
16
menu_item_types.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package seo
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
var _ pact.HasMenuItemTypes = (*Plugin)(nil)
|
||||
|
||||
// MenuItemTypes registers the page registry's type seo-page (D-33): its
|
||||
// content descriptor (controller golem15.seo.pages, Kind page) lists,
|
||||
// describes and matches the registry pages, so every page gets the SEO
|
||||
// tab, resolves ok and is listed in the sitemaps grouped by its kind.
|
||||
func (p *Plugin) MenuItemTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{classes.PageMenuItemType(p.Service)}
|
||||
}
|
||||
203
models/page.go
Normal file
203
models/page.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The kinds of a registry page (D-33): they decide the sitemap file group
|
||||
// and the default structured data.
|
||||
const (
|
||||
PageKindPage = "page"
|
||||
PageKindOffer = "offer"
|
||||
PageKindService = "service"
|
||||
PageKindLanding = "landing"
|
||||
)
|
||||
|
||||
// PageKinds lists the kinds in form order.
|
||||
var PageKinds = []string{PageKindPage, PageKindOffer, PageKindService, PageKindLanding}
|
||||
|
||||
// The origins of a registry page: entered in the admin, reported by the
|
||||
// frontend server's route report, or created through MCP.
|
||||
const (
|
||||
PageOriginManual = "manual"
|
||||
PageOriginReported = "reported"
|
||||
PageOriginMCP = "mcp"
|
||||
)
|
||||
|
||||
// PageOrigins lists the origins in filter order.
|
||||
var PageOrigins = []string{PageOriginManual, PageOriginReported, PageOriginMCP}
|
||||
|
||||
// Page is one frontend page of the page registry on golem15_seo_pages
|
||||
// (D-33): a page the site serves itself (home, contact, an offer), with
|
||||
// its address per locale, a kind, a name, a parent, the date its content
|
||||
// last changed and a note. URL is the default-locale path; URLs holds the
|
||||
// other locales' paths and Titles the other locales' names (a locale
|
||||
// without a path has no version of the page). ReportedAt is the newest
|
||||
// route report that named the page.
|
||||
type Page struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
URL string `gorm:"column:url"`
|
||||
URLs lagoon.Jsonable[map[string]string] `gorm:"column:urls"`
|
||||
Title string `gorm:"column:title"`
|
||||
Titles lagoon.Jsonable[map[string]string] `gorm:"column:titles"`
|
||||
Kind string `gorm:"column:kind"`
|
||||
Origin string `gorm:"column:origin"`
|
||||
ParentID *PageRef `gorm:"column:parent_id"`
|
||||
ContentUpdatedAt *lagoon.Date `gorm:"column:content_updated_at"`
|
||||
ReportedAt *time.Time `gorm:"column:reported_at"`
|
||||
Note string `gorm:"column:note"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (Page) TableName() string { return "golem15_seo_pages" }
|
||||
|
||||
// MorphName names a registry page in history.
|
||||
func (Page) MorphName() string { return `Golem15\SEO\Models\Page` }
|
||||
|
||||
// Fillable lists the owner form's columns; the addresses and names per
|
||||
// locale are saved by the plugin's form extension on the Pages screen.
|
||||
func (*Page) Fillable() []string {
|
||||
return []string{"kind", "parent_id", "content_updated_at", "note"}
|
||||
}
|
||||
|
||||
// Rules is empty: the Pages controller and the registry service check a
|
||||
// page before a save (classes.CheckPage).
|
||||
func (*Page) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// NewPage is a page with the form's defaults: kind page, entered by hand,
|
||||
// its content changed today.
|
||||
func NewPage() Page {
|
||||
today := lagoon.DateOf(time.Now().UTC())
|
||||
return Page{
|
||||
URLs: lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true},
|
||||
Titles: lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true},
|
||||
Kind: PageKindPage,
|
||||
Origin: PageOriginManual,
|
||||
ContentUpdatedAt: &today,
|
||||
}
|
||||
}
|
||||
|
||||
// LocaleURLs returns the page's path per locale: def (the default locale)
|
||||
// with URL, then every other locale with a non-empty path.
|
||||
func (p Page) LocaleURLs(def string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for locale, path := range p.URLs.Get() {
|
||||
if path = strings.TrimSpace(path); path != "" && locale != def {
|
||||
out[locale] = path
|
||||
}
|
||||
}
|
||||
if u := strings.TrimSpace(p.URL); u != "" {
|
||||
out[def] = u
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TitleIn returns the page's name in locale, the default-locale name when
|
||||
// that locale has none.
|
||||
func (p Page) TitleIn(locale string) string {
|
||||
if t := strings.TrimSpace(p.Titles.Get()[locale]); t != "" {
|
||||
return t
|
||||
}
|
||||
return p.Title
|
||||
}
|
||||
|
||||
// PageRef is a registry page id held by another page (its parent). The
|
||||
// admin's parent dropdown posts it as text, which UnmarshalText reads; ""
|
||||
// is 0, the top level.
|
||||
type PageRef uint
|
||||
|
||||
// UnmarshalText reads a page id written as text.
|
||||
func (r *PageRef) UnmarshalText(b []byte) error {
|
||||
text := strings.TrimSpace(string(b))
|
||||
if text == "" {
|
||||
*r = 0
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.ParseUint(text, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("models: page id %q is not a number", b)
|
||||
}
|
||||
*r = PageRef(n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parent returns the parent page's id, 0 at the top level.
|
||||
func (p Page) Parent() uint {
|
||||
if p.ParentID == nil {
|
||||
return 0
|
||||
}
|
||||
return uint(*p.ParentID)
|
||||
}
|
||||
|
||||
// IDText is the page's id as text.
|
||||
func (p Page) IDText() string { return strconv.FormatUint(uint64(p.ID), 10) }
|
||||
|
||||
// KnownPageKind reports whether kind is one of PageKinds.
|
||||
func KnownPageKind(kind string) bool {
|
||||
for _, k := range PageKinds {
|
||||
if k == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The filter scopes of the Pages list (UI-SPEC C21).
|
||||
const (
|
||||
FilterPageKind = "FilterPageKind"
|
||||
FilterPageOrigin = "FilterPageOrigin"
|
||||
FilterPageMissing = "FilterPageMissing"
|
||||
MissingOnly = "only"
|
||||
MissingHide = "hide"
|
||||
)
|
||||
|
||||
// FilterScopes lists the Pages list's filter scopes.
|
||||
func (Page) FilterScopes() []string {
|
||||
return []string{FilterPageKind, FilterPageOrigin, FilterPageMissing}
|
||||
}
|
||||
|
||||
// MissingSQL is true for a reported page the newest route report did not
|
||||
// name: its reported_at is older than the settings' routes_reported_at.
|
||||
const MissingSQL = `(golem15_seo_pages.origin = 'reported' AND EXISTS (SELECT 1 FROM golem15_seo_settings s WHERE s.id = 1 AND s.routes_reported_at IS NOT NULL AND (golem15_seo_pages.reported_at IS NULL OR golem15_seo_pages.reported_at < s.routes_reported_at)))`
|
||||
|
||||
// FilterScope narrows the Pages list; an unknown value matches nothing.
|
||||
func (Page) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
s, _ := value.(string)
|
||||
switch name {
|
||||
case FilterPageKind:
|
||||
if !KnownPageKind(s) {
|
||||
return db.Where("1 = 0")
|
||||
}
|
||||
return db.Where("golem15_seo_pages.kind = ?", s)
|
||||
case FilterPageOrigin:
|
||||
for _, o := range PageOrigins {
|
||||
if o == s {
|
||||
return db.Where("golem15_seo_pages.origin = ?", s)
|
||||
}
|
||||
}
|
||||
return db.Where("1 = 0")
|
||||
case FilterPageMissing:
|
||||
switch s {
|
||||
case MissingOnly:
|
||||
return db.Where(MissingSQL)
|
||||
case MissingHide:
|
||||
return db.Where("NOT " + MissingSQL)
|
||||
}
|
||||
return db.Where("1 = 0")
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Page{})
|
||||
}
|
||||
29
models/page/columns.yaml
Normal file
29
models/page/columns.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
# Pages list columns (UI-SPEC C21). kind and origin show their labels (the
|
||||
# controller's ListExtendRecords) and sort by the stored values.
|
||||
columns:
|
||||
url:
|
||||
label: golem15.seo::lang.pages.url
|
||||
type: text
|
||||
searchable: true
|
||||
sortable: true
|
||||
title:
|
||||
label: golem15.seo::lang.pages.title
|
||||
type: text
|
||||
searchable: true
|
||||
kind:
|
||||
label: golem15.seo::lang.pages.kind
|
||||
type: text
|
||||
sortable: true
|
||||
origin:
|
||||
label: golem15.seo::lang.pages.origin
|
||||
type: text
|
||||
sortable: true
|
||||
content_updated_at:
|
||||
label: golem15.seo::lang.pages.content_updated
|
||||
type: date
|
||||
sortable: true
|
||||
reported_at:
|
||||
label: golem15.seo::lang.pages.reported
|
||||
type: datetime
|
||||
sortable: true
|
||||
invisible: true
|
||||
43
models/page/fields.yaml
Normal file
43
models/page/fields.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
# Page form (UI-SPEC C21). url (after page_status) and title (after kind)
|
||||
# are translatable and added by the plugin's form extension, which stores
|
||||
# them per locale; the SEO tab follows.
|
||||
fields:
|
||||
page_status:
|
||||
type: partial
|
||||
path: page_status
|
||||
span: full
|
||||
context: update
|
||||
|
||||
kind:
|
||||
label: golem15.seo::lang.pages.kind
|
||||
comment: golem15.seo::lang.pages.kind_comment
|
||||
type: dropdown
|
||||
span: right
|
||||
default: page
|
||||
options:
|
||||
page: golem15.seo::lang.pages.kind_page
|
||||
offer: golem15.seo::lang.pages.kind_offer
|
||||
service: golem15.seo::lang.pages.kind_service
|
||||
landing: golem15.seo::lang.pages.kind_landing
|
||||
|
||||
parent_id:
|
||||
label: golem15.seo::lang.pages.parent
|
||||
comment: golem15.seo::lang.pages.parent_comment
|
||||
type: dropdown
|
||||
span: right
|
||||
emptyOption: golem15.seo::lang.pages.parent_none
|
||||
options: parentOptions
|
||||
|
||||
content_updated_at:
|
||||
label: golem15.seo::lang.pages.content_updated
|
||||
comment: golem15.seo::lang.pages.content_updated_comment
|
||||
type: datepicker
|
||||
mode: date
|
||||
span: left
|
||||
|
||||
note:
|
||||
label: golem15.seo::lang.pages.note
|
||||
comment: golem15.seo::lang.pages.note_comment
|
||||
type: textarea
|
||||
size: tiny
|
||||
span: full
|
||||
@@ -23,6 +23,8 @@ const (
|
||||
SubjectRobots = "robots"
|
||||
SubjectSettings = "settings"
|
||||
SubjectNotFound = "notfound"
|
||||
// SubjectPage is a page of the page registry (D-33).
|
||||
SubjectPage = "page"
|
||||
)
|
||||
|
||||
// Revision is one recorded SEO change on golem15_seo_revisions (D-13): who
|
||||
|
||||
@@ -95,8 +95,11 @@ type Settings struct {
|
||||
VerifyOther lagoon.Jsonable[[]VerifyMeta] `gorm:"column:verify_other"`
|
||||
VerifyFiles lagoon.Jsonable[[]VerifyFile] `gorm:"column:verify_files"`
|
||||
CSVDelimiter string `gorm:"column:csv_delimiter"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
// RoutesReportedAt is the time of the newest route report (D-33),
|
||||
// written by the report only.
|
||||
RoutesReportedAt *time.Time `gorm:"column:routes_reported_at"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (Settings) TableName() string { return "golem15_seo_settings" }
|
||||
|
||||
@@ -84,6 +84,15 @@ var AdminOperations = []AdminOperation{
|
||||
{controllers.RulesControllerID, OpExtensionSave, "update_rule"},
|
||||
{controllers.RulesControllerID, OpWidgetAction + ":seo_snippet", "get_url_seo"},
|
||||
|
||||
// Pages, the page registry; its own address-and-name extension saves
|
||||
// with the form.
|
||||
{controllers.PagesControllerID, OpList, "list_pages"},
|
||||
{controllers.PagesControllerID, OpShow, "list_pages"},
|
||||
{controllers.PagesControllerID, OpCreate, "create_page"},
|
||||
{controllers.PagesControllerID, OpUpdate, "update_page"},
|
||||
{controllers.PagesControllerID, OpDelete, "delete_page"},
|
||||
{controllers.PagesControllerID, OpExtensionSave, "update_page"},
|
||||
|
||||
// robots.txt and AI bots.
|
||||
{controllers.RobotsControllerID, OpShow, "show_robots"},
|
||||
{controllers.RobotsControllerID, OpUpdate, "update_robots"},
|
||||
|
||||
@@ -140,6 +140,7 @@ func requiredOperations(t *testing.T, p *Plugin) []AdminOperation {
|
||||
{Controller: controllers.RulesControllerID, Form: p.ruleHistory()},
|
||||
{Controller: controllers.RedirectsControllerID, Form: p.redirectChecks()},
|
||||
{Controller: controllers.RobotsControllerID, Form: p.robotsForm()},
|
||||
{Controller: controllers.PagesControllerID, Form: p.pagesForm()},
|
||||
}
|
||||
for _, ext := range exts {
|
||||
if ext.Form == nil {
|
||||
|
||||
184
registry_test.go
Normal file
184
registry_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package seo_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
)
|
||||
|
||||
// pagesAPI is the Pages screen's admin API.
|
||||
var pagesAPI = adminAPI("/golem15/seo/pages")
|
||||
|
||||
// createRegistryPage creates a registry page through the admin form and
|
||||
// returns its id.
|
||||
func (e *testEnv) createRegistryPage(t *testing.T, token string, body map[string]any) string {
|
||||
t.Helper()
|
||||
return createdID(t, e.expect(t, http.StatusCreated, http.MethodPost, pagesAPI, token, body).Body.Bytes())
|
||||
}
|
||||
|
||||
// refusal posts body and expects a 422 whose answer contains want.
|
||||
func (e *testEnv) refusal(t *testing.T, method, path, token string, body map[string]any, want string) {
|
||||
t.Helper()
|
||||
rec := e.call(t, method, path, token, body)
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), want) {
|
||||
t.Fatalf("%s %s %v = %d %s, want 422 with %q", method, path, body, rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry covers the page registry (D-33, UI-SPEC C21): a page made
|
||||
// in the Pages screen is a seo-page record with the SEO tab, resolves ok,
|
||||
// is listed in the sitemap file of its kind with hreflang alternates, the
|
||||
// C21 refusals, the delete, the filter groups and the management API.
|
||||
func TestRegistry(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
|
||||
contact := env.createRegistryPage(t, token, map[string]any{"url": map[string]any{"en": "/kontakt", "pl": ""}, "title": map[string]any{"en": "Kontakt"}, "kind": "page"})
|
||||
got := env.resolve(t, "/kontakt")
|
||||
seoBody, _ := got["seo"].(map[string]any)
|
||||
if got["action"] != "ok" || !strings.Contains(seoBody["title"].(string), "Kontakt") {
|
||||
t.Fatalf("resolve /kontakt = %v, want ok with the page name in the title", got)
|
||||
}
|
||||
|
||||
// The registry is the content type seo-page with the SEO tab.
|
||||
reg, _ := env.app.Lookup[*signpost.Registry]()
|
||||
if typ, ok := reg.ContentType("seo-page"); !ok || typ.Content == nil || typ.Content.Controller != "golem15.seo.pages" || typ.Content.Kind != "page" {
|
||||
t.Fatalf("seo-page content type = %+v ok=%v", typ, ok)
|
||||
}
|
||||
rec, err := reg.Describe(t.Context(), "seo-page", contact, "")
|
||||
if err != nil || rec == nil || len(rec.Locales) != 1 || rec.Locales[0].URL != "/kontakt" || rec.Locales[0].Title != "Kontakt" || rec.LastMod == nil {
|
||||
t.Fatalf("describe = %+v err=%v, want one en locale /kontakt with a content date (no pl address)", rec, err)
|
||||
}
|
||||
findField(t, env.formSchema(t, token, "golem15/seo/pages"), "seo_title")
|
||||
env.expect(t, http.StatusOK, http.MethodPut, pagesAPI+"/"+contact, token, map[string]any{"seo_title": map[string]any{"en": "Contact us"}})
|
||||
if seoBody, _ := env.resolve(t, "/kontakt")["seo"].(map[string]any); seoBody["title"] != "Contact us" {
|
||||
t.Fatalf("after the SEO tab save the title = %v, want Contact us", seoBody["title"])
|
||||
}
|
||||
|
||||
// Two locales: two URLs and hreflang alternates.
|
||||
about := env.createRegistryPage(t, token, map[string]any{"url": map[string]any{"en": "/about", "pl": "/o-firmie"}, "title": map[string]any{"en": "About", "pl": "O firmie"}})
|
||||
if got := env.resolve(t, "/o-firmie"); got["action"] != "ok" || got["locale"] != "pl" {
|
||||
t.Fatalf("resolve /o-firmie = %v, want ok in pl", got)
|
||||
}
|
||||
show := env.expect(t, http.StatusOK, http.MethodGet, pagesAPI+"/"+about, token, nil).Body.String()
|
||||
if !jsonContains(show, `"url":{"en":"/about","pl":"/o-firmie"}`) || !jsonContains(show, `"title":{"en":"About","pl":"O firmie"}`) {
|
||||
t.Fatalf("show = %s, want the address and the name per locale", show)
|
||||
}
|
||||
offer := env.createRegistryPage(t, token, map[string]any{"url": map[string]any{"en": "/oferta"}, "title": map[string]any{"en": "Oferta"}, "kind": "offer"})
|
||||
|
||||
env.rebuildSitemap(t)
|
||||
pages := env.sitemapFile(t, "sitemap-pages-1.xml")
|
||||
for _, want := range []string{".test/kontakt</loc>", ".test/about</loc>", ".test/o-firmie</loc>", `hreflang="pl"`} {
|
||||
if !strings.Contains(pages, want) {
|
||||
t.Fatalf("sitemap-pages-1.xml lacks %s:\n%s", want, pages)
|
||||
}
|
||||
}
|
||||
if strings.Contains(pages, "/oferta<") {
|
||||
t.Fatalf("the offer is in the pages file:\n%s", pages)
|
||||
}
|
||||
if offers := env.sitemapFile(t, "sitemap-offers-1.xml"); !strings.Contains(offers, ".test/oferta</loc>") {
|
||||
t.Fatalf("sitemap-offers-1.xml lacks the offer:\n%s", offers)
|
||||
}
|
||||
|
||||
// The C21 refusals.
|
||||
env.createPage(t, token, map[string]any{"title": "Lwia grzywa", "slug": "lwia-grzywa", "published": true})
|
||||
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "/pages/lwia-grzywa"}}, "This address belongs to Page “Lwia grzywa”")
|
||||
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "/kontakt"}}, "Another page already uses this address.")
|
||||
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "/nowa", "pl": "/o-firmie"}}, "Another page already uses this address.")
|
||||
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "kontakt"}}, "Start the address with / and leave out the domain.")
|
||||
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "https://example.test/x"}}, "Start the address with / and leave out the domain.")
|
||||
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"url": map[string]any{"en": "", "pl": "/tylko-pl"}}, "Enter the address in the default language.")
|
||||
env.refusal(t, http.MethodPost, pagesAPI, token, map[string]any{"title": map[string]any{"en": "No address"}}, "Enter the address in the default language.")
|
||||
env.expect(t, http.StatusOK, http.MethodPut, pagesAPI+"/"+about, token, map[string]any{"parent_id": contact})
|
||||
env.refusal(t, http.MethodPut, pagesAPI+"/"+contact, token, map[string]any{"parent_id": about}, "A page cannot be placed under itself or one of its subpages.")
|
||||
env.refusal(t, http.MethodPut, pagesAPI+"/"+contact, token, map[string]any{"parent_id": contact}, "A page cannot be placed under itself or one of its subpages.")
|
||||
var count int64
|
||||
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_pages`).Scan(&count).Error; err != nil || count != 3 {
|
||||
t.Fatalf("pages after the refusals = %d err=%v, want 3", count, err)
|
||||
}
|
||||
if rec, err := reg.Describe(t.Context(), "seo-page", about, ""); err != nil || len(rec.Parents) != 1 || rec.Parents[0].ID != contact {
|
||||
t.Fatalf("about parents = %+v err=%v, want the contact page", rec, err)
|
||||
}
|
||||
|
||||
// A changed address gets its 301, from the admin and from the API.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, pagesAPI+"/"+about, token, map[string]any{"url": map[string]any{"en": "/about-us", "pl": "/o-firmie"}})
|
||||
if got := env.resolve(t, "/about"); got["action"] != "redirect" || got["location"] != siteURL+"/about-us" {
|
||||
t.Fatalf("resolve of the old address = %v, want a redirect to /about-us", got)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/pages/"+offer, token, map[string]any{"url": "/oferty"})
|
||||
if got := env.resolve(t, "/oferta"); got["action"] != "redirect" || got["location"] != siteURL+"/oferty" {
|
||||
t.Fatalf("resolve of the old API address = %v, want a redirect to /oferty", got)
|
||||
}
|
||||
|
||||
// The filter's groups: the four kinds with their titles per locale.
|
||||
filter, ok := env.app.Lookup[contract.Filter]()
|
||||
if !ok {
|
||||
t.Fatal("no contract.Filter published")
|
||||
}
|
||||
groups, err := filter.Groups(t.Context())
|
||||
if err != nil || len(groups) != 4 {
|
||||
t.Fatalf("groups = %+v err=%v", groups, err)
|
||||
}
|
||||
keys := []string{}
|
||||
for _, g := range groups {
|
||||
keys = append(keys, g.Key)
|
||||
}
|
||||
if strings.Join(keys, ",") != "pages,offers,services,landing-pages" || groups[3].Titles["en"] != "Landing page" || groups[0].Titles["pl"] != "Strona" {
|
||||
t.Fatalf("groups = %+v", groups)
|
||||
}
|
||||
|
||||
// Deleting a page deletes its SEO entries; it leaves the sitemaps.
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, pagesAPI+"/"+contact, token, nil)
|
||||
var entries int64
|
||||
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE model_type = ? AND record_id = ?`, `Golem15\SEO\Models\Page`, contact).Scan(&entries).Error; err != nil || entries != 0 {
|
||||
t.Fatalf("entries after delete = %d err=%v, want 0", entries, err)
|
||||
}
|
||||
env.rebuildSitemap(t)
|
||||
if pages := env.sitemapFile(t, "sitemap-pages-1.xml"); strings.Contains(pages, ".test/kontakt</loc>") {
|
||||
t.Fatalf("the deleted page is still listed:\n%s", pages)
|
||||
}
|
||||
if got := env.resolve(t, "/kontakt"); got["action"] != "not_found" {
|
||||
t.Fatalf("resolve of the deleted page = %v, want not_found", got)
|
||||
}
|
||||
|
||||
// The management API (the MCP tools' routes).
|
||||
api := "/_seo/api/v1/pages"
|
||||
created := env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"url": "/uslugi", "title": "Usługi", "kind": "service"}).Body.String()
|
||||
var answer struct {
|
||||
Data struct {
|
||||
ID uint `json:"id"`
|
||||
Origin string `json:"origin"`
|
||||
URLs map[string]string `json:"urls"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(created), &answer); err != nil || answer.Data.ID == 0 || answer.Data.Origin != "manual" || answer.Data.Kind != "service" || answer.Data.URLs["en"] != "/uslugi" {
|
||||
t.Fatalf("create answer = %s err=%v", created, err)
|
||||
}
|
||||
id := jsonID(answer.Data.ID)
|
||||
env.expect(t, http.StatusOK, http.MethodPut, api+"/"+id, token, map[string]any{"url": map[string]any{"pl": "/uslugi-pl"}, "note": "x"})
|
||||
list := env.expect(t, http.StatusOK, http.MethodGet, api+"?kind=service", token, nil).Body.String()
|
||||
if !jsonContains(list, `"urls":{"en":"/uslugi","pl":"/uslugi-pl"}`) || !jsonContains(list, `"total":1`) {
|
||||
t.Fatalf("list = %s", list)
|
||||
}
|
||||
rec2 := env.call(t, http.MethodPost, api, token, map[string]any{"url": "/about-us"})
|
||||
if rec2.Code != http.StatusUnprocessableEntity || !strings.Contains(rec2.Body.String(), "Another page already uses this address.") {
|
||||
t.Fatalf("API duplicate = %d %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, api+"/"+id, token, nil)
|
||||
env.expect(t, http.StatusNotFound, http.MethodDelete, api+"/"+id, token, nil)
|
||||
other := env.admin(t, false)
|
||||
env.expect(t, http.StatusForbidden, http.MethodGet, api, other, nil)
|
||||
env.expect(t, http.StatusUnauthorized, http.MethodGet, api, "", nil)
|
||||
}
|
||||
|
||||
// jsonID is an id as path text.
|
||||
func jsonID(id uint) string {
|
||||
raw, _ := json.Marshal(id)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Get("/redirects/{id}", apicontrollers.ShowRedirect(p.Service), api)
|
||||
g.Put("/redirects/{id}", apicontrollers.UpdateRedirect(p.Service), api)
|
||||
g.Delete("/redirects/{id}", apicontrollers.DeleteRedirect(p.Service), api)
|
||||
g.Get("/pages", apicontrollers.ListPages(p.Service), api)
|
||||
g.Post("/pages", apicontrollers.CreatePage(p.Service), api)
|
||||
g.Put("/pages/{id}", apicontrollers.UpdatePage(p.Service), api)
|
||||
g.Delete("/pages/{id}", apicontrollers.DeletePage(p.Service), api)
|
||||
g.Get("/notfound", apicontrollers.ListNotFound(p.Service), api)
|
||||
g.Post("/notfound/{id}/redirect", apicontrollers.RedirectNotFound(p.Service), api)
|
||||
g.Post("/notfound/{id}/gone", apicontrollers.GoneNotFound(p.Service), api)
|
||||
|
||||
@@ -25,11 +25,13 @@ 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) {
|
||||
// The SEO plugin's own page registry (seo-page, "Site page") is a
|
||||
// content type too, registered after the fixture's.
|
||||
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 | Notes | Indexing | Addresses | Verification"; got != want {
|
||||
if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Site page | Indexing | Addresses | Verification"; got != want {
|
||||
t.Fatalf("settings tabs = %q, want %q", got, want)
|
||||
}
|
||||
findField(t, fields, "type_acme_page_inject")
|
||||
@@ -76,10 +78,10 @@ func TestSettingsTypeTabs(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no content types", func(t *testing.T) {
|
||||
t.Run("no other content types", func(t *testing.T) {
|
||||
env := bootPlugins(t, []string{"golem15.seo"})
|
||||
token := env.admin(t, true)
|
||||
if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Indexing | Addresses | Verification"; got != want {
|
||||
if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Site page | Indexing | Addresses | Verification"; got != want {
|
||||
t.Fatalf("settings tabs = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
56
updates/202610280002_create_golem15_seo_pages.go
Normal file
56
updates/202610280002_create_golem15_seo_pages.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// createPages creates golem15_seo_pages, the page registry (D-33): the
|
||||
// site's own frontend pages with their address per locale (url is the
|
||||
// default locale's, urls the others'), name per locale, kind, origin,
|
||||
// parent, content date, last report and note, one row per default-locale
|
||||
// address. The settings get routes_reported_at, the time of the newest
|
||||
// route report, against which a reported page shows as missing.
|
||||
var createPages = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610280002_create_golem15_seo_pages",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`CREATE TABLE golem15_seo_pages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
urls JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
titles JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
kind TEXT NOT NULL DEFAULT 'page',
|
||||
origin TEXT NOT NULL DEFAULT 'manual',
|
||||
parent_id BIGINT NULL REFERENCES golem15_seo_pages (id) ON DELETE SET NULL,
|
||||
content_updated_at DATE NULL,
|
||||
reported_at TIMESTAMPTZ NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP(0) NULL,
|
||||
updated_at TIMESTAMP(0) NULL
|
||||
)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// One page per default-locale address; a row being created holds
|
||||
// '' only inside its own save transaction.
|
||||
if err := tx.Exec(`CREATE UNIQUE INDEX golem15_seo_pages_url ON golem15_seo_pages (url) WHERE url <> ''`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`CREATE INDEX golem15_seo_pages_urls ON golem15_seo_pages USING gin (urls jsonb_path_ops)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec(`ALTER TABLE golem15_seo_settings ADD COLUMN routes_reported_at TIMESTAMPTZ NULL`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`ALTER TABLE golem15_seo_settings DROP COLUMN IF EXISTS routes_reported_at`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec(`DROP TABLE IF EXISTS golem15_seo_pages`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(createPages...)
|
||||
}
|
||||
Reference in New Issue
Block a user