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:
Jakub Zych
2026-11-04 13:20:45 +01:00
parent 17b810dfde
commit 9e802ec850
27 changed files with 2281 additions and 9 deletions

View File

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

View File

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

View File

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

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

View 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

View 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

View 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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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"},

View File

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

View File

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

View File

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

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