feat(11.1-03): add the Architecture and Plugins docs sections
- architecture: introduction, Go modules and workspaces, application lifecycle, request lifecycle - plugins: registration, scheduling, extending, testing - verified Examples for backpack services, towel request context, pact schedules and festival events - TestDocsRequiredPages lists the eight new pages
This commit is contained in:
@@ -78,6 +78,14 @@ var requiredPages = []string{
|
||||
"index",
|
||||
"setup/installation",
|
||||
"setup/coming-from-wintercms",
|
||||
"architecture/introduction",
|
||||
"architecture/go-modules-and-workspaces",
|
||||
"architecture/application-lifecycle",
|
||||
"architecture/request-lifecycle",
|
||||
"plugins/registration",
|
||||
"plugins/scheduling",
|
||||
"plugins/extending",
|
||||
"plugins/testing",
|
||||
}
|
||||
|
||||
// TestDocsRequiredPages asserts that every required page is in the loaded
|
||||
|
||||
75
docs/architecture/application-lifecycle.md
Normal file
75
docs/architecture/application-lifecycle.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Application lifecycle
|
||||
description: "What happens when an application binary starts: configuration, the backpack container, plugin ordering, Register and Boot, and database-dependent boot work."
|
||||
section: architecture
|
||||
order: 30
|
||||
---
|
||||
# Application lifecycle
|
||||
|
||||
Every run of an application binary, whether it serves HTTP or runs a single console command, goes through the same start-up. `summer build` generates the `main.go` that performs it, so you never write it by hand.
|
||||
|
||||
## Start-up sequence
|
||||
|
||||
The generated `main` does the following, in order:
|
||||
|
||||
1. Loads configuration with `compass.Load` from the `config/` directory, applying the environment directory and `SUMMER_` variables.
|
||||
2. Creates the application container with `backpack.New`.
|
||||
3. Activates the plugins listed in `summer.yaml` with `party.Activate`.
|
||||
4. Collects the console commands: the framework's runtime commands (from `lagoon.RuntimeCommands`, `conga.RuntimeCommands`, `surf.ServeCommand`, `surf.RouteListCommand` and `cabana.RuntimeCommands`), then the commands of every plugin that implements `pact.HasCommands`.
|
||||
5. Publishes the command set as a `bonfire.Catalog`, so the scheduler can run commands in-process.
|
||||
6. Runs the command named on the command line.
|
||||
|
||||
## Plugin ordering
|
||||
|
||||
`party.Activate` selects the plugins by ID and orders them so that every plugin comes after the plugins its `party.Plugin.Requires` lists. It fails before any plugin code runs when an ID is empty, duplicated or not compiled in, when a required plugin is missing, or when the requirements form a cycle.
|
||||
|
||||
## Register, then Boot
|
||||
|
||||
Activation runs in phases, and each phase finishes for every plugin before the next begins:
|
||||
|
||||
1. `backpack.App.SetPlugins` records the complete plugin set, so `backpack.App.HasPlugin` answers correctly from the first Register onwards.
|
||||
2. The embedded defaults of every plugin that implements `pact.HasConfig` are merged into the configuration under the plugin ID.
|
||||
3. `party.Plugin.Register` runs for every plugin. Publish services here; do not use other plugins' services yet.
|
||||
4. The framework publishes the translator and the mailer, and registers each plugin's translations and mail templates.
|
||||
5. `party.Plugin.Boot` runs for every plugin. Look up services, register event listeners and extend other plugins here.
|
||||
|
||||
This is the WinterCMS `register` and `boot` split: when any Boot runs, every plugin has already registered.
|
||||
|
||||
## The container
|
||||
|
||||
`backpack.App` is the application container that Register and Boot receive. It holds the configuration in `backpack.App.Config`, the event bus in `backpack.App.Events` and a typed service registry. Nothing in it is process-global, so two applications in one test do not share state.
|
||||
|
||||
Services are keyed by their Go type. A plugin publishes a value with `backpack.App.Publish` and another plugin reads it with `backpack.App.Lookup` and the same type argument. Publish under an interface type when consumers should not depend on your implementation:
|
||||
|
||||
```go src=modules/backpack/example_test.go#ExampleApp_Publish
|
||||
app := backpack.New(&compass.Config{})
|
||||
app.SetPlugins([]string{"acme.greeter", "acme.blog"})
|
||||
|
||||
// acme.greeter, in its Register step: publish under the interface type.
|
||||
var greeter Greeter = englishGreeter{}
|
||||
if err := app.Publish(greeter); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// acme.blog, in its Boot step: look the service up by the same type.
|
||||
if app.HasPlugin("acme.greeter") {
|
||||
if found, ok := app.Lookup[Greeter](); ok {
|
||||
fmt.Println(found.Greet("blog"))
|
||||
}
|
||||
}
|
||||
|
||||
// A second Publish under the same type is refused.
|
||||
fmt.Println(app.Publish(greeter) != nil)
|
||||
// Output:
|
||||
// Hello, blog
|
||||
// true
|
||||
```
|
||||
|
||||
## Capability interfaces
|
||||
|
||||
Beyond the four `party.Plugin` methods, a plugin declares what it contributes by implementing interfaces from [pact](../../modules/pact/README.md). The framework package that owns a capability finds it with a type assertion: surf asks for `pact.HasRoutes` and `pact.HasMiddleware`, lagoon for `pact.HasMigrations`, cabana for `pact.HasAdminControllers`, conga for `pact.HasJobs` and `pact.HasSchedule`. A plugin that does not implement an interface simply does not take part in that capability.
|
||||
|
||||
## Database-dependent boot work
|
||||
|
||||
Boot runs before any command opens the database: `migrate` and `serve` open it after activation, and commands such as `key:generate` never open it. Code that needs the database handle during boot, such as registering GORM callbacks, therefore goes through `lagoon.OnDatabase`. It runs the function immediately when the database is already published, and otherwise queues it until `lagoon.Publish` makes the shared `*sql.DB` and `*gorm.DB` handles available. An error from a queued function is returned by `lagoon.Publish`, so the command that opened the database fails instead of running with a half-registered plugin. [Extending plugins](../plugins/extending.md) shows where GORM callbacks fit.
|
||||
69
docs/architecture/go-modules-and-workspaces.md
Normal file
69
docs/architecture/go-modules-and-workspaces.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Go modules and workspaces
|
||||
description: Require the framework module, develop plugins as local modules in a Go workspace, list them in summer.yaml and fork a plugin with a replace directive.
|
||||
section: architecture
|
||||
order: 20
|
||||
---
|
||||
# Go modules and workspaces
|
||||
|
||||
WinterCMS uses Composer to pull in the framework and plugins. SummerCMS uses Go modules: the framework is one module, each plugin is its own module, and the application module requires them all.
|
||||
|
||||
## The framework module
|
||||
|
||||
The framework is the module `git.golem15.com/golem15/summercms`. Every framework package is imported from `git.golem15.com/golem15/summercms/modules/<name>`, for example `git.golem15.com/golem15/summercms/modules/party`.
|
||||
|
||||
An application requires the framework in its `go.mod`. While you work against a local checkout of the framework, point the requirement at it with a `replace` directive. For an application module `acme` in a directory next to the framework checkout:
|
||||
|
||||
```text
|
||||
module git.golem15.com/acme/acme
|
||||
|
||||
go 1.27.0
|
||||
|
||||
require git.golem15.com/golem15/summercms v0.0.0
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../summercms.go
|
||||
```
|
||||
|
||||
`summer make:plugin` copies this framework `replace` into the new plugin's `go.mod`, rewritten relative to the plugin directory, so the plugin builds against the same checkout.
|
||||
|
||||
## The summer.yaml manifest
|
||||
|
||||
The manifest in the application root names the application module, the binary `summer build` writes to `bin/`, and the plugins in activation order:
|
||||
|
||||
```yaml
|
||||
module: git.golem15.com/acme/acme
|
||||
binary: acme
|
||||
plugins:
|
||||
- id: acme.user
|
||||
module: git.golem15.com/acme/acme/plugins/user
|
||||
- id: acme.blog
|
||||
module: git.golem15.com/acme/acme/plugins/blog
|
||||
```
|
||||
|
||||
`summer build` turns this list into `plugins.gen.go`. The order is the manifest order, adjusted so that each plugin comes after the plugins it requires. Plugins with no dependency between them keep the order you wrote.
|
||||
|
||||
## Local plugins in a workspace
|
||||
|
||||
A plugin you develop inside the application lives in `plugins/<name>` as its own module. `summer make:plugin acme.blog` creates it there, and `summer plugin:add plugins/blog` registers it:
|
||||
|
||||
- it adds the plugin to `summer.yaml`;
|
||||
- it adds a `require` and a `replace` pointing at the local directory to the application's `go.mod`;
|
||||
- it adds the plugin directory to the nearest `go.work`, creating one in the application root when there is none.
|
||||
|
||||
With a `go.work` in place, `go build`, `go test` and your editor see every local plugin module together. `summer build` uses the nearest `go.work` it finds; without one it builds in module mode.
|
||||
|
||||
```sh
|
||||
summer make:plugin acme.blog
|
||||
summer plugin:add plugins/blog
|
||||
summer build
|
||||
```
|
||||
|
||||
## Replacing and forking a plugin
|
||||
|
||||
WinterCMS lets you replace a plugin by overriding its classes. In SummerCMS you fork the plugin's module and point the application at your fork with a `replace` directive. The plugin ID and the import path stay the same, so nothing else in the application changes:
|
||||
|
||||
```text
|
||||
replace git.golem15.com/acme/user => ../forks/user
|
||||
```
|
||||
|
||||
Keep the fork's plugin ID unchanged when it must stand in for the original, since other plugins require it by ID. If you want both to exist side by side, give the fork a new module path and a new ID and list it in `summer.yaml` instead. To extend a plugin without forking it, see [Extending plugins](../plugins/extending.md).
|
||||
41
docs/architecture/introduction.md
Normal file
41
docs/architecture/introduction.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Architecture introduction
|
||||
description: "How a SummerCMS application is built: one Go binary with compiled plugins, a headless JSON API, an embedded admin SPA and console commands."
|
||||
section: architecture
|
||||
order: 10
|
||||
---
|
||||
# Architecture introduction
|
||||
|
||||
A SummerCMS application is one Go binary. The framework modules, the application's plugins, the embedded admin SPA and every console command are compiled into it. You deploy that file and its `config/` directory; nothing is installed or loaded at runtime.
|
||||
|
||||
## One binary, compiled plugins
|
||||
|
||||
A plugin is a Go package that implements `party.Plugin` and registers itself from `init` with `party.Register`. The application lists the plugins it uses in its `summer.yaml` manifest. `summer build` reads that manifest, generates `plugins.gen.go` (a blank import for every plugin and the ordered `PluginIDs` list) and a `main.go`, then runs `go build`. Adding or removing a plugin is a rebuild, not a runtime switch.
|
||||
|
||||
This replaces the WinterCMS plugin directory scan. The compiler checks every plugin against the interfaces it claims to implement, and a missing dependency fails the build or the boot, never a later request.
|
||||
|
||||
## Headless by design
|
||||
|
||||
SummerCMS serves a JSON API and the admin SPA. It has no themes, CMS pages or frontend components: the public site is a separate application that calls the API and subscribes to realtime channels. The admin is a compiled Vue application that [boardwalk](../../modules/boardwalk/README.md) serves from the binary, and it reads the admin API that [cabana](../../modules/cabana/README.md) builds from each plugin's `fields.yaml` and `columns.yaml`.
|
||||
|
||||
## The framework modules
|
||||
|
||||
Each framework module is one Go package under `modules/`, documented by its README and its API reference page.
|
||||
|
||||
| Concern | Modules |
|
||||
|---------|---------|
|
||||
| Plugins and the container | [party](../../modules/party/README.md), [pact](../../modules/pact/README.md), [backpack](../../modules/backpack/README.md), [festival](../../modules/festival/README.md), [compass](../../modules/compass/README.md) |
|
||||
| HTTP | [surf](../../modules/surf/README.md), [towel](../../modules/towel/README.md), [wire](../../modules/wire/README.md), [bouncer](../../modules/bouncer/README.md), [wristband](../../modules/wristband/README.md), [fetchguard](../../modules/fetchguard/README.md) |
|
||||
| Data | [lagoon](../../modules/lagoon/README.md), [beachcomber](../../modules/beachcomber/README.md) |
|
||||
| Admin | [cabana](../../modules/cabana/README.md), [boardwalk](../../modules/boardwalk/README.md) |
|
||||
| Services | [phrasebook](../../modules/phrasebook/README.md), [postcard](../../modules/postcard/README.md), [conga](../../modules/conga/README.md), [lighthouse](../../modules/lighthouse/README.md), [flare](../../modules/flare/README.md) |
|
||||
| Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md) |
|
||||
|
||||
## What runs where
|
||||
|
||||
Two programs carry console commands:
|
||||
|
||||
- The `summer` tool is the developer CLI. You install it once with `go install ./cmd/summer`. It builds and watches applications (`summer build`, `summer dev`), scaffolds plugins and their parts (`summer make:plugin`, `summer make:model` and the other `make:` commands), records and replays API parity fixtures and builds these docs.
|
||||
- The application binary, for example `bin/hello`, carries the runtime commands: `serve`, `migrate`, `route:list`, `queue:work`, `admin:create` and the commands its plugins add. It is what you run in production.
|
||||
|
||||
Some `summer` commands, such as `summer migrate` and `summer serve`, run the matching command of the application binary in the current application directory, building it first when it is missing. The [Installation](../setup/installation.md) guide walks through both programs.
|
||||
82
docs/architecture/request-lifecycle.md
Normal file
82
docs/architecture/request-lifecycle.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Request lifecycle
|
||||
description: "How an HTTP request reaches a plugin handler: route collection, named middleware, constraints, body limits, CORS, recovery and request context values."
|
||||
section: architecture
|
||||
order: 40
|
||||
---
|
||||
# Request lifecycle
|
||||
|
||||
The `serve` command builds one `http.Handler` from every plugin's route declarations and serves it with the Go standard library. This page follows a request from the socket to your handler and back.
|
||||
|
||||
## Building the router
|
||||
|
||||
When `serve` starts, `surf.BuildRouter` collects the routes:
|
||||
|
||||
1. It registers the built-in middleware (`throttle`, `body.limit`, `locale.from-principal` and, when the admin is enabled, `backend`).
|
||||
2. It registers the named middleware of every plugin that implements `pact.HasMiddleware`, `pact.HasMiddlewareFactories` or `pact.HasHouseMiddleware`, and the rate-limit buckets of every `surf.BucketProvider`.
|
||||
3. It calls `pact.HasRoutes.Routes` on every plugin in activation order, passing a `pact.Router` that records groups, routes, middleware names and constraints.
|
||||
4. It mounts the cabana admin API and checks every route.
|
||||
|
||||
`surf.Assemble` then compiles the routes onto a standard library `http.ServeMux`. A duplicate route, an unknown middleware name or a malformed `throttle` parameter fails here, at boot, and `serve` exits with the error instead of serving a broken router. `./bin/acme route:list` builds the same router without opening the database and prints the route table.
|
||||
|
||||
## Declaring routes
|
||||
|
||||
A plugin declares routes the way a WinterCMS `routes.php` file does, with groups that share a prefix and middleware:
|
||||
|
||||
```php
|
||||
Route::group(['prefix' => 'api/blog', 'middleware' => ['auth']], function () {
|
||||
Route::get('posts/{id}', 'Acme\Blog\Http\Posts@show')->where('id', '[0-9]+');
|
||||
});
|
||||
```
|
||||
|
||||
In Go the same declaration is a `pact.HasRoutes` method. Paths use Go `http.ServeMux` patterns, `pact.Router.Where` and `pact.Router.WhereIn` constrain the last declared route, and middleware names are strings that must be registered by the time the router is built. The [surf](../../modules/surf/README.md) reference has the full route builder with rate-limit buckets and per-route body limits.
|
||||
|
||||
## The wrapping order
|
||||
|
||||
surf wraps every non-raw route in the same layers. From the outside in:
|
||||
|
||||
1. CORS, only for the paths configured under `http.cors.paths`, including preflight requests.
|
||||
2. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body.
|
||||
3. The request locale, taken from the `Accept-Language` header and stored in the context.
|
||||
4. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
|
||||
5. The route's middleware, in the order you listed them: group middleware first, then the route's own.
|
||||
6. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
|
||||
7. Your handler.
|
||||
|
||||
Raw groups, declared with `pact.Router.GroupRaw`, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500.
|
||||
|
||||
## Request context values
|
||||
|
||||
WinterCMS reads the current locale and user through facades. SummerCMS carries them on the request's `context.Context`. surf stores the locale with `towel.WithLocale`, the authentication middleware stores the signed-in principal, and your own middleware can add the organization or collection with `towel.WithOrganization` and `towel.WithCollection`. Any code that receives the context reads them back without a global lookup:
|
||||
|
||||
```go src=modules/towel/example_test.go#ExampleWithLocale
|
||||
// A middleware stores the organization once for the whole request.
|
||||
withAcme := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := towel.WithOrganization(r.Context(), "acme")
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// The handler reads the values back from its request context.
|
||||
listPosts := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
org, _ := towel.Organization(r.Context())
|
||||
locale, ok := towel.Locale(r.Context())
|
||||
if !ok {
|
||||
locale = "en"
|
||||
}
|
||||
fmt.Fprintf(w, "posts for %s in %s", org, locale)
|
||||
})
|
||||
|
||||
// surf sets the locale from Accept-Language; here the test sets it.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/blog/posts", nil)
|
||||
req = req.WithContext(towel.WithLocale(req.Context(), "pl"))
|
||||
rec := httptest.NewRecorder()
|
||||
withAcme(listPosts).ServeHTTP(rec, req)
|
||||
fmt.Println(rec.Body.String())
|
||||
// Output: posts for acme in pl
|
||||
```
|
||||
|
||||
## Writing responses
|
||||
|
||||
Handlers write JSON with `wire.WriteJSON`, which keeps bodies byte-compatible with a Laravel backend: HTML characters are not escaped and there is no trailing newline. `wire.Slice` turns a nil list into `[]`, `wire.Time` marshals timestamps in Carbon's `+00:00` form and `wire.TriBool` models a nullable boolean. Errors that should look like your API's error envelope go through the house middleware a plugin registers with `pact.HasHouseMiddleware`.
|
||||
70
docs/plugins/extending.md
Normal file
70
docs/plugins/extending.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Extending plugins
|
||||
description: Extend other plugins through typed events, published services, optional dependencies and GORM callbacks, and replace a plugin by forking its module.
|
||||
section: plugins
|
||||
order: 30
|
||||
---
|
||||
# Extending plugins
|
||||
|
||||
Plugins in WinterCMS extend each other by listening to events and by calling `extend` on another plugin's classes at runtime. Go has no runtime class extension, so SummerCMS gives you four explicit mechanisms: events, published services, optional dependencies and database callbacks. When none of them fits, you fork the plugin.
|
||||
|
||||
## Events
|
||||
|
||||
The event bus from [festival](../../modules/festival/README.md) is the Go form of `Event::listen` and `Event::fire`. Each application has one bus, `backpack.App.Events`. A plugin that wants to be extensible defines an event type and fires it; other plugins listen for that type from their Boot step.
|
||||
|
||||
Events are routed by Go type, not by a string name, so a listener for `PostPublished` receives exactly that type and a payload mismatch does not compile:
|
||||
|
||||
```go src=modules/festival/example_test.go#ExampleBus_Fire
|
||||
bus := festival.New() // in a plugin, use app.Events
|
||||
|
||||
// acme.search and acme.notify extend acme.blog from their Boot steps.
|
||||
bus.Listen("acme.search", func(ctx context.Context, e PostPublished) error {
|
||||
fmt.Println("index", e.Title)
|
||||
return nil
|
||||
})
|
||||
bus.ListenPriority("acme.notify", 10, func(ctx context.Context, e PostPublished) error {
|
||||
fmt.Println("notify subscribers of", e.Title)
|
||||
return nil
|
||||
})
|
||||
|
||||
// acme.blog fires the event; higher priorities run first.
|
||||
if err := bus.Fire(context.Background(), PostPublished{Title: "Hello"}); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
// Output:
|
||||
// notify subscribers of Hello
|
||||
// index Hello
|
||||
```
|
||||
|
||||
Each listener names the plugin that owns it, so an error or a recovered panic in a listener reports which plugin failed. The bus has three dispatch modes:
|
||||
|
||||
- `festival.Bus.Fire` runs every listener and returns their joined errors.
|
||||
- `festival.Bus.Collect` runs every listener and merges the payload each one adds, for events that gather contributions such as extra fields or menu items. The event implements `festival.Collectable`.
|
||||
- `festival.Bus.UntilHandled` stops at the first listener that handles the event, the WinterCMS halting fire. The event implements `festival.Handleable`.
|
||||
|
||||
`festival.Bus.ListenPriority` sets a priority: higher priorities run first, and equal priorities run in registration order.
|
||||
|
||||
## Services
|
||||
|
||||
A plugin that offers functionality to others publishes it on the container during Register with `backpack.App.Publish`, preferably under an interface type. Other plugins read it during Boot with `backpack.App.Lookup`. Because both sides use the same type, the consumer only imports the package that declares the interface, not the provider's internals. See [Application lifecycle](../architecture/application-lifecycle.md) for a complete example.
|
||||
|
||||
## Optional dependencies
|
||||
|
||||
A required dependency goes in `party.Plugin.Requires`, and activation fails when it is missing. For an integration that should work only when another plugin happens to be installed, check for it instead:
|
||||
|
||||
- `backpack.App.HasPlugin` reports whether a plugin ID is part of this build. It answers correctly during Register, before that plugin has booted.
|
||||
- `pact.OptionalMessage` is a small service an optional plugin can publish so others integrate with it without importing its package.
|
||||
|
||||
This replaces `PluginManager::exists` checks in WinterCMS.
|
||||
|
||||
## Model hooks and GORM callbacks
|
||||
|
||||
A model reacts to its own lifecycle with GORM hook methods such as `BeforeSave`; [lagoon](../../modules/lagoon/README.md) names them as interfaces (`lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete` and the rest) so you can assert them at compile time.
|
||||
|
||||
To react to another plugin's models, the equivalent of `Post::extend` with model events, register a GORM callback on the shared database handle. Boot runs before the database is open, so register it through `lagoon.OnDatabase`, which calls your function with the shared `*gorm.DB` once it is available. For work that must wait until the transaction commits, such as sending mail or publishing a realtime event, use `lagoon.AfterCommit`.
|
||||
|
||||
## Replacing a plugin
|
||||
|
||||
When an extension point is missing, fork the plugin's module and point the application at your copy with a `replace` directive in its `go.mod`, keeping the plugin ID. The rest of the application keeps importing and requiring the original path. [Go modules and workspaces](../architecture/go-modules-and-workspaces.md) shows the directive.
|
||||
|
||||
Prefer adding an event or a published service to the original plugin over a long-lived fork: a fork has to be kept in step with every change upstream.
|
||||
118
docs/plugins/registration.md
Normal file
118
docs/plugins/registration.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Plugin registration
|
||||
description: "Declare a plugin: its ID, the party.Plugin lifecycle, the pact capability interfaces it opts into, its embedded files and the scaffolded layout."
|
||||
section: plugins
|
||||
order: 10
|
||||
---
|
||||
# Plugin registration
|
||||
|
||||
Plugins are the foundation of every SummerCMS application. A plugin adds models, routes, admin screens, console commands, jobs and translations, and it can extend other plugins. This page covers how a plugin tells the framework what it contributes.
|
||||
|
||||
## Plugin identifiers
|
||||
|
||||
Every plugin has an ID in `vendor.plugin` form: two lower-case parts, each starting with a letter and containing only letters and digits, such as `acme.blog`. The ID is how the manifest lists the plugin, how other plugins require it and how its configuration is namespaced (`acme.blog.posts_per_page`). WinterCMS writes the same identifier as `Acme.Blog`; in SummerCMS it is always lower case.
|
||||
|
||||
The scaffolder derives the package and directory name from the second part, so `summer make:plugin acme.blog` creates `plugins/blog` with `package blog`.
|
||||
|
||||
## The plugin type
|
||||
|
||||
A plugin is a Go type that implements `party.Plugin`. Its package registers it from `init` with `party.Register`, so importing the package is enough to make the plugin available; the generated `plugins.gen.go` does that import for every plugin in `summer.yaml`.
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `party.Plugin.ID` | Returns the plugin ID. |
|
||||
| `party.Plugin.Requires` | Lists the IDs of plugins that must register and boot before this one, like `$require` in WinterCMS. |
|
||||
| `party.Plugin.Register` | Runs before any plugin boots. Publish services on the container here. |
|
||||
| `party.Plugin.Boot` | Runs after every plugin registered. Listen to events and use other plugins' services here. |
|
||||
|
||||
Here is a complete plugin that also declares a backend permission:
|
||||
|
||||
```go src=modules/party/example_plugin_test.go
|
||||
package party_test
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// BlogPlugin is the acme.blog plugin: the Go form of a WinterCMS Plugin.php.
|
||||
// A real plugin package also registers it from init with
|
||||
// party.Register(&BlogPlugin{}).
|
||||
type BlogPlugin struct{}
|
||||
|
||||
// The optional capabilities the plugin opts into, checked at compile time.
|
||||
var _ pact.HasPermissions = (*BlogPlugin)(nil)
|
||||
|
||||
// ID is the plugin identifier in vendor.plugin form.
|
||||
func (p *BlogPlugin) ID() string { return "acme.blog" }
|
||||
|
||||
// Requires lists the plugins that must register and boot first ($require).
|
||||
func (p *BlogPlugin) Requires() []string { return []string{"acme.user"} }
|
||||
|
||||
// Register runs before any plugin boots: publish services here.
|
||||
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
|
||||
|
||||
// Boot runs after every plugin registered: listen to events and look up
|
||||
// services other plugins published.
|
||||
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
|
||||
|
||||
// Permissions replaces registerPermissions().
|
||||
func (p *BlogPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{
|
||||
{Code: "acme.blog.access_posts", Tab: "Blog", Label: "Manage posts"},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `var _ pact.HasPermissions = (*BlogPlugin)(nil)` line is a compile-time check: if a method is missing or has the wrong signature, the build fails instead of the capability being silently ignored. Add one such line for every capability your plugin implements.
|
||||
|
||||
## Capability interfaces
|
||||
|
||||
A WinterCMS plugin overrides `register*` methods of `PluginBase`. A SummerCMS plugin implements small interfaces from [pact](../../modules/pact/README.md) instead, and the framework discovers each one with a type assertion.
|
||||
|
||||
| Interface | Contributes |
|
||||
|-----------|-------------|
|
||||
| `pact.HasRoutes` | HTTP routes, declared on a `pact.Router`. |
|
||||
| `pact.HasMiddleware`, `pact.HasMiddlewareFactories` | Named and parameterized route middleware. |
|
||||
| `pact.HasConfig` | Default configuration, merged under the plugin ID. |
|
||||
| `pact.HasMigrations` | An ordered set of database migrations. |
|
||||
| `pact.HasCommands` | Console commands for the application binary. |
|
||||
| `pact.HasJobs` | Background jobs. |
|
||||
| `pact.HasSchedule` | Console commands that run on a schedule; see [Scheduling](scheduling.md). |
|
||||
| `pact.HasLang`, `pact.HasLangOverrides` | Translations, and overrides of other namespaces. |
|
||||
| `pact.HasMailTemplates` | Mail templates and layouts. |
|
||||
| `pact.HasPermissions`, `pact.HasNavigation`, `pact.HasSettings` | Backend permissions, navigation and settings screens. |
|
||||
| `pact.HasAdminControllers` | Admin controllers built from `fields.yaml` and `columns.yaml`. |
|
||||
| `pact.HasModels` | The plugin's GORM models. No framework package reads it yet. |
|
||||
|
||||
## Embedded files
|
||||
|
||||
Configuration defaults, translations and mail templates ship inside the binary through Go's `embed` package. The plugin returns an `fs.FS` for each:
|
||||
|
||||
- `pact.HasConfig.ConfigFS` returns a tree with `config/config.yaml`. Its keys become `<plugin id>.<key>`, and any other `config/<name>.yaml` becomes `<plugin id>.<name>.<key>`. The application's own `config/` directory and `SUMMER_` variables override them.
|
||||
- `pact.HasLang.LangFS` returns `lang/<locale>/<group>.yaml` files.
|
||||
- `pact.HasMailTemplates.MailTemplatesFS` returns the `views/mail` templates, and `pact.HasMailTemplates.MailTemplates` lists their names.
|
||||
|
||||
## The scaffolded layout
|
||||
|
||||
`summer make:plugin acme.blog` writes a plugin that compiles and follows the WinterCMS directory layout, with each directory as a Go subpackage:
|
||||
|
||||
```text
|
||||
plugins/blog/
|
||||
├── go.mod the plugin module, requiring the framework
|
||||
├── plugin.go the Plugin type, its capabilities and init registration
|
||||
├── routes.go the Routes method
|
||||
├── registry.gen.go generated lists of models, migrations, commands, jobs and admin controllers
|
||||
├── classes/ services and hooks
|
||||
├── config/config.yaml default configuration
|
||||
├── console/ console commands
|
||||
├── controllers/ HTTP handlers and admin controllers
|
||||
├── jobs/ background jobs
|
||||
├── lang/en/lang.yaml translations
|
||||
├── middleware/ named middleware
|
||||
├── models/ GORM models
|
||||
├── updates/ migrations
|
||||
└── views/mail/ mail templates
|
||||
```
|
||||
|
||||
The `make:` commands, such as `summer make:model acme.blog Post`, add files to these directories and regenerate `registry.gen.go`, so you do not edit that file by hand. The capability methods in `plugin.go` return the generated lists, so a new model, migration, command, job or admin controller is picked up without editing the plugin type.
|
||||
83
docs/plugins/scheduling.md
Normal file
83
docs/plugins/scheduling.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Task scheduling
|
||||
description: Run a plugin's console commands on a schedule with pact.HasSchedule, and run the scheduler in the worker, as its own process or from system cron.
|
||||
section: plugins
|
||||
order: 20
|
||||
---
|
||||
# Task scheduling
|
||||
|
||||
WinterCMS plugins schedule work in `registerSchedule`. A SummerCMS plugin declares the same thing by implementing `pact.HasSchedule`: it returns a list of its registered console commands, each with the arguments to pass and how often to run it. Only these compiled entries ever run; there is no way to schedule an arbitrary command at runtime.
|
||||
|
||||
## Defining schedules
|
||||
|
||||
Each entry is a `pact.ScheduledCommand`: the command name in `namespace:verb` form, its arguments and a `pact.Cadence`. The command must be registered by some plugin through `pact.HasCommands`.
|
||||
|
||||
```go src=modules/pact/example_test.go#BlogPlugin.Schedule
|
||||
// Schedule runs three of the plugin's registered console commands.
|
||||
func (p *BlogPlugin) Schedule() []pact.ScheduledCommand {
|
||||
return []pact.ScheduledCommand{
|
||||
{Command: "blog:prune-drafts", Cadence: pact.Daily()},
|
||||
{Command: "blog:send-digest", Cadence: pact.DailyAt(7, 30)},
|
||||
{Command: "blog:sync-feed", Args: []string{"--quiet"}, Cadence: pact.Every(15 * time.Minute)},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Build a cadence with one of three functions:
|
||||
|
||||
| Function | Runs | Laravel equivalent |
|
||||
|----------|------|--------------------|
|
||||
| `pact.Daily` | Every day at 00:00. | `->daily()` |
|
||||
| `pact.DailyAt` | Every day at the given hour and minute. | `->dailyAt('07:30')` |
|
||||
| `pact.Every` | At every multiple of the interval since midnight, so `pact.Every(15 * time.Minute)` runs at :00, :15, :30 and :45. | `->everyFifteenMinutes()` |
|
||||
|
||||
Times are wall-clock times in the `app.timezone` location (UTC when it is not set). On a daylight saving day a daily entry keeps its wall-clock time.
|
||||
|
||||
The scheduler checks every entry when a worker starts, and the start fails with an error naming the plugin ID and the entry index when:
|
||||
|
||||
- the command name is empty or the cadence is the zero `pact.Cadence`;
|
||||
- a daily hour or minute is out of range;
|
||||
- an `pact.Every` interval is shorter than one second or does not divide 24 hours evenly.
|
||||
|
||||
A command that no plugin registers does not fail the start: each run logs a warning and is skipped.
|
||||
|
||||
You can inspect a cadence with `pact.Cadence.At` (the hour and minute of a daily cadence) and `pact.Cadence.Interval` (24 hours for a daily cadence). This example prints the entries above:
|
||||
|
||||
```go src=modules/pact/example_test.go#ExampleHasSchedule
|
||||
var plugin pact.HasSchedule = &BlogPlugin{}
|
||||
for _, entry := range plugin.Schedule() {
|
||||
if hour, minute, daily := entry.Cadence.At(); daily {
|
||||
fmt.Printf("%s %v: daily at %02d:%02d\n", entry.Command, entry.Args, hour, minute)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s %v: every %s\n", entry.Command, entry.Args, entry.Cadence.Interval())
|
||||
}
|
||||
// Output:
|
||||
// blog:prune-drafts []: daily at 00:00
|
||||
// blog:send-digest []: daily at 07:30
|
||||
// blog:sync-feed [--quiet]: every 15m0s
|
||||
```
|
||||
|
||||
## How schedules run
|
||||
|
||||
The schedule runs inside the background job worker from [conga](../../modules/conga/README.md). Every worker turns each entry into a periodic job with the ID `<plugin id>[<index>]:<command>`, for example `acme.blog[0]:blog:prune-drafts`. When several instances of the application run, one worker is elected leader and only the leader enqueues due runs, so each period runs once across all instances, even when the leader changes mid-period.
|
||||
|
||||
Each run is a job on the `scheduled` queue with a single attempt: an interrupted run is not retried, and the next period runs normally. The worker calls the command in-process and logs its output line by line.
|
||||
|
||||
The worker runs in `serve` by default. When you run workers separately (`queue.work_in_serve` set to `false`), `./bin/acme queue:work` carries the schedule too.
|
||||
|
||||
## Running the scheduler on its own
|
||||
|
||||
To run only the scheduler in its own process, use `schedule:run`. It starts a worker on the `scheduled` queue and runs until it receives SIGINT or SIGTERM:
|
||||
|
||||
```sh
|
||||
./bin/acme schedule:run
|
||||
```
|
||||
|
||||
If you prefer system cron, as in Laravel, use `schedule:run --once` every minute. It runs, without the job queue, every entry that is due in the current minute and exits:
|
||||
|
||||
```sh
|
||||
* * * * * cd /srv/acme && ./bin/acme schedule:run --once
|
||||
```
|
||||
|
||||
`schedule:run --once` has no overlap lock: two runs in the same minute run the due entries twice, as Laravel does. From the application directory during development, `summer schedule:run --once` runs the same command through the built binary.
|
||||
48
docs/plugins/testing.md
Normal file
48
docs/plugins/testing.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Testing plugins
|
||||
description: Test plugins with go test, run database tests against real PostgreSQL containers, replay API parity fixtures and keep documentation examples running.
|
||||
section: plugins
|
||||
order: 40
|
||||
---
|
||||
# Testing plugins
|
||||
|
||||
SummerCMS uses the standard Go test tooling. There is no separate test runner and no PHPUnit bootstrap: a plugin's tests are `_test.go` files next to its code, and `go test` runs them.
|
||||
|
||||
## Running tests
|
||||
|
||||
Run every test in the module from its root:
|
||||
|
||||
```sh
|
||||
go vet ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Tests that need Docker, such as database tests, skip themselves in short mode. Use it for a fast loop:
|
||||
|
||||
```sh
|
||||
go test -short ./...
|
||||
```
|
||||
|
||||
In an application with local plugins in a `go.work` workspace, run the tests of one plugin by its directory, for example `go test ./plugins/blog/...`.
|
||||
|
||||
## Unit tests without a database
|
||||
|
||||
Most plugin code runs without a database. Build a container with `backpack.New`, call your plugin's Register and Boot, and assert on what it published. Drive HTTP handlers with `net/http/httptest`: `surf.Assemble` builds the same handler `serve` uses, so a test can send requests to your routes without listening on a port. Call console commands in-process with `bonfire.Call`.
|
||||
|
||||
## Database tests
|
||||
|
||||
SummerCMS supports PostgreSQL only, so database tests run against real PostgreSQL rather than an SQLite stand-in. The framework's own tests start a `postgres:16-alpine` container through testcontainers-go and create the database with the ICU `pl-PL` locale that lagoon checks for when it connects. Follow the same pattern in plugin tests:
|
||||
|
||||
- skip the test when `testing.Short` reports true;
|
||||
- create the database with `LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'`, or lagoon refuses the connection;
|
||||
- migrate a fresh database per test with `lagoon.Migrate`, so tests do not depend on each other.
|
||||
|
||||
Docker must be running for these tests.
|
||||
|
||||
## API parity tests
|
||||
|
||||
When you port an existing backend, its real responses are the acceptance test. [tide](../../modules/tide/README.md) records request and response fixtures from the reference backend and replays them against your port, reporting differences after masking IDs and timestamps. The `summer parity:record`, `summer parity:replay`, `summer parity:proxy` and `summer parity:broadcasts` commands wrap it.
|
||||
|
||||
## Examples in the documentation
|
||||
|
||||
Every Go code block in these docs is a copy of an `Example` function or a marked region of a test that `go test ./...` runs. If you change a framework API and forget an example, `go test` fails. Write your plugin's examples the same way: an `Example` function with an `// Output:` comment is compiled, run and compared by `go test`, so it cannot go stale.
|
||||
@@ -13,5 +13,9 @@ llms_notes:
|
||||
sections:
|
||||
- name: setup
|
||||
title: Setup
|
||||
- name: architecture
|
||||
title: Architecture
|
||||
- name: plugins
|
||||
title: Plugins
|
||||
- name: api
|
||||
title: API reference
|
||||
|
||||
42
modules/backpack/example_test.go
Normal file
42
modules/backpack/example_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package backpack_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// Greeter is a service the acme.greeter plugin offers to other plugins.
|
||||
type Greeter interface {
|
||||
Greet(name string) string
|
||||
}
|
||||
|
||||
type englishGreeter struct{}
|
||||
|
||||
func (englishGreeter) Greet(name string) string { return "Hello, " + name }
|
||||
|
||||
func ExampleApp_Publish() {
|
||||
app := backpack.New(&compass.Config{})
|
||||
app.SetPlugins([]string{"acme.greeter", "acme.blog"})
|
||||
|
||||
// acme.greeter, in its Register step: publish under the interface type.
|
||||
var greeter Greeter = englishGreeter{}
|
||||
if err := app.Publish(greeter); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// acme.blog, in its Boot step: look the service up by the same type.
|
||||
if app.HasPlugin("acme.greeter") {
|
||||
if found, ok := app.Lookup[Greeter](); ok {
|
||||
fmt.Println(found.Greet("blog"))
|
||||
}
|
||||
}
|
||||
|
||||
// A second Publish under the same type is refused.
|
||||
fmt.Println(app.Publish(greeter) != nil)
|
||||
// Output:
|
||||
// Hello, blog
|
||||
// true
|
||||
}
|
||||
35
modules/festival/example_test.go
Normal file
35
modules/festival/example_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package festival_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/festival"
|
||||
)
|
||||
|
||||
// PostPublished is the event the acme.blog plugin fires after a post goes live.
|
||||
type PostPublished struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
func ExampleBus_Fire() {
|
||||
bus := festival.New() // in a plugin, use app.Events
|
||||
|
||||
// acme.search and acme.notify extend acme.blog from their Boot steps.
|
||||
bus.Listen("acme.search", func(ctx context.Context, e PostPublished) error {
|
||||
fmt.Println("index", e.Title)
|
||||
return nil
|
||||
})
|
||||
bus.ListenPriority("acme.notify", 10, func(ctx context.Context, e PostPublished) error {
|
||||
fmt.Println("notify subscribers of", e.Title)
|
||||
return nil
|
||||
})
|
||||
|
||||
// acme.blog fires the event; higher priorities run first.
|
||||
if err := bus.Fire(context.Background(), PostPublished{Title: "Hello"}); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
// Output:
|
||||
// notify subscribers of Hello
|
||||
// index Hello
|
||||
}
|
||||
37
modules/pact/example_test.go
Normal file
37
modules/pact/example_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package pact_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// BlogPlugin is the acme.blog plugin; only its schedule is shown here.
|
||||
type BlogPlugin struct{}
|
||||
|
||||
var _ pact.HasSchedule = (*BlogPlugin)(nil)
|
||||
|
||||
// Schedule runs three of the plugin's registered console commands.
|
||||
func (p *BlogPlugin) Schedule() []pact.ScheduledCommand {
|
||||
return []pact.ScheduledCommand{
|
||||
{Command: "blog:prune-drafts", Cadence: pact.Daily()},
|
||||
{Command: "blog:send-digest", Cadence: pact.DailyAt(7, 30)},
|
||||
{Command: "blog:sync-feed", Args: []string{"--quiet"}, Cadence: pact.Every(15 * time.Minute)},
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleHasSchedule() {
|
||||
var plugin pact.HasSchedule = &BlogPlugin{}
|
||||
for _, entry := range plugin.Schedule() {
|
||||
if hour, minute, daily := entry.Cadence.At(); daily {
|
||||
fmt.Printf("%s %v: daily at %02d:%02d\n", entry.Command, entry.Args, hour, minute)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s %v: every %s\n", entry.Command, entry.Args, entry.Cadence.Interval())
|
||||
}
|
||||
// Output:
|
||||
// blog:prune-drafts []: daily at 00:00
|
||||
// blog:send-digest []: daily at 07:30
|
||||
// blog:sync-feed [--quiet]: every 15m0s
|
||||
}
|
||||
37
modules/towel/example_test.go
Normal file
37
modules/towel/example_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package towel_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
func ExampleWithLocale() {
|
||||
// A middleware stores the organization once for the whole request.
|
||||
withAcme := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := towel.WithOrganization(r.Context(), "acme")
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// The handler reads the values back from its request context.
|
||||
listPosts := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
org, _ := towel.Organization(r.Context())
|
||||
locale, ok := towel.Locale(r.Context())
|
||||
if !ok {
|
||||
locale = "en"
|
||||
}
|
||||
fmt.Fprintf(w, "posts for %s in %s", org, locale)
|
||||
})
|
||||
|
||||
// surf sets the locale from Accept-Language; here the test sets it.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/blog/posts", nil)
|
||||
req = req.WithContext(towel.WithLocale(req.Context(), "pl"))
|
||||
rec := httptest.NewRecorder()
|
||||
withAcme(listPosts).ServeHTTP(rec, req)
|
||||
fmt.Println(rec.Body.String())
|
||||
// Output: posts for acme in pl
|
||||
}
|
||||
Reference in New Issue
Block a user