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.
This commit is contained in:
Jakub Zych
2026-11-04 22:39:14 +01:00
parent 1048c01fd9
commit 6b67ab759b
5 changed files with 59 additions and 5 deletions

View File

@@ -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`, `<key>.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`:

View File

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

View File

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

View File

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

View File

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