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:
203
models/page.go
Normal file
203
models/page.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The kinds of a registry page (D-33): they decide the sitemap file group
|
||||
// and the default structured data.
|
||||
const (
|
||||
PageKindPage = "page"
|
||||
PageKindOffer = "offer"
|
||||
PageKindService = "service"
|
||||
PageKindLanding = "landing"
|
||||
)
|
||||
|
||||
// PageKinds lists the kinds in form order.
|
||||
var PageKinds = []string{PageKindPage, PageKindOffer, PageKindService, PageKindLanding}
|
||||
|
||||
// The origins of a registry page: entered in the admin, reported by the
|
||||
// frontend server's route report, or created through MCP.
|
||||
const (
|
||||
PageOriginManual = "manual"
|
||||
PageOriginReported = "reported"
|
||||
PageOriginMCP = "mcp"
|
||||
)
|
||||
|
||||
// PageOrigins lists the origins in filter order.
|
||||
var PageOrigins = []string{PageOriginManual, PageOriginReported, PageOriginMCP}
|
||||
|
||||
// Page is one frontend page of the page registry on golem15_seo_pages
|
||||
// (D-33): a page the site serves itself (home, contact, an offer), with
|
||||
// its address per locale, a kind, a name, a parent, the date its content
|
||||
// last changed and a note. URL is the default-locale path; URLs holds the
|
||||
// other locales' paths and Titles the other locales' names (a locale
|
||||
// without a path has no version of the page). ReportedAt is the newest
|
||||
// route report that named the page.
|
||||
type Page struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
URL string `gorm:"column:url"`
|
||||
URLs lagoon.Jsonable[map[string]string] `gorm:"column:urls"`
|
||||
Title string `gorm:"column:title"`
|
||||
Titles lagoon.Jsonable[map[string]string] `gorm:"column:titles"`
|
||||
Kind string `gorm:"column:kind"`
|
||||
Origin string `gorm:"column:origin"`
|
||||
ParentID *PageRef `gorm:"column:parent_id"`
|
||||
ContentUpdatedAt *lagoon.Date `gorm:"column:content_updated_at"`
|
||||
ReportedAt *time.Time `gorm:"column:reported_at"`
|
||||
Note string `gorm:"column:note"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (Page) TableName() string { return "golem15_seo_pages" }
|
||||
|
||||
// MorphName names a registry page in history.
|
||||
func (Page) MorphName() string { return `Golem15\SEO\Models\Page` }
|
||||
|
||||
// Fillable lists the owner form's columns; the addresses and names per
|
||||
// locale are saved by the plugin's form extension on the Pages screen.
|
||||
func (*Page) Fillable() []string {
|
||||
return []string{"kind", "parent_id", "content_updated_at", "note"}
|
||||
}
|
||||
|
||||
// Rules is empty: the Pages controller and the registry service check a
|
||||
// page before a save (classes.CheckPage).
|
||||
func (*Page) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// NewPage is a page with the form's defaults: kind page, entered by hand,
|
||||
// its content changed today.
|
||||
func NewPage() Page {
|
||||
today := lagoon.DateOf(time.Now().UTC())
|
||||
return Page{
|
||||
URLs: lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true},
|
||||
Titles: lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true},
|
||||
Kind: PageKindPage,
|
||||
Origin: PageOriginManual,
|
||||
ContentUpdatedAt: &today,
|
||||
}
|
||||
}
|
||||
|
||||
// LocaleURLs returns the page's path per locale: def (the default locale)
|
||||
// with URL, then every other locale with a non-empty path.
|
||||
func (p Page) LocaleURLs(def string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for locale, path := range p.URLs.Get() {
|
||||
if path = strings.TrimSpace(path); path != "" && locale != def {
|
||||
out[locale] = path
|
||||
}
|
||||
}
|
||||
if u := strings.TrimSpace(p.URL); u != "" {
|
||||
out[def] = u
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TitleIn returns the page's name in locale, the default-locale name when
|
||||
// that locale has none.
|
||||
func (p Page) TitleIn(locale string) string {
|
||||
if t := strings.TrimSpace(p.Titles.Get()[locale]); t != "" {
|
||||
return t
|
||||
}
|
||||
return p.Title
|
||||
}
|
||||
|
||||
// PageRef is a registry page id held by another page (its parent). The
|
||||
// admin's parent dropdown posts it as text, which UnmarshalText reads; ""
|
||||
// is 0, the top level.
|
||||
type PageRef uint
|
||||
|
||||
// UnmarshalText reads a page id written as text.
|
||||
func (r *PageRef) UnmarshalText(b []byte) error {
|
||||
text := strings.TrimSpace(string(b))
|
||||
if text == "" {
|
||||
*r = 0
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.ParseUint(text, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("models: page id %q is not a number", b)
|
||||
}
|
||||
*r = PageRef(n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parent returns the parent page's id, 0 at the top level.
|
||||
func (p Page) Parent() uint {
|
||||
if p.ParentID == nil {
|
||||
return 0
|
||||
}
|
||||
return uint(*p.ParentID)
|
||||
}
|
||||
|
||||
// IDText is the page's id as text.
|
||||
func (p Page) IDText() string { return strconv.FormatUint(uint64(p.ID), 10) }
|
||||
|
||||
// KnownPageKind reports whether kind is one of PageKinds.
|
||||
func KnownPageKind(kind string) bool {
|
||||
for _, k := range PageKinds {
|
||||
if k == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The filter scopes of the Pages list (UI-SPEC C21).
|
||||
const (
|
||||
FilterPageKind = "FilterPageKind"
|
||||
FilterPageOrigin = "FilterPageOrigin"
|
||||
FilterPageMissing = "FilterPageMissing"
|
||||
MissingOnly = "only"
|
||||
MissingHide = "hide"
|
||||
)
|
||||
|
||||
// FilterScopes lists the Pages list's filter scopes.
|
||||
func (Page) FilterScopes() []string {
|
||||
return []string{FilterPageKind, FilterPageOrigin, FilterPageMissing}
|
||||
}
|
||||
|
||||
// MissingSQL is true for a reported page the newest route report did not
|
||||
// name: its reported_at is older than the settings' routes_reported_at.
|
||||
const MissingSQL = `(golem15_seo_pages.origin = 'reported' AND EXISTS (SELECT 1 FROM golem15_seo_settings s WHERE s.id = 1 AND s.routes_reported_at IS NOT NULL AND (golem15_seo_pages.reported_at IS NULL OR golem15_seo_pages.reported_at < s.routes_reported_at)))`
|
||||
|
||||
// FilterScope narrows the Pages list; an unknown value matches nothing.
|
||||
func (Page) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
s, _ := value.(string)
|
||||
switch name {
|
||||
case FilterPageKind:
|
||||
if !KnownPageKind(s) {
|
||||
return db.Where("1 = 0")
|
||||
}
|
||||
return db.Where("golem15_seo_pages.kind = ?", s)
|
||||
case FilterPageOrigin:
|
||||
for _, o := range PageOrigins {
|
||||
if o == s {
|
||||
return db.Where("golem15_seo_pages.origin = ?", s)
|
||||
}
|
||||
}
|
||||
return db.Where("1 = 0")
|
||||
case FilterPageMissing:
|
||||
switch s {
|
||||
case MissingOnly:
|
||||
return db.Where(MissingSQL)
|
||||
case MissingHide:
|
||||
return db.Where("NOT " + MissingSQL)
|
||||
}
|
||||
return db.Where("1 = 0")
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Page{})
|
||||
}
|
||||
Reference in New Issue
Block a user