feat(11.1-05): pin the walkthrough to the scaffolder and link it from the concept map
- TestScaffoldLayout runs make:plugin, make:model, make:migration, make:admin-controller and make:command for acme.blog in a copy of examples/hello and compares the file set with docs/examples/blog - the page lists the exact make commands, the go.mod a scaffolded plugin gets, what the scaffolder leaves to the developer and a checklist - scaffolding.md no longer claims same-second migrations get consecutive timestamps; only same-name ones do - coming-from-wintercms.md and index.md link the walkthrough
This commit is contained in:
@@ -60,7 +60,7 @@ summer make:model Comment
|
||||
| `summer make:job` | `jobs/<name>.go` | A typed job built with `conga.Job`; the plugin never imports the queue library. |
|
||||
| `summer make:admin-controller` | `controllers/<name>.go`, `controllers/<name>/config_form.yaml`, `controllers/<name>/config_list.yaml`, `models/<name>/fields.yaml`, `models/<name>/columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration. |
|
||||
|
||||
File names are the snake-case form of the name: `AddPublishedAt` becomes `add_published_at`. Migration file names start with a 14-digit timestamp, so they sort in the order you created them; migrations created in the same second get consecutive timestamps.
|
||||
File names are the snake-case form of the name: `AddPublishedAt` becomes `add_published_at`. Migration file names start with a 14-digit timestamp, so they sort in the order you created them. A second migration with the same name in the same second gets the next second's timestamp, but migrations with different names created in the same second share one timestamp and sort by name; check the order in `updates/`, as [Porting a plugin](../setup/porting-a-plugin.md) describes.
|
||||
|
||||
After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers, and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add.
|
||||
|
||||
|
||||
143
docs/examples/blog/scaffold_layout_test.go
Normal file
143
docs/examples/blog/scaffold_layout_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package blog_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/internal/build"
|
||||
)
|
||||
|
||||
// TestScaffoldLayout pins the walkthrough to the scaffolder: running the
|
||||
// make commands the porting page lists for acme.blog, in its order, in a
|
||||
// copy of the examples/hello application, must produce exactly the files of
|
||||
// this plugin (without its tests, and with migration timestamps normalised). The
|
||||
// in-repository copy has no go.mod or go.sum because it lives in the
|
||||
// framework module; those two are left out of the scaffolder's set.
|
||||
func TestScaffoldLayout(t *testing.T) {
|
||||
// The make commands run go mod tidy; keep it on the module cache.
|
||||
t.Setenv("GOPROXY", "off")
|
||||
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := copyApp(t, filepath.Join(root, "examples", "hello"), root)
|
||||
|
||||
ctx := t.Context()
|
||||
if _, err := build.MakePlugin(ctx, app, "acme.blog"); err != nil {
|
||||
t.Fatalf("MakePlugin: %v", err)
|
||||
}
|
||||
if _, err := build.MakeModel(ctx, app, "acme.blog", "Post", false); err != nil {
|
||||
t.Fatalf("MakeModel: %v", err)
|
||||
}
|
||||
if _, err := build.MakeMigration(ctx, app, "acme.blog", "AddPublishedAt"); err != nil {
|
||||
t.Fatalf("MakeMigration: %v", err)
|
||||
}
|
||||
if _, err := build.MakeAdminController(ctx, app, "acme.blog", "Posts"); err != nil {
|
||||
t.Fatalf("MakeAdminController: %v", err)
|
||||
}
|
||||
if _, err := build.MakeCommand(ctx, app, "acme.blog", "Publish"); err != nil {
|
||||
t.Fatalf("MakeCommand: %v", err)
|
||||
}
|
||||
|
||||
scaffolded := fileSet(t, filepath.Join(app, "plugins", "blog"), func(rel string) bool {
|
||||
return rel != "go.mod" && rel != "go.sum"
|
||||
})
|
||||
walkthrough := fileSet(t, ".", func(rel string) bool {
|
||||
return !strings.HasSuffix(rel, "_test.go")
|
||||
})
|
||||
var missing, extra []string
|
||||
for _, f := range scaffolded {
|
||||
if !slices.Contains(walkthrough, f) {
|
||||
missing = append(missing, f)
|
||||
}
|
||||
}
|
||||
for _, f := range walkthrough {
|
||||
if !slices.Contains(scaffolded, f) {
|
||||
extra = append(extra, f)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 || len(extra) > 0 {
|
||||
t.Errorf("docs/examples/blog differs from the scaffolder output\nscaffolded but missing here: %v\nhere but not scaffolded: %v", missing, extra)
|
||||
}
|
||||
}
|
||||
|
||||
var migrationStamp = regexp.MustCompile(`^updates/\d{14}_`)
|
||||
|
||||
// fileSet returns the sorted relative paths of the files under dir that keep
|
||||
// accepts, with a leading 14-digit migration timestamp replaced by a token.
|
||||
func fileSet(t *testing.T, dir string, keep func(rel string) bool) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if keep(rel) {
|
||||
out = append(out, migrationStamp.ReplaceAllString(rel, "updates/TIMESTAMP_"))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// copyApp copies the application at src into a temporary directory, without
|
||||
// its bin/ directory, and points every go.mod replace of the framework
|
||||
// module at framework.
|
||||
func copyApp(t *testing.T, src, framework string) string {
|
||||
t.Helper()
|
||||
const module = "git.golem15.com/golem15/summercms"
|
||||
dst := t.TempDir()
|
||||
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if rel == "bin" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return os.MkdirAll(filepath.Join(dst, rel), 0o755)
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Name() == "go.mod" {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "replace "))
|
||||
if strings.HasPrefix(trimmed, module+" =>") {
|
||||
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
||||
prefix := ""
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "replace ") {
|
||||
prefix = "replace "
|
||||
}
|
||||
lines[i] = indent + prefix + module + " => " + framework
|
||||
}
|
||||
}
|
||||
data = []byte(strings.Join(lines, "\n"))
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dst, rel), data, 0o644)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -8,11 +8,11 @@ order: 0
|
||||
|
||||
SummerCMS keeps what makes WinterCMS productive (plugins that extend each other, YAML-driven admin forms and lists, console scaffolding) and compiles an application into a single Go binary. The framework modules, the application's plugins, the embedded admin SPA and the console commands all ship as one executable.
|
||||
|
||||
Start with [Installation](setup/installation.md) to set up the toolchain and the `summer` CLI. If you know WinterCMS, read [Coming from WinterCMS](setup/coming-from-wintercms.md) for a map of its concepts to SummerCMS. The API reference section has one page per framework module.
|
||||
Start with [Installation](setup/installation.md) to set up the toolchain and the `summer` CLI. If you know WinterCMS, read [Coming from WinterCMS](setup/coming-from-wintercms.md) for a map of its concepts to SummerCMS. Then follow [Porting a plugin](setup/porting-a-plugin.md), which moves a WinterCMS plugin to SummerCMS step by step. The API reference section has one page per framework module.
|
||||
|
||||
## Where to start
|
||||
|
||||
- [Setup](setup/introduction.md): what SummerCMS is, installation, configuration and the move from WinterCMS.
|
||||
- [Setup](setup/introduction.md): what SummerCMS is, installation, configuration, the move from WinterCMS and a [plugin porting walkthrough](setup/porting-a-plugin.md).
|
||||
- [Architecture](architecture/introduction.md): the single binary, Go modules, the application lifecycle and the request lifecycle.
|
||||
- [Plugins](plugins/registration.md): registering a plugin, scheduling, extending other plugins and testing.
|
||||
- [Backend](backend/admin-controllers.md): admin controllers, forms, lists, relations, users, settings and the admin SPA.
|
||||
|
||||
@@ -10,6 +10,8 @@ SummerCMS keeps the parts of WinterCMS that make a plugin developer productive.
|
||||
|
||||
What changes is everything that depends on PHP at runtime. Plugins are Go packages compiled into one binary, so there is no plugin directory scanned at boot and no runtime autoloading. Magic methods, dynamic properties and behaviours give way to Go interfaces and composition. SummerCMS is headless: it serves a JSON API and the admin SPA, and the frontend is a separate application that calls that API.
|
||||
|
||||
This page maps the concepts. To see them applied to one plugin from start to finish, follow [Porting a plugin](porting-a-plugin.md), which takes an `acme/blog` plugin with a model, migrations, a route, a backend controller and a console command to SummerCMS.
|
||||
|
||||
## Concept map
|
||||
|
||||
Each row names the WinterCMS concept, the SummerCMS identifiers that replace it, and where to read more: the guide page first, then the module reference.
|
||||
|
||||
@@ -10,6 +10,52 @@ This walkthrough ports a small WinterCMS plugin, `Acme.Blog`, to SummerCMS. The
|
||||
|
||||
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, run its command and run its migrations up and down against PostgreSQL. Read [Coming from WinterCMS](coming-from-wintercms.md) first for the map of concepts.
|
||||
|
||||
## Scaffold it yourself
|
||||
|
||||
Every file of the plugin starts as scaffolder output. From the application directory, these commands produce the same file layout as `docs/examples/blog`, which a test in the framework checks:
|
||||
|
||||
```sh
|
||||
summer make:plugin acme.blog
|
||||
summer make:model acme.blog Post
|
||||
summer make:migration acme.blog AddPublishedAt
|
||||
summer make:admin-controller acme.blog Posts
|
||||
summer make:command acme.blog Publish
|
||||
summer plugin:add plugins/blog
|
||||
summer build
|
||||
```
|
||||
|
||||
| Command | Writes |
|
||||
|---------|--------|
|
||||
| `summer make:plugin acme.blog` | `plugins/blog` as its own Go module: `plugin.go`, `routes.go`, `registry.gen.go`, `go.mod`, `config/config.yaml`, `lang/en/lang.yaml`, `views/mail/welcome.htm` and a `doc.go` in `classes`, `console`, `controllers`, `jobs`, `middleware`, `models` and `updates` |
|
||||
| `summer make:model acme.blog Post` | `models/post.go` and `updates/<timestamp>_create_acme_blog_posts.go` |
|
||||
| `summer make:migration acme.blog AddPublishedAt` | `updates/<timestamp>_add_published_at.go` |
|
||||
| `summer make:admin-controller acme.blog Posts` | `controllers/posts.go`, `controllers/posts/config_form.yaml`, `controllers/posts/config_list.yaml`, `models/posts/fields.yaml` and `models/posts/columns.yaml` |
|
||||
| `summer make:command acme.blog Publish` | `console/publish.go` |
|
||||
| `summer plugin:add plugins/blog` | The plugin in `summer.yaml`, a `require` and a local `replace` in the application's `go.mod`, and the directory in `go.work` |
|
||||
| `summer build` | `bin/acme`, the application binary with the plugin compiled in |
|
||||
|
||||
Each `make:` command also rewrites `registry.gen.go` and runs `go mod tidy` in the plugin. The sections below fill in what each generated file leaves empty. See [Scaffolding](../console/scaffolding.md) for every option of these commands.
|
||||
|
||||
The copy in the framework repository has no `go.mod`, because it is a package of the framework module so that the framework's own `go test ./...` covers it. A plugin you scaffold is a module of its own, and its `go.mod` starts like this for an application whose module is `example.com/acme` (indirect requirements left out):
|
||||
|
||||
```text
|
||||
module example.com/acme/plugins/blog
|
||||
|
||||
go 1.27.0
|
||||
|
||||
toolchain go1.27.0
|
||||
|
||||
require (
|
||||
git.golem15.com/golem15/summercms v0.0.0
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../../../summercms.go
|
||||
```
|
||||
|
||||
The `replace` points at the same framework checkout as the application's `go.mod`, so the plugin builds against your local framework while you develop.
|
||||
|
||||
## Plugin registration
|
||||
|
||||
In WinterCMS, `Plugin.php` describes the plugin and registers what it adds:
|
||||
@@ -758,3 +804,26 @@ summer build
|
||||
```
|
||||
|
||||
See [Writing commands](../console/writing-commands.md) for arguments, flags, prompts and output.
|
||||
|
||||
## What the scaffolder leaves to you
|
||||
|
||||
The `make:` commands write files that compile, not a finished plugin. These are the steps this walkthrough had to do by hand, and the scaffolder behaviour behind them:
|
||||
|
||||
- **The generated-code header stays.** Files the `make:` commands write start with `// Code generated by summer make. DO NOT EDIT.`, although you are meant to edit them. Keep the line: the commands rebuild `registry.gen.go` from the files that carry it, so a model, migration, command or admin controller whose header you delete disappears from the accessors the next time you run a `make:` command. Linters also treat these files as generated and skip them.
|
||||
- **Check the migration order.** A migration's file name and ID start with the second it was created in. Migrations with different names created in the same second get the same timestamp and run in file-name order, so `add_published_at` would run before `create_acme_blog_posts` and fail on the missing table. Look at `updates/` after scaffolding; if two files share a timestamp, rename the later one and its ID. The example uses fixed timestamps, `20260101000000` and `20260101000100`.
|
||||
- **The admin controller names the model after itself.** `make:admin-controller acme.blog Posts` returns `Posts` from `ModelName` and writes `modelClass: Posts`, and it puts `fields.yaml` and `columns.yaml` under `models/posts/`. Change `ModelName` and both `modelClass` values to the model, `Post`; the YAML can stay where it is, as in this example.
|
||||
- **The admin controller needs more than the scaffolder writes.** The generated controller implements only `pact.AdminController`. Add `NewRecord` (`pact.AdminRecordSource`) so the admin API has a model to query, and `RequiredPermissions` (`pact.AdminPermissioned`): without it, any signed-in administrator can open the controller. The model needs `Fillable` and `Rules` before the admin API can save it. The plugin needs `AdminFS` (`pact.AdminAssets`) to embed the YAML; without it the application refuses to start once the admin is enabled.
|
||||
- **A command that needs the application takes it as a parameter.** The function `make:command` writes takes no arguments, which is what the generated accessor looks for, so it cannot reach the database or the config. Give it the dependency as a parameter and return it from `Commands` yourself, as `blog:publish` does.
|
||||
|
||||
## Checklist
|
||||
|
||||
What changed on the way from WinterCMS to SummerCMS:
|
||||
|
||||
- `Plugin.php` became a `Plugin` type in `plugin.go`, registered from `init` with `party.Register`; each `register*` method became a capability interface such as `pact.HasPermissions` or `pact.HasNavigation`.
|
||||
- The plugin is a Go module the application imports; `summer plugin:add` and `summer build` replace dropping a directory into `plugins/`.
|
||||
- The Eloquent model became a GORM struct; `$fillable` and `$rules` became `Fillable` and `Rules` methods, and every mass assignment goes through `lagoon.Fill`.
|
||||
- `version.yaml` and the update scripts became timestamped gormigrate entries, each with a real `Rollback`.
|
||||
- `routes.php` became `Routes` on a `pact.Router`, with handlers that answer through a response type of their own and `lagoon.Paginate`.
|
||||
- The backend controller class became a `pact.AdminController` with a model, a permission and the same YAML; the generic admin API replaces the behaviours and their views.
|
||||
- The artisan command became a `bonfire.Command`, run with `./bin/acme blog:publish`.
|
||||
- Language strings and config defaults stay in YAML files embedded in the binary.
|
||||
|
||||
Reference in New Issue
Block a user