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

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