From 6b67ab759bd6b8dda81d15d7e91f3c15ae4da3e1 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 22:39:14 +0100 Subject: [PATCH] feat: contract.Rebuilder asks for a debounced rebuild The sitemap publishes classes.Listing as contract.Rebuilder too, so a plugin whose Filter reads data of its own can queue the debounced rebuild a record event queues when that data changes. --- README.md | 6 +++++- classes/listing.go | 16 +++++++++++++++- contract/contract.go | 12 ++++++++++++ plugin.go | 9 ++++++--- sitemap_index_test.go | 21 +++++++++++++++++++++ 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4ddde7f..aa595b1 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ v0.1 served one `/sitemap.xml` in the WinterCMS byte format from a definition of - **HTML sitemap API.** `GET /_sitemap/api/v1/html` and `/_sitemap/api/v1/html/{group}` answer the HTML sitemap's groups and pages from the same indexable set as the XML sitemaps (D-34); the site's layer renders them. - **Settings singleton.** One tile in the CMS settings category: Files (site address, file size, the files table), Content types (per type: include, file name, HTML sitemap heading), Additional addresses, Images and News, IndexNow and HTML sitemap. - **Management API and MCP.** `GET`/`PUT /_sitemap/api/v1/settings` and `POST /_sitemap/api/v1/rebuild`, and the MCP tools `sitemap_get_settings`, `sitemap_update_settings` and `sitemap_rebuild` (D-10). -- **Contract.** `contract.Filter` for other plugins to implement and `contract.Listing` for other plugins to read (D-95). +- **Contract.** `contract.Filter` for other plugins to implement, `contract.Listing` for other plugins to read and `contract.Rebuilder` for other plugins to ask for a rebuild (D-95). ## Usage @@ -202,6 +202,10 @@ The plugin publishes `classes.Listing` as `contract.Listing`: `Settings` also answers the HTML sitemap settings (`HTMLEnabled`, `HTMLPaths` by locale, `HTMLPageSize`) and the IndexNow key file name (`KeyFile`, `.txt`, empty while IndexNow is off) and the content types switched off (`TypesOff`, whose addresses are left out with `ReasonType`), so the SEO plugin and the layer read them without importing the sitemap's internals. +### Rebuilder + +The plugin also publishes `classes.Listing` as `contract.Rebuilder`. `RequestRebuild` queues one debounced rebuild, the same one a record event queues (a request inside the debounce window joins it), so a plugin whose `Filter` reads data of its own asks for a rebuild when that data changes; the SEO plugin does it when the frontend reports a changed set of noindex paths. A plugin that finds no `contract.Rebuilder` (an older sitemap plugin) leaves the change to the next scheduled rebuild. + ### Admin The Settings page has one Sitemaps tile (icon `network`, category CMS, permission `golem15.sitemap.access_definitions`). It opens the singleton controller `golem15.sitemap.settings`: diff --git a/classes/listing.go b/classes/listing.go index d80ffb9..96664c5 100644 --- a/classes/listing.go +++ b/classes/listing.go @@ -20,7 +20,10 @@ type Listing struct { App *backpack.App } -var _ contract.Listing = Listing{} +var ( + _ contract.Listing = Listing{} + _ contract.Rebuilder = Listing{} +) // The page sizes of Entries. const ( @@ -39,6 +42,17 @@ func (l Listing) db(ctx context.Context) (*gorm.DB, error) { return db.WithContext(ctx), nil } +// RequestRebuild queues one debounced rebuild through +// RequestDebouncedRebuild, the same one a record event queues +// (contract.Rebuilder). +func (l Listing) RequestRebuild(ctx context.Context) error { + db, err := l.db(ctx) + if err != nil { + return err + } + return RequestDebouncedRebuild(ctx, l.App, db) +} + // Settings answers the site address, the index address, the default // locale and the groups with listed addresses (by key), with their titles // (a content type's saved HTML sitemap headings, else the titles of the diff --git a/contract/contract.go b/contract/contract.go index 8826157..522407f 100644 --- a/contract/contract.go +++ b/contract/contract.go @@ -79,6 +79,18 @@ type Filter interface { Groups(ctx context.Context) ([]Group, error) } +// Rebuilder asks the sitemap for a rebuild. RequestRebuild queues one +// debounced rebuild, the same one a record event queues: a request inside +// the debounce window joins the rebuild already queued. Another plugin +// calls it when something its Filter reads has changed (the SEO plugin's +// noindex paths). The sitemap plugin publishes it with +// app.Publish[contract.Rebuilder] in its Register; a plugin that finds none +// published (an older sitemap plugin) leaves the change to the next +// scheduled rebuild. +type Rebuilder interface { + RequestRebuild(ctx context.Context) error +} + // Listing is what the sitemap publishes for other plugins: its settings and // the listed addresses of each group. The sitemap plugin publishes it with // app.Publish[contract.Listing] in its Register. diff --git a/plugin.go b/plugin.go index 9f713e0..f383819 100644 --- a/plugin.go +++ b/plugin.go @@ -48,11 +48,14 @@ func (p *Plugin) ID() string { return "golem15.sitemap" } // from a named plugin, and a Filter through the service registry. func (p *Plugin) Requires() []string { return nil } -// Register publishes the sitemap's contract.Listing for other plugins -// (D-95). +// Register publishes the sitemap's contract.Listing and contract.Rebuilder +// for other plugins (D-95). func (p *Plugin) Register(app *backpack.App) error { p.app = app - return app.Publish[contract.Listing](classes.Listing{App: app}) + if err := app.Publish[contract.Listing](classes.Listing{App: app}); err != nil { + return err + } + return app.Publish[contract.Rebuilder](classes.Listing{App: app}) } // Boot keeps the booted application for the routes, jobs and commands and diff --git a/sitemap_index_test.go b/sitemap_index_test.go index 36771fd..e8aacde 100644 --- a/sitemap_index_test.go +++ b/sitemap_index_test.go @@ -856,4 +856,25 @@ func TestListing(t *testing.T) { if err != nil || none.Total != 0 || len(none.Entries) != 0 || none.Next != "" { t.Fatalf("unknown group = %+v %v", none, err) } + // contract.Rebuilder (P33-59) queues the debounced rebuild a record + // event queues, once per debounce window. + rb, ok := e.app.Lookup[contract.Rebuilder]() + if !ok { + t.Fatal("no contract.Rebuilder") + } + for range 2 { + if err := rb.RequestRebuild(ctx); err != nil { + t.Fatal(err) + } + } + var pending *time.Time + if err := e.gdb.Raw(`SELECT rebuild_pending_until FROM golem15_sitemap_settings WHERE id = 1`).Scan(&pending).Error; err != nil { + t.Fatal(err) + } + if pending == nil { + t.Fatal("RequestRebuild set no rebuild_pending_until") + } + if n := e.riverJobs(t, classes.RebuildKind); n != 1 { + t.Fatalf("two requests queued %d rebuilds, want 1", n) + } }