From 41a3190956aa522e843abfdfd99947e790bdd3b7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 30 Sep 2026 23:28:41 +0200 Subject: [PATCH] feat(11.1-05): add the acme.blog walkthrough plugin with its model, migration and posts route - docs/examples/blog: scaffolder output for acme.blog (make:plugin, make:model) in the root module, with a Post model, a fill allow-list and NewPost, the create migration and GET /api/blog/posts paginated through lagoon - short tests activate the plugin, check the route with surf, the fill allow-list and the migration order - docs/setup/porting-a-plugin.md: registration, model, migrations and routes sections with src= copies of the plugin - TestDocsRequiredPages requires setup/porting-a-plugin --- cmd/summer/docs_test.go | 1 + docs/examples/blog/blog_test.go | 97 +++++ docs/examples/blog/classes/doc.go | 2 + docs/examples/blog/config/config.yaml | 3 + docs/examples/blog/console/doc.go | 2 + docs/examples/blog/controllers/doc.go | 2 + docs/examples/blog/jobs/doc.go | 2 + docs/examples/blog/lang/en/lang.yaml | 1 + docs/examples/blog/middleware/doc.go | 2 + docs/examples/blog/models/doc.go | 2 + docs/examples/blog/models/post.go | 37 ++ docs/examples/blog/models/post_test.go | 44 +++ docs/examples/blog/plugin.go | 68 ++++ docs/examples/blog/registry.gen.go | 35 ++ docs/examples/blog/routes.go | 76 ++++ .../20260101000000_create_acme_blog_posts.go | 28 ++ docs/examples/blog/updates/doc.go | 2 + docs/examples/blog/updates/updates_test.go | 57 +++ docs/examples/blog/views/mail/welcome.htm | 4 + docs/setup/porting-a-plugin.md | 333 ++++++++++++++++++ 20 files changed, 798 insertions(+) create mode 100644 docs/examples/blog/blog_test.go create mode 100644 docs/examples/blog/classes/doc.go create mode 100644 docs/examples/blog/config/config.yaml create mode 100644 docs/examples/blog/console/doc.go create mode 100644 docs/examples/blog/controllers/doc.go create mode 100644 docs/examples/blog/jobs/doc.go create mode 100644 docs/examples/blog/lang/en/lang.yaml create mode 100644 docs/examples/blog/middleware/doc.go create mode 100644 docs/examples/blog/models/doc.go create mode 100644 docs/examples/blog/models/post.go create mode 100644 docs/examples/blog/models/post_test.go create mode 100644 docs/examples/blog/plugin.go create mode 100644 docs/examples/blog/registry.gen.go create mode 100644 docs/examples/blog/routes.go create mode 100644 docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go create mode 100644 docs/examples/blog/updates/doc.go create mode 100644 docs/examples/blog/updates/updates_test.go create mode 100644 docs/examples/blog/views/mail/welcome.htm create mode 100644 docs/setup/porting-a-plugin.md diff --git a/cmd/summer/docs_test.go b/cmd/summer/docs_test.go index 44136bf..1a2d479 100644 --- a/cmd/summer/docs_test.go +++ b/cmd/summer/docs_test.go @@ -124,6 +124,7 @@ var requiredPages = []string{ "services/search", "services/parity-testing", "services/frontend-and-ajax", + "setup/porting-a-plugin", } // sectionOrder is the D-08 sidebar order: WinterCMS's documentation order, diff --git a/docs/examples/blog/blog_test.go b/docs/examples/blog/blog_test.go new file mode 100644 index 0000000..9c3f626 --- /dev/null +++ b/docs/examples/blog/blog_test.go @@ -0,0 +1,97 @@ +package blog_test + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "git.golem15.com/golem15/summercms/docs/examples/blog" + "git.golem15.com/golem15/summercms/docs/examples/blog/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" +) + +// activate boots acme.blog the way the generated main does: an application +// config directory with the required HTTP limits, then party.Activate with +// the plugin ID. +func activate(t *testing.T) (*backpack.App, party.Plugin) { + t.Helper() + dir := t.TempDir() + httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n" + if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte(httpYAML), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Env: "testing", Environ: []string{}}) + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + plugins, err := party.Activate(app, []string{"acme.blog"}) + if err != nil { + t.Fatalf("Activate: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("Activate returned %d plugins, want 1", len(plugins)) + } + return app, plugins[0] +} + +func TestPluginActivates(t *testing.T) { + app, p := activate(t) + if p.ID() != "acme.blog" { + t.Fatalf("ID = %q, want acme.blog", p.ID()) + } + if _, ok := p.(*blog.Plugin); !ok { + t.Fatalf("activated plugin is %T, want *blog.Plugin", p) + } + if len(p.Requires()) != 0 { + t.Errorf("Requires = %v, want none", p.Requires()) + } + if _, ok := p.(pact.HasRoutes); !ok { + t.Error("plugin does not implement pact.HasRoutes") + } + if _, ok := p.(pact.HasConfig); !ok { + t.Error("plugin does not implement pact.HasConfig") + } + if _, ok := p.(pact.HasLang); !ok { + t.Error("plugin does not implement pact.HasLang") + } + hasModels, ok := p.(pact.HasModels) + if !ok { + t.Fatal("plugin does not implement pact.HasModels") + } + if got := hasModels.Models(); len(got) != 1 { + t.Errorf("Models = %v, want one model", got) + } else if _, ok := got[0].(*models.Post); !ok { + t.Errorf("Models()[0] is %T, want *models.Post", got[0]) + } + hasMigrations, ok := p.(pact.HasMigrations) + if !ok { + t.Fatal("plugin does not implement pact.HasMigrations") + } + if len(hasMigrations.Migrations()) == 0 { + t.Error("Migrations is empty") + } + if got := app.Config.Int("acme.blog.per_page"); got != 15 { + t.Errorf("acme.blog.per_page = %d, want the plugin default 15", got) + } +} + +func TestRoutesRegistered(t *testing.T) { + app, p := activate(t) + router, err := surf.BuildRouter(app, []party.Plugin{p}) + if err != nil { + t.Fatalf("BuildRouter: %v", err) + } + routes := router.Routes() + found := slices.ContainsFunc(routes, func(r surf.RouteInfo) bool { + return r.Method == "GET" && r.Pattern == "/api/blog/posts" && r.PluginID == "acme.blog" + }) + if !found { + t.Fatalf("GET /api/blog/posts is not registered for acme.blog: %+v", routes) + } +} diff --git a/docs/examples/blog/classes/doc.go b/docs/examples/blog/classes/doc.go new file mode 100644 index 0000000..bd0c061 --- /dev/null +++ b/docs/examples/blog/classes/doc.go @@ -0,0 +1,2 @@ +// Package classes holds services and hooks for the acme.blog plugin. +package classes diff --git a/docs/examples/blog/config/config.yaml b/docs/examples/blog/config/config.yaml new file mode 100644 index 0000000..d288e7a --- /dev/null +++ b/docs/examples/blog/config/config.yaml @@ -0,0 +1,3 @@ +# Defaults for acme.blog, merged under the plugin ID: the application reads +# this value as acme.blog.per_page and can override it in its own config. +per_page: 15 diff --git a/docs/examples/blog/console/doc.go b/docs/examples/blog/console/doc.go new file mode 100644 index 0000000..61c5182 --- /dev/null +++ b/docs/examples/blog/console/doc.go @@ -0,0 +1,2 @@ +// Package console holds bonfire commands for the acme.blog plugin. +package console diff --git a/docs/examples/blog/controllers/doc.go b/docs/examples/blog/controllers/doc.go new file mode 100644 index 0000000..3fb6802 --- /dev/null +++ b/docs/examples/blog/controllers/doc.go @@ -0,0 +1,2 @@ +// Package controllers holds HTTP handlers and admin controllers for the acme.blog plugin. +package controllers diff --git a/docs/examples/blog/jobs/doc.go b/docs/examples/blog/jobs/doc.go new file mode 100644 index 0000000..93bdb37 --- /dev/null +++ b/docs/examples/blog/jobs/doc.go @@ -0,0 +1,2 @@ +// Package jobs holds background jobs for the acme.blog plugin. +package jobs diff --git a/docs/examples/blog/lang/en/lang.yaml b/docs/examples/blog/lang/en/lang.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/docs/examples/blog/lang/en/lang.yaml @@ -0,0 +1 @@ +{} diff --git a/docs/examples/blog/middleware/doc.go b/docs/examples/blog/middleware/doc.go new file mode 100644 index 0000000..0e64dd6 --- /dev/null +++ b/docs/examples/blog/middleware/doc.go @@ -0,0 +1,2 @@ +// Package middleware holds named HTTP middleware for the acme.blog plugin. +package middleware diff --git a/docs/examples/blog/models/doc.go b/docs/examples/blog/models/doc.go new file mode 100644 index 0000000..814e15d --- /dev/null +++ b/docs/examples/blog/models/doc.go @@ -0,0 +1,2 @@ +// Package models holds GORM models for the acme.blog plugin. +package models diff --git a/docs/examples/blog/models/post.go b/docs/examples/blog/models/post.go new file mode 100644 index 0000000..1e6763e --- /dev/null +++ b/docs/examples/blog/models/post.go @@ -0,0 +1,37 @@ +// Code generated by summer make. DO NOT EDIT. + +package models + +import ( + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" +) + +// Post is a blog post, the Go form of the WinterCMS Acme\Blog\Models\Post +// model. +type Post struct { + ID uint `gorm:"column:id;primaryKey"` + Title string `gorm:"column:title"` + Slug string `gorm:"column:slug"` + Body string `gorm:"column:body"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +// TableName keeps the WinterCMS table name. +func (Post) TableName() string { return "acme_blog_posts" } + +// Fillable is the Go form of $fillable: the only columns lagoon.Fill may +// set from a request. +func (Post) Fillable() []string { return []string{"title", "slug", "body"} } + +// NewPost is the Go form of Post::make($input): it copies only the fillable +// keys of input onto a new post and drops the rest, such as id. +func NewPost(input map[string]any) (*Post, error) { + post := &Post{} + if err := lagoon.Fill(post, post.Fillable(), input, true); err != nil { + return nil, err + } + return post, nil +} diff --git a/docs/examples/blog/models/post_test.go b/docs/examples/blog/models/post_test.go new file mode 100644 index 0000000..95395b0 --- /dev/null +++ b/docs/examples/blog/models/post_test.go @@ -0,0 +1,44 @@ +package models_test + +import ( + "slices" + "testing" + + "git.golem15.com/golem15/summercms/docs/examples/blog/models" + "git.golem15.com/golem15/summercms/modules/lagoon" +) + +var _ lagoon.HasFillable = models.Post{} + +func TestPostTableAndFill(t *testing.T) { + if got := (models.Post{}).TableName(); got != "acme_blog_posts" { + t.Fatalf("TableName = %q, want acme_blog_posts", got) + } + if got, want := (models.Post{}).Fillable(), []string{"title", "slug", "body"}; !slices.Equal(got, want) { + t.Fatalf("Fillable = %v, want %v", got, want) + } + + input := map[string]any{"id": 99, "title": "Hello", "slug": "hello-world", "body": "First post.", "created_at": "2020-01-01"} + post, err := models.NewPost(input) + if err != nil { + t.Fatalf("NewPost: %v", err) + } + if post.ID != 0 { + t.Errorf("ID = %d, want 0: id is not fillable", post.ID) + } + if !post.CreatedAt.IsZero() { + t.Errorf("CreatedAt = %v, want zero: created_at is not fillable", post.CreatedAt) + } + if post.Title != "Hello" || post.Slug != "hello-world" || post.Body != "First post." { + t.Errorf("post = %+v, want the title, slug and body from input", post) + } + + // lagoon.Fill with the same allow-list, as NewPost does, drops id too. + var direct models.Post + if err := lagoon.Fill(&direct, direct.Fillable(), map[string]any{"id": 7, "title": "Direct"}, true); err != nil { + t.Fatalf("Fill: %v", err) + } + if direct.ID != 0 || direct.Title != "Direct" { + t.Errorf("Fill result = %+v, want ID 0 and title Direct", direct) + } +} diff --git a/docs/examples/blog/plugin.go b/docs/examples/blog/plugin.go new file mode 100644 index 0000000..36c3b81 --- /dev/null +++ b/docs/examples/blog/plugin.go @@ -0,0 +1,68 @@ +package blog + +import ( + "embed" + "io/fs" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/go-gormigrate/gormigrate/v2" +) + +var ( + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) + _ pact.HasMailTemplates = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasCommands = (*Plugin)(nil) + _ pact.HasJobs = (*Plugin)(nil) + _ pact.HasAdminControllers = (*Plugin)(nil) +) + +//go:embed config +var configFS embed.FS + +//go:embed lang +var langFS embed.FS + +//go:embed views/mail +var mailFS embed.FS + +// Plugin is the acme.blog plugin, the Go form of Plugin.php. +type Plugin struct { + app *backpack.App +} + +func (p *Plugin) ID() string { return "acme.blog" } +func (p *Plugin) Requires() []string { return nil } + +func (p *Plugin) Register(*backpack.App) error { return nil } + +// Boot keeps the application, so route handlers and commands can reach its +// services, such as the database, when they run. +func (p *Plugin) Boot(app *backpack.App) error { + p.app = app + return nil +} + +func (p *Plugin) ConfigFS() fs.FS { return configFS } +func (p *Plugin) LangFS() fs.FS { return langFS } + +func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS } +func (p *Plugin) MailTemplates() []string { return nil } +func (p *Plugin) MailLayouts() map[string]string { return nil } + +func (p *Plugin) Models() []any { return generatedModels() } +func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() } +func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() } +func (p *Plugin) Jobs() []pact.Job { return generatedJobs() } +func (p *Plugin) AdminControllers() []pact.AdminController { + return generatedAdminControllers() +} + +func init() { + party.Register(&Plugin{}) +} diff --git a/docs/examples/blog/registry.gen.go b/docs/examples/blog/registry.gen.go new file mode 100644 index 0000000..e6a1a32 --- /dev/null +++ b/docs/examples/blog/registry.gen.go @@ -0,0 +1,35 @@ +// Code generated by summer make. DO NOT EDIT. + +package blog + +import ( + "git.golem15.com/golem15/summercms/docs/examples/blog/models" + "git.golem15.com/golem15/summercms/docs/examples/blog/updates" + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/pact" + "github.com/go-gormigrate/gormigrate/v2" +) + +func generatedModels() []any { + return []any{ + &models.Post{}, + } +} + +func generatedMigrations() []*gormigrate.Migration { + return []*gormigrate.Migration{ + updates.CreatePosts(), + } +} + +func generatedCommands() []bonfire.Command { + return nil +} + +func generatedJobs() []pact.Job { + return nil +} + +func generatedAdminControllers() []pact.AdminController { + return nil +} diff --git a/docs/examples/blog/routes.go b/docs/examples/blog/routes.go new file mode 100644 index 0000000..f4261ff --- /dev/null +++ b/docs/examples/blog/routes.go @@ -0,0 +1,76 @@ +package blog + +import ( + "net/http" + "strconv" + "time" + + "git.golem15.com/golem15/summercms/docs/examples/blog/models" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +var _ pact.HasRoutes = (*Plugin)(nil) + +// Routes replaces routes.php. +func (p *Plugin) Routes(r pact.Router) error { + r.Get("/api/blog/posts", p.listPosts) + return nil +} + +// postJSON is the response shape of one post. It is built field by field, +// so a column added to the model never leaks into the API. +type postJSON struct { + ID uint `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Body string `json:"body"` + CreatedAt wire.Time `json:"created_at"` +} + +// listPosts answers GET /api/blog/posts?page=N&per_page=M with one page of +// posts, newest first, in the {data, meta} shape of Laravel's paginator. +func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) { + db, ok := p.app.Lookup[*gorm.DB]() + if !ok { + wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "database unavailable"}) + return + } + page := queryInt(r, "page", 1, 1, 10000) + perPage := queryInt(r, "per_page", p.app.Config.Int("acme.blog.per_page"), 1, 100) + + q := db.WithContext(r.Context()).Model(&models.Post{}) + var total int64 + if err := q.Count(&total).Error; err != nil { + wire.WriteOpaque500(w) + return + } + var posts []models.Post + if err := q.Order("created_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil { + wire.WriteOpaque500(w) + return + } + rows := make([]postJSON, 0, len(posts)) + for _, post := range posts { + rows = append(rows, postJSON{ + ID: post.ID, + Title: post.Title, + Slug: post.Slug, + Body: post.Body, + CreatedAt: wire.Time{Time: post.CreatedAt.UTC().Truncate(time.Second)}, + }) + } + wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total)) +} + +// queryInt reads an integer query parameter, falling back to def when it is +// missing or not a number, and clamps it to [lo, hi]. +func queryInt(r *http.Request, name string, def, lo, hi int) int { + n, err := strconv.Atoi(r.URL.Query().Get(name)) + if err != nil { + n = def + } + return min(max(n, lo), hi) +} diff --git a/docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go b/docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go new file mode 100644 index 0000000..aea580b --- /dev/null +++ b/docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go @@ -0,0 +1,28 @@ +// Code generated by summer make. DO NOT EDIT. + +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// CreatePosts returns the 20260101000000_create_acme_blog_posts gormigrate entry. +func CreatePosts() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "20260101000000_create_acme_blog_posts", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE acme_blog_posts ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + body TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS acme_blog_posts").Error + }, + } +} diff --git a/docs/examples/blog/updates/doc.go b/docs/examples/blog/updates/doc.go new file mode 100644 index 0000000..539aa13 --- /dev/null +++ b/docs/examples/blog/updates/doc.go @@ -0,0 +1,2 @@ +// Package updates holds the gormigrate set for the acme.blog plugin. +package updates diff --git a/docs/examples/blog/updates/updates_test.go b/docs/examples/blog/updates/updates_test.go new file mode 100644 index 0000000..f9d9915 --- /dev/null +++ b/docs/examples/blog/updates/updates_test.go @@ -0,0 +1,57 @@ +package updates_test + +import ( + "os" + "regexp" + "slices" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/docs/examples/blog" + "git.golem15.com/golem15/summercms/docs/examples/blog/updates" +) + +var migrationFile = regexp.MustCompile(`^(\d{14})_[a-z0-9_]+\.go$`) + +func TestMigrationIDs(t *testing.T) { + if got := updates.CreatePosts().ID; got != "20260101000000_create_acme_blog_posts" { + t.Errorf("CreatePosts ID = %q", got) + } + + // The plugin returns the set in registry.gen.go's order, which must be + // ascending by ID: gormigrate applies it in slice order. + var ids []string + for _, m := range (&blog.Plugin{}).Migrations() { + if m.Migrate == nil || m.Rollback == nil { + t.Errorf("migration %s has no Migrate or Rollback", m.ID) + } + ids = append(ids, m.ID) + } + if len(ids) == 0 { + t.Fatal("the plugin has no migrations") + } + if !slices.IsSorted(ids) { + t.Errorf("migration IDs are not in ascending order: %v", ids) + } + + // Every migration file is named after its ID, and every ID has a file. + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + var files []string + for _, e := range entries { + name := e.Name() + if e.IsDir() || name == "doc.go" || strings.HasSuffix(name, "_test.go") || !strings.HasSuffix(name, ".go") { + continue + } + if !migrationFile.MatchString(name) { + t.Errorf("%s does not start with a 14-digit timestamp", name) + continue + } + files = append(files, strings.TrimSuffix(name, ".go")) + } + if !slices.Equal(files, ids) { + t.Errorf("migration files %v, want one per ID %v", files, ids) + } +} diff --git a/docs/examples/blog/views/mail/welcome.htm b/docs/examples/blog/views/mail/welcome.htm new file mode 100644 index 0000000..32f8598 --- /dev/null +++ b/docs/examples/blog/views/mail/welcome.htm @@ -0,0 +1,4 @@ +subject = "Welcome" +description = "Placeholder mail template" +== +Hello. diff --git a/docs/setup/porting-a-plugin.md b/docs/setup/porting-a-plugin.md new file mode 100644 index 0000000..aab7812 --- /dev/null +++ b/docs/setup/porting-a-plugin.md @@ -0,0 +1,333 @@ +--- +title: Porting a plugin +description: Take a WinterCMS acme/blog plugin with a model, migrations, a route, a backend controller and an artisan command to a compiled SummerCMS plugin, step by step. +section: setup +order: 50 +--- +# Porting a plugin + +This walkthrough ports a small WinterCMS plugin, `Acme.Blog`, to SummerCMS. The plugin has what most real plugins have: a registration class, a `Post` model, `version.yaml` updates, a `routes.php` API endpoint, a backend `Posts` controller with its YAML, and an artisan command. Each section shows the WinterCMS file first and the SummerCMS file that replaces it. + +The name `acme/blog` is a neutral example. The SummerCMS code on this page is not a sketch: every Go and YAML block is a copy of a file under `docs/examples/blog` in the framework repository, a compiled plugin whose tests activate it, serve its route and run its migrations against PostgreSQL. Read [Coming from WinterCMS](coming-from-wintercms.md) first for the map of concepts. + +## Plugin registration + +In WinterCMS, `Plugin.php` describes the plugin and registers what it adds: + +```php + 'Blog', + 'description' => 'A simple blog', + 'author' => 'Acme', + ]; + } + + public function boot() + { + } +} +``` + +In SummerCMS the plugin is a Go type in the plugin's root package, `plugin.go`. `summer make:plugin acme.blog` writes it with every capability a new plugin usually needs: embedded config, language and mail files, models, migrations, commands, jobs and admin controllers. The plugin registers itself from `init`, and the application imports the package so that `init` runs: + +```go src=docs/examples/blog/plugin.go +package blog + +import ( + "embed" + "io/fs" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/go-gormigrate/gormigrate/v2" +) + +var ( + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) + _ pact.HasMailTemplates = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasCommands = (*Plugin)(nil) + _ pact.HasJobs = (*Plugin)(nil) + _ pact.HasAdminControllers = (*Plugin)(nil) +) + +//go:embed config +var configFS embed.FS + +//go:embed lang +var langFS embed.FS + +//go:embed views/mail +var mailFS embed.FS + +// Plugin is the acme.blog plugin, the Go form of Plugin.php. +type Plugin struct { + app *backpack.App +} + +func (p *Plugin) ID() string { return "acme.blog" } +func (p *Plugin) Requires() []string { return nil } + +func (p *Plugin) Register(*backpack.App) error { return nil } + +// Boot keeps the application, so route handlers and commands can reach its +// services, such as the database, when they run. +func (p *Plugin) Boot(app *backpack.App) error { + p.app = app + return nil +} + +func (p *Plugin) ConfigFS() fs.FS { return configFS } +func (p *Plugin) LangFS() fs.FS { return langFS } + +func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS } +func (p *Plugin) MailTemplates() []string { return nil } +func (p *Plugin) MailLayouts() map[string]string { return nil } + +func (p *Plugin) Models() []any { return generatedModels() } +func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() } +func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() } +func (p *Plugin) Jobs() []pact.Job { return generatedJobs() } +func (p *Plugin) AdminControllers() []pact.AdminController { + return generatedAdminControllers() +} + +func init() { + party.Register(&Plugin{}) +} +``` + +`Boot` keeps the application, because the route handler below reaches the database through it. The capability methods return `generatedModels()`, `generatedMigrations()` and the other accessors from `registry.gen.go`, which the `make:` commands rewrite each time they add a model, a migration, a command, a job or an admin controller. + +The plugin's defaults live in `config/config.yaml`, the equivalent of the plugin's `config/config.php`. They are merged under the plugin ID, so this value is read as `acme.blog.per_page`: + +```yaml src=docs/examples/blog/config/config.yaml +# Defaults for acme.blog, merged under the plugin ID: the application reads +# this value as acme.blog.per_page and can override it in its own config. +per_page: 15 +``` + +## The Post model + +The WinterCMS model extends Eloquent and lists its mass-assignable columns in `$fillable`: + +```php +increments('id'); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('body'); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('acme_blog_posts'); + } +} +``` + +In SummerCMS each migration is a gormigrate entry in `updates/`, named after a UTC timestamp so the files sort in the order they run. There is no `version.yaml`: the plugin returns its migrations in order from `pact.HasMigrations`, and each plugin keeps its own history table. The migration `summer make:model` wrote creates the table with `id` and the timestamps; add the model's columns to it: + +```go src=docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go#CreatePosts +// CreatePosts returns the 20260101000000_create_acme_blog_posts gormigrate entry. +func CreatePosts() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "20260101000000_create_acme_blog_posts", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE acme_blog_posts ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + body TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS acme_blog_posts").Error + }, + } +} +``` + +`summer migrate` runs every plugin's pending migrations in plugin order. See [Migrations](../database/migrations.md) for the history tables and the rollback commands. + +## Routes + +A WinterCMS plugin declares its API endpoints in `routes.php`: + +```php +paginate(15); +}); +``` + +The SummerCMS plugin implements `pact.HasRoutes` in `routes.go` and declares the route on a `pact.Router`. The handler reads the database the application published, counts and loads one page with bound parameters, and answers in the `{data, meta}` shape of Laravel's paginator through `lagoon.Paginate` and `wire.WriteJSON`: + +```go src=docs/examples/blog/routes.go +package blog + +import ( + "net/http" + "strconv" + "time" + + "git.golem15.com/golem15/summercms/docs/examples/blog/models" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +var _ pact.HasRoutes = (*Plugin)(nil) + +// Routes replaces routes.php. +func (p *Plugin) Routes(r pact.Router) error { + r.Get("/api/blog/posts", p.listPosts) + return nil +} + +// postJSON is the response shape of one post. It is built field by field, +// so a column added to the model never leaks into the API. +type postJSON struct { + ID uint `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Body string `json:"body"` + CreatedAt wire.Time `json:"created_at"` +} + +// listPosts answers GET /api/blog/posts?page=N&per_page=M with one page of +// posts, newest first, in the {data, meta} shape of Laravel's paginator. +func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) { + db, ok := p.app.Lookup[*gorm.DB]() + if !ok { + wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "database unavailable"}) + return + } + page := queryInt(r, "page", 1, 1, 10000) + perPage := queryInt(r, "per_page", p.app.Config.Int("acme.blog.per_page"), 1, 100) + + q := db.WithContext(r.Context()).Model(&models.Post{}) + var total int64 + if err := q.Count(&total).Error; err != nil { + wire.WriteOpaque500(w) + return + } + var posts []models.Post + if err := q.Order("created_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil { + wire.WriteOpaque500(w) + return + } + rows := make([]postJSON, 0, len(posts)) + for _, post := range posts { + rows = append(rows, postJSON{ + ID: post.ID, + Title: post.Title, + Slug: post.Slug, + Body: post.Body, + CreatedAt: wire.Time{Time: post.CreatedAt.UTC().Truncate(time.Second)}, + }) + } + wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total)) +} + +// queryInt reads an integer query parameter, falling back to def when it is +// missing or not a number, and clamps it to [lo, hi]. +func queryInt(r *http.Request, name string, def, lo, hi int) int { + n, err := strconv.Atoi(r.URL.Query().Get(name)) + if err != nil { + n = def + } + return min(max(n, lo), hi) +} +``` + +The response is built from a separate `postJSON` type rather than the model, so a column added later does not appear in the API by accident. `page` and `per_page` are clamped, so a client cannot ask for the whole table at once. See [Routing](../services/routing.md) for groups, middleware and authentication, and [Queries and pagination](../database/queries-and-pagination.md) for sorting by a column the client names.