feat(04-01): scaffold Winter-shaped plugins with generated registry

Render plugin.go, routes.go, leaf packages and go.mod from embedded
templates, emit empty registry accessors, and define job, admin, lang
and mail capability contracts so a new plugin compiles before artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-18 13:34:32 +02:00
parent 846e6d5b74
commit 1edf9d7e4c
6 changed files with 552 additions and 63 deletions

View File

@@ -3,6 +3,7 @@ package build
import (
"bytes"
"context"
"embed"
"fmt"
"go/ast"
"go/format"
@@ -13,8 +14,34 @@ import (
"path/filepath"
"strconv"
"strings"
"text/template"
)
//go:embed stubs/*.tmpl
var stubFS embed.FS
var stubTmpl = template.Must(template.ParseFS(stubFS, "stubs/*.tmpl"))
var pluginLeaves = []string{
"models",
"classes",
"controllers",
"console",
"jobs",
"middleware",
"updates",
}
type stubData struct {
Package string
ID string
Module string
Framework string
GoVersion string
Toolchain string
Replace string
}
const (
frameworkModule = "git.golem15.com/golem15/summercms"
goVersionLine = "go 1.27.0"
@@ -50,27 +77,21 @@ func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
return "", fmt.Errorf("build: plugin module: %w", err)
}
if err := os.MkdirAll(filepath.Join(pluginDir, "config"), 0o755); err != nil {
return "", fmt.Errorf("build: create plugin dir: %w", err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "config", ".gitkeep"), []byte{}, 0o644); err != nil {
return "", fmt.Errorf("build: write config placeholder: %w", err)
}
src, err := pluginGoSource(name, id)
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.go"), src, 0o644); err != nil {
return "", fmt.Errorf("build: write plugin.go: %w", err)
}
replace, err := frameworkReplaceFor(pluginDir, appDir)
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(pluginDir, "go.mod"), pluginGoMod(modPath, replace), 0o644); err != nil {
return "", fmt.Errorf("build: write go.mod: %w", err)
data := stubData{
Package: name,
ID: id,
Module: modPath,
Framework: frameworkModule,
GoVersion: goVersionLine,
Toolchain: toolchainLine,
Replace: replace,
}
if err := writePluginScaffold(pluginDir, data); err != nil {
return "", err
}
if err := runGo(ctx, pluginDir, "mod", "tidy"); err != nil {
return "", err
@@ -156,39 +177,83 @@ func pluginName(id string) string {
return name
}
func pluginGoSource(pkg, id string) ([]byte, error) {
var b strings.Builder
b.WriteString("package " + pkg + "\n\n")
b.WriteString("import (\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/party\"\n")
b.WriteString(")\n\n")
fmt.Fprintf(&b, "// Plugin is the %s plugin.\n", id)
b.WriteString("type Plugin struct{}\n\n")
fmt.Fprintf(&b, "func (p *Plugin) ID() string { return %s }\n", strconv.Quote(id))
b.WriteString("func (p *Plugin) Requires() []string { return nil }\n\n")
b.WriteString("func (p *Plugin) Register(*backpack.App) error { return nil }\n")
b.WriteString("func (p *Plugin) Boot(*backpack.App) error { return nil }\n\n")
b.WriteString("func init() {\n")
b.WriteString("\tparty.Register(&Plugin{})\n")
b.WriteString("}\n")
formatted, err := format.Source([]byte(b.String()))
func writePluginScaffold(pluginDir string, data stubData) error {
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
return fmt.Errorf("build: create plugin dir: %w", err)
}
pluginSrc, err := renderStub("plugin.go", data)
if err != nil {
return nil, fmt.Errorf("build: format plugin.go: %w", err)
return err
}
if err := writeFile(filepath.Join(pluginDir, "plugin.go"), pluginSrc); err != nil {
return err
}
routesSrc, err := renderStub("routes.go", data)
if err != nil {
return err
}
if err := writeFile(filepath.Join(pluginDir, "routes.go"), routesSrc); err != nil {
return err
}
for _, leaf := range pluginLeaves {
src, err := renderStub(leaf+"/doc.go", data)
if err != nil {
return err
}
if err := writeFile(filepath.Join(pluginDir, leaf, "doc.go"), src); err != nil {
return err
}
}
for _, asset := range pluginAssets {
if err := writeFile(filepath.Join(pluginDir, filepath.FromSlash(asset.rel)), []byte(asset.body)); err != nil {
return err
}
}
if err := writeRegistry(pluginDir, emptyRegistry(data.Package, data.Module)); err != nil {
return err
}
modSrc, err := renderStub("go.mod", data)
if err != nil {
return err
}
return writeFile(filepath.Join(pluginDir, "go.mod"), modSrc)
}
type pluginAsset struct {
rel string
body string
}
var pluginAssets = []pluginAsset{
{rel: "config/config.yaml", body: "{}\n"},
{rel: "lang/en/lang.yaml", body: "{}\n"},
{rel: "views/mail/welcome.htm", body: "subject = \"Welcome\"\ndescription = \"Placeholder mail template\"\n==\nHello.\n"},
}
func renderStub(name string, data any) ([]byte, error) {
var buf bytes.Buffer
if err := stubTmpl.ExecuteTemplate(&buf, name, data); err != nil {
return nil, fmt.Errorf("build: render stub %s: %w", name, err)
}
raw := buf.Bytes()
if !strings.HasSuffix(name, ".go") {
return raw, nil
}
formatted, err := format.Source(raw)
if err != nil {
return nil, fmt.Errorf("build: format stub %s: %w\n%s", name, err, raw)
}
return formatted, nil
}
func pluginGoMod(modPath, replace string) []byte {
var b strings.Builder
fmt.Fprintf(&b, "module %s\n\n", modPath)
b.WriteString(goVersionLine + "\n\n")
b.WriteString(toolchainLine + "\n\n")
fmt.Fprintf(&b, "require %s v0.0.0\n", frameworkModule)
if replace != "" {
fmt.Fprintf(&b, "\nreplace %s => %s\n", frameworkModule, replace)
func writeFile(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("build: mkdir %s: %w", filepath.Dir(path), err)
}
return []byte(b.String())
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("build: write %s: %w", path, err)
}
return nil
}
func frameworkReplaceFor(pluginDir, appDir string) (string, error) {