feat(kiosk): plugin.yaml manifest and summer plugin:manifest

- new module kiosk: Manifest, Marshal, Parse, Derive, Generate, Write, Check, StaleError
- plugin:manifest [dir] with --check, --pretend, --name, --description
- docs: kiosk README, root table row, plugins/marketplace.md, scaffolding row
This commit is contained in:
Jakub Zych
2026-10-29 16:55:17 +01:00
parent 824e3d5882
commit ec6a1955fc
14 changed files with 979 additions and 1 deletions

84
modules/kiosk/README.md Normal file
View File

@@ -0,0 +1,84 @@
# kiosk
Plugin manifests and the plugin registry contract: generates and checks each plugin's plugin.yaml from its own sources.
`import "git.golem15.com/golem15/summercms/modules/kiosk"`
**Name:** A beach kiosk where every plugin on the shelf carries its label; the label is printed from what is in the box, never written by hand.
## Overview
A plugin describes itself in `plugin.yaml`, committed next to its `plugin.go`. The file is generated: `summer plugin:manifest` runs `kiosk.Write`, which reads the plugin's sources and never runs its code. The ID and the required plugins come from what `ID()` and `Requires()` in `plugin.go` return, the module path and the version minimums from `go.mod`, the package import path from the directory, and the name and description from the README's H1 and the paragraph after it (the standard README structure). The registry on summercms.io reads the same file from every tagged release, and `summer build` bakes its name and description into the application.
WinterCMS keeps this data in a plugin's `Plugin.php` `pluginDetails()` and in `composer.json`; here it is derived once, so the copy in the repository cannot drift from the code.
## Features
- One `plugin.yaml` per plugin package directory, so a repository with a nested plugin carries two files.
- A schema version (`kiosk.SchemaVersion`) and a fixed header line (`kiosk.Header`); a manifest with a newer schema is refused with a hint to update summer.
- Go-style minimums: `framework` is the `go.mod` require of `kiosk.FrameworkModule`, and each required plugin gets the version its module is required at.
- Name and description overrides (`--name`, `--description`) stored in an `overrides:` block that regeneration keeps.
- `kiosk.Check` regenerates in memory and returns a `kiosk.StaleError` when the committed file is missing or differs, which `summer plugin:manifest --check` turns into exit status 1.
## Usage
```go
path, warnings, err := kiosk.Write(ctx, "plugins/acme/blog", kiosk.DeriveOptions{})
if err != nil {
return err
}
for _, w := range warnings {
log.Println(w)
}
fmt.Println("wrote", path)
if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil {
var stale *kiosk.StaleError
if errors.As(err, &stale) {
fmt.Printf("expected:\n%s", stale.Want)
}
return err
}
```
## API reference
| Identifier | Description |
|------------|-------------|
| `kiosk.FileName` | The manifest file name, `plugin.yaml`. |
| `kiosk.SchemaVersion` | The newest schema this package reads, and the one it writes. |
| `kiosk.FrameworkModule` | The framework module path; its `go.mod` require is the plugin's framework minimum. |
| `kiosk.Header` | The comment line every generated `plugin.yaml` starts with. |
| `kiosk.Manifest` | One `plugin.yaml`: `Schema`, `ID`, `Module`, `Package`, `Name`, `Description`, `Framework`, `Requires`, `Overrides`, written in that order. |
| `kiosk.Requires` | Required plugin ID to minimum version; marshals sorted by ID. An empty minimum means the plugin ships in the same module or its module is not a `go.mod` require. |
| `kiosk.Requires.MarshalYAML` | Emits the map as a mapping sorted by ID. |
| `kiosk.Overrides` | The persisted `--name` and `--description` values. |
| `kiosk.Marshal` | The `plugin.yaml` bytes: the header line, then the fields. |
| `kiosk.Parse` | Reads `plugin.yaml` bytes; unknown fields are ignored, a newer schema is an error. |
| `kiosk.DeriveOptions` | Override changes for `kiosk.Derive`: nil keeps the persisted value, a pointer to `""` clears it. |
| `kiosk.Derive` | Builds the manifest of a plugin directory from its sources and returns it with warnings. |
| `kiosk.Generate` | `kiosk.Derive` plus `kiosk.Marshal`: the bytes `kiosk.Write` would write. |
| `kiosk.Write` | Generates and writes `<dir>/plugin.yaml` and returns its path. |
| `kiosk.Check` | Returns a `kiosk.StaleError` when `<dir>/plugin.yaml` is missing or differs from a fresh generation. |
| `kiosk.StaleError` | `Path`, `Missing`, and the generated (`Want`) and committed (`Got`) bytes; its message names the first differing line. |
## CLI commands
| Command | Purpose |
|---------|---------|
| `summer plugin:manifest [dir]` | Writes `plugin.yaml` for the plugin in `dir` (default: the current directory). `--pretend` prints it instead, `--check` fails when the committed file is missing or stale, `--name` and `--description` set or (with an empty value) clear the overrides. |
## Dependencies
- SummerCMS modules: none.
- Third-party: `github.com/goccy/go-yaml` (manifest YAML), `github.com/yuin/goldmark` (README headings and paragraphs).
- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `os`, `os/exec`, `path/filepath`, `regexp`, `sort`, `strconv`, `strings`.
- Tools: the `go` command (`go mod edit -json`), run without a shell.
## Testing
```sh
go test ./modules/kiosk/...
```
`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests. The tests run the local `go` command but need no network and no database.

91
modules/kiosk/check.go Normal file
View File

@@ -0,0 +1,91 @@
package kiosk
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
)
// Generate derives the manifest of the plugin in dir and returns the
// plugin.yaml bytes and any warnings, without writing anything.
func Generate(ctx context.Context, dir string, opts DeriveOptions) ([]byte, []string, error) {
m, warnings, err := Derive(ctx, dir, opts)
if err != nil {
return nil, warnings, err
}
out, err := Marshal(m)
if err != nil {
return nil, warnings, err
}
return out, warnings, nil
}
// Write generates dir's plugin.yaml and writes it, returning its path.
func Write(ctx context.Context, dir string, opts DeriveOptions) (path string, warnings []string, err error) {
out, warnings, err := Generate(ctx, dir, opts)
if err != nil {
return "", warnings, err
}
path = filepath.Join(dir, FileName)
if err := os.WriteFile(path, out, 0o644); err != nil {
return "", warnings, fmt.Errorf("kiosk: write %s: %w", path, err)
}
return path, warnings, nil
}
// StaleError reports a plugin.yaml that is missing or differs from what
// summer plugin:manifest would write now.
type StaleError struct {
// Path is the plugin.yaml path.
Path string
// Missing is true when the file does not exist.
Missing bool
// Want is the freshly generated content; Got is the committed one.
Want, Got []byte
}
// Error names the path and, for a stale file, the first differing line.
func (e *StaleError) Error() string {
if e.Missing {
return fmt.Sprintf("kiosk: %s is missing; generate it with summer plugin:manifest", e.Path)
}
want := bytes.Split(e.Want, []byte("\n"))
got := bytes.Split(e.Got, []byte("\n"))
for i := 0; i < len(want) || i < len(got); i++ {
var w, g []byte
if i < len(want) {
w = want[i]
}
if i < len(got) {
g = got[i]
}
if !bytes.Equal(w, g) {
return fmt.Sprintf("kiosk: %s is stale: line %d is %q, want %q", e.Path, i+1, g, w)
}
}
return fmt.Sprintf("kiosk: %s is stale", e.Path)
}
// Check regenerates dir's plugin.yaml in memory, keeping its overrides,
// and returns a *StaleError when the file on disk is missing or differs.
func Check(ctx context.Context, dir string) error {
want, _, err := Generate(ctx, dir, DeriveOptions{})
if err != nil {
return err
}
path := filepath.Join(dir, FileName)
got, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return &StaleError{Path: path, Missing: true, Want: want}
}
if err != nil {
return fmt.Errorf("kiosk: read %s: %w", path, err)
}
if !bytes.Equal(want, got) {
return &StaleError{Path: path, Want: want, Got: got}
}
return nil
}

368
modules/kiosk/derive.go Normal file
View File

@@ -0,0 +1,368 @@
package kiosk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
)
// DeriveOptions changes the persisted name and description overrides. A
// nil field keeps the override already in plugin.yaml; a pointer to ""
// clears it; any other value sets it.
type DeriveOptions struct {
Name *string
Description *string
}
// goMod is the part of `go mod edit -json` output Derive reads.
type goMod struct {
Module struct {
Path string
}
Require []goModRequire
}
type goModRequire struct {
Path string
Version string
Indirect bool
}
// version returns the go.mod require version of path, or "".
func (m goMod) version(path string) (string, bool) {
for _, r := range m.Require {
if r.Path == path {
return r.Version, true
}
}
return "", false
}
var pseudoVersionRe = regexp.MustCompile(`^v\d+\.\d+\.\d+-(?:[0-9A-Za-z.-]+\.)?\d{14}-[0-9a-f]{12}$`)
// isPseudoVersion reports whether v is a Go pseudo-version.
func isPseudoVersion(v string) bool { return pseudoVersionRe.MatchString(v) }
// Derive builds the manifest of the plugin in dir from its sources: the
// module and the version minimums from the nearest go.mod, the ID and
// requirements from plugin.go, the name and description from README.md.
// Overrides come from an existing plugin.yaml in dir, changed by opts.
// No plugin code is compiled or run. The returned strings are warnings.
func Derive(ctx context.Context, dir string, opts DeriveOptions) (Manifest, []string, error) {
abs, err := filepath.Abs(dir)
if err != nil {
return Manifest{}, nil, fmt.Errorf("kiosk: resolve %s: %w", dir, err)
}
if st, err := os.Stat(abs); err != nil || !st.IsDir() {
return Manifest{}, nil, fmt.Errorf("kiosk: %s is not a plugin directory", dir)
}
root, err := moduleRoot(abs)
if err != nil {
return Manifest{}, nil, err
}
mod, err := readGoMod(ctx, root)
if err != nil {
return Manifest{}, nil, err
}
var warnings []string
m := Manifest{Schema: SchemaVersion, Module: mod.Module.Path}
m.Package, err = packagePath(mod.Module.Path, root, abs)
if err != nil {
return Manifest{}, nil, err
}
fw, ok := mod.version(FrameworkModule)
switch {
case !ok:
return Manifest{}, nil, fmt.Errorf("kiosk: %s does not require %s; the framework require is the plugin's framework minimum", filepath.Join(root, "go.mod"), FrameworkModule)
case fw == "v0.0.0":
return Manifest{}, nil, fmt.Errorf("kiosk: %s requires %s v0.0.0, which is not a released version; require a framework tag", filepath.Join(root, "go.mod"), FrameworkModule)
case isPseudoVersion(fw):
warnings = append(warnings, fmt.Sprintf("framework minimum %s is a pseudo-version; require a framework tag before publishing", fw))
}
m.Framework = fw
ident, err := readLiteralIdentity(abs)
if err != nil {
return Manifest{}, nil, err
}
m.ID = ident.ID
if len(ident.Requires) > 0 {
m.Requires = Requires{}
for _, id := range ident.Requires {
m.Requires[id] = ""
}
}
existing, err := readExisting(abs)
if err != nil {
return Manifest{}, nil, err
}
ov := Overrides{}
if existing.Overrides != nil {
ov = *existing.Overrides
}
if opts.Name != nil {
ov.Name = strings.TrimSpace(*opts.Name)
}
if opts.Description != nil {
ov.Description = strings.TrimSpace(*opts.Description)
}
if ov != (Overrides{}) {
m.Overrides = &ov
}
h1, summary, err := readReadme(filepath.Join(abs, "README.md"))
if err != nil {
return Manifest{}, nil, err
}
m.Name = h1
if ov.Name != "" {
m.Name = ov.Name
} else if h1 == m.ID {
return Manifest{}, nil, fmt.Errorf("kiosk: README.md's H1 is the plugin ID %s, not a display name; pass --name=\"…\" (it is kept in %s)", m.ID, FileName)
}
m.Description = summary
if ov.Description != "" {
m.Description = ov.Description
}
if m.Description == "" {
return Manifest{}, nil, fmt.Errorf("kiosk: README.md has no summary paragraph after its H1; add the one-sentence summary or pass --description=\"…\"")
}
return m, warnings, nil
}
// moduleRoot walks up from dir to the nearest directory with a go.mod.
func moduleRoot(dir string) (string, error) {
for d := dir; ; {
if st, err := os.Stat(filepath.Join(d, "go.mod")); err == nil && !st.IsDir() {
return d, nil
}
parent := filepath.Dir(d)
if parent == d {
return "", fmt.Errorf("kiosk: no go.mod in %s or above", dir)
}
d = parent
}
}
// packagePath is the import path of dir inside the module rooted at root.
func packagePath(modPath, root, dir string) (string, error) {
rel, err := filepath.Rel(root, dir)
if err != nil {
return "", fmt.Errorf("kiosk: package path: %w", err)
}
if rel == "." {
return modPath, nil
}
return modPath + "/" + filepath.ToSlash(rel), nil
}
// readGoMod runs `go mod edit -json` in root.
func readGoMod(ctx context.Context, root string) (goMod, error) {
out, err := runGo(ctx, root, "mod", "edit", "-json")
if err != nil {
return goMod{}, err
}
var m goMod
if err := json.Unmarshal(out, &m); err != nil {
return goMod{}, fmt.Errorf("kiosk: read %s: %w", filepath.Join(root, "go.mod"), err)
}
if m.Module.Path == "" {
return goMod{}, fmt.Errorf("kiosk: %s has no module path", filepath.Join(root, "go.mod"))
}
return m, nil
}
// runGo runs the go command in dir without a shell and returns stdout.
// Workspaces are off: a plugin manifest describes the module on its own.
func runGo(ctx context.Context, dir string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
env := os.Environ()
filtered := make([]string, 0, len(env)+1)
for _, e := range env {
if !strings.HasPrefix(e, "GOWORK=") {
filtered = append(filtered, e)
}
}
cmd.Env = append(filtered, "GOWORK=off")
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("kiosk: go %s in %s: %w\n%s", strings.Join(args, " "), dir, err, bytes.TrimSpace(stderr.Bytes()))
}
return out, nil
}
// readExisting parses dir's plugin.yaml, or returns the zero manifest when
// there is none.
func readExisting(dir string) (Manifest, error) {
raw, err := os.ReadFile(filepath.Join(dir, FileName))
if errors.Is(err, os.ErrNotExist) {
return Manifest{}, nil
}
if err != nil {
return Manifest{}, fmt.Errorf("kiosk: read %s: %w", filepath.Join(dir, FileName), err)
}
m, err := Parse(raw)
if err != nil {
return Manifest{}, fmt.Errorf("%w\n(delete %s and rerun summer plugin:manifest; pass --name/--description again if it had overrides)", err, filepath.Join(dir, FileName))
}
return m, nil
}
// readReadme returns the plain text of the README's first H1 and of the
// first paragraph after it, whitespace collapsed.
func readReadme(path string) (name, summary string, err error) {
src, err := os.ReadFile(path)
if err != nil {
return "", "", fmt.Errorf("kiosk: the plugin needs a README.md whose H1 is the plugin name: %w", err)
}
doc := goldmark.New().Parser().Parse(text.NewReader(src))
var h1 gast.Node
for n := doc.FirstChild(); n != nil; n = n.NextSibling() {
if h, ok := n.(*gast.Heading); ok && h.Level == 1 {
h1 = h
break
}
}
if h1 == nil {
return "", "", fmt.Errorf("kiosk: %s has no H1; its first-level heading is the plugin name", path)
}
name = plainText(h1, src)
if name == "" {
return "", "", fmt.Errorf("kiosk: %s has an empty H1", path)
}
for n := h1.NextSibling(); n != nil; n = n.NextSibling() {
if p, ok := n.(*gast.Paragraph); ok {
summary = plainText(p, src)
break
}
if _, ok := n.(*gast.Heading); ok {
break
}
}
return name, summary, nil
}
// plainText is the text content of n with whitespace collapsed: code
// spans and link texts keep their text, markup is dropped.
func plainText(n gast.Node, src []byte) string {
var b strings.Builder
_ = gast.Walk(n, func(c gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
switch t := c.(type) {
case *gast.Text:
b.Write(t.Segment.Value(src))
if t.SoftLineBreak() || t.HardLineBreak() {
b.WriteByte(' ')
}
case *gast.String:
b.Write(t.Value)
case *gast.AutoLink:
b.Write(t.Label(src))
return gast.WalkSkipChildren, nil
case *gast.RawHTML:
return gast.WalkSkipChildren, nil
}
return gast.WalkContinue, nil
})
return strings.Join(strings.Fields(b.String()), " ")
}
// identity is what Task 2's reader returns: the literals of ID() and
// Requires().
type identity struct {
ID string
Requires []string
}
// readLiteralIdentity reads the string literals returned by ID() and
// Requires() in dir/plugin.go.
func readLiteralIdentity(dir string) (identity, error) {
path := filepath.Join(dir, "plugin.go")
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return identity{}, fmt.Errorf("kiosk: parse %s: %w", path, err)
}
var id identity
var idErr, reqErr error
foundID := false
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv == nil || fn.Body == nil {
continue
}
ret := lastReturn(fn)
switch fn.Name.Name {
case "ID":
foundID = true
lit, ok := ret.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
idErr = fmt.Errorf("kiosk: %s: ID() must return a string literal", path)
continue
}
id.ID, _ = strconv.Unquote(lit.Value)
case "Requires":
switch e := ret.(type) {
case *ast.Ident:
if e.Name != "nil" {
reqErr = fmt.Errorf("kiosk: %s: Requires() must return nil or a []string literal", path)
}
case *ast.CompositeLit:
for _, el := range e.Elts {
lit, ok := el.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
reqErr = fmt.Errorf("kiosk: %s: Requires() must return string literals", path)
break
}
s, _ := strconv.Unquote(lit.Value)
id.Requires = append(id.Requires, s)
}
default:
reqErr = fmt.Errorf("kiosk: %s: Requires() must return nil or a []string literal", path)
}
}
}
if !foundID {
return identity{}, fmt.Errorf("kiosk: %s has no ID() method", path)
}
if idErr != nil {
return identity{}, idErr
}
if reqErr != nil {
return identity{}, reqErr
}
return id, nil
}
// lastReturn is the single result of fn's last top-level return, or nil.
func lastReturn(fn *ast.FuncDecl) ast.Expr {
var res ast.Expr
for _, stmt := range fn.Body.List {
if ret, ok := stmt.(*ast.ReturnStmt); ok && len(ret.Results) == 1 {
res = ret.Results[0]
}
}
return res
}

23
modules/kiosk/kiosk.go Normal file
View File

@@ -0,0 +1,23 @@
// Package kiosk reads and writes plugin manifests (plugin.yaml) and holds
// the plugin registry contract shared by the summer tool, the registry
// server and the admin.
//
// A plugin.yaml is generated from the plugin's own sources by summer
// plugin:manifest: the ID and required plugins from plugin.go, the module
// path and version minimums from go.mod, the name and description from the
// README. It is committed next to plugin.go and never edited by hand; Check
// and CheckT report a stale copy.
package kiosk
const (
// FileName is the manifest file name, one per plugin package directory.
FileName = "plugin.yaml"
// SchemaVersion is the newest plugin.yaml schema this package reads
// and the one it writes.
SchemaVersion = 1
// FrameworkModule is the framework's module path; its go.mod require
// is a plugin's framework minimum.
FrameworkModule = "git.golem15.com/golem15/summercms"
// Header is the comment line every generated plugin.yaml starts with.
Header = "# Generated by summer plugin:manifest. Do not edit; rerun the command."
)

90
modules/kiosk/manifest.go Normal file
View File

@@ -0,0 +1,90 @@
package kiosk
import (
"bytes"
"fmt"
"sort"
"github.com/goccy/go-yaml"
)
// Manifest is one plugin.yaml. Fields are written in declaration order.
type Manifest struct {
// Schema is the plugin.yaml format version (SchemaVersion when written).
Schema int `yaml:"schema"`
// ID is the plugin ID that plugin.go's ID() returns.
ID string `yaml:"id"`
// Module is the go.mod module path: what go get takes.
Module string `yaml:"module"`
// Package is the plugin's import path: what summer.yaml lists. It
// differs from Module for a plugin in a subdirectory of its module.
Package string `yaml:"package"`
// Name is the display name: the README's H1 or the name override.
Name string `yaml:"name"`
// Description is the README's one-sentence summary or the override.
Description string `yaml:"description"`
// Framework is the minimum framework version: the go.mod require of
// FrameworkModule.
Framework string `yaml:"framework"`
// Requires maps each plugin ID that Requires() returns to its minimum
// version; empty when the plugin ships in the same module or its
// module is not a go.mod require.
Requires Requires `yaml:"requires,omitempty"`
// Overrides holds the --name and --description values; nil when
// neither is set.
Overrides *Overrides `yaml:"overrides,omitempty"`
}
// Overrides are the name and description set with summer plugin:manifest
// --name and --description. They survive regeneration until cleared.
type Overrides struct {
Name string `yaml:"name,omitempty"`
Description string `yaml:"description,omitempty"`
}
// Requires maps a required plugin ID to its minimum version. It marshals
// with the IDs sorted, so the generated bytes are stable.
type Requires map[string]string
// MarshalYAML emits the map as a sorted mapping.
func (r Requires) MarshalYAML() (any, error) {
ids := make([]string, 0, len(r))
for id := range r {
ids = append(ids, id)
}
sort.Strings(ids)
out := make(yaml.MapSlice, 0, len(ids))
for _, id := range ids {
out = append(out, yaml.MapItem{Key: id, Value: r[id]})
}
return out, nil
}
// Marshal returns the plugin.yaml bytes: the Header line, then the fields.
func Marshal(m Manifest) ([]byte, error) {
if m.Overrides != nil && *m.Overrides == (Overrides{}) {
m.Overrides = nil
}
body, err := yaml.Marshal(m)
if err != nil {
return nil, fmt.Errorf("kiosk: marshal %s: %w", FileName, err)
}
var b bytes.Buffer
b.WriteString(Header)
b.WriteByte('\n')
b.Write(body)
return b.Bytes(), nil
}
// Parse reads plugin.yaml bytes. A schema newer than SchemaVersion is an
// error that says to update summer; unknown fields are ignored.
func Parse(data []byte) (Manifest, error) {
var m Manifest
if err := yaml.Unmarshal(data, &m); err != nil {
return Manifest{}, fmt.Errorf("kiosk: parse %s: %w", FileName, err)
}
if m.Schema > SchemaVersion {
return Manifest{}, fmt.Errorf("kiosk: %s has schema %d, this summer reads up to %d; update summer", FileName, m.Schema, SchemaVersion)
}
return m, nil
}

View File

@@ -0,0 +1,126 @@
package kiosk
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
// copyTestdata copies testdata/<names...> into a fresh temp dir, keeping
// the relative layout, and returns the temp dir.
func copyTestdata(t *testing.T, names ...string) string {
t.Helper()
dst := t.TempDir()
for _, name := range names {
src := filepath.Join("testdata", name)
err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel("testdata", path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, 0o644)
})
if err != nil {
t.Fatalf("copy %s: %v", src, err)
}
}
return dst
}
// TestManifestSmoke drives plugin.yaml generation end to end on the
// fixture plugins in testdata.
func TestManifestSmoke(t *testing.T) {
ctx := context.Background()
t.Run("literal", func(t *testing.T) {
dir := filepath.Join(copyTestdata(t, "literal"), "literal")
path, warnings, err := Write(ctx, dir, DeriveOptions{})
if err != nil {
t.Fatalf("Write: %v", err)
}
if len(warnings) != 0 {
t.Errorf("warnings = %v", warnings)
}
if path != filepath.Join(dir, FileName) {
t.Errorf("path = %s", path)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(raw), Header+"\n") {
t.Errorf("plugin.yaml does not start with the header:\n%s", raw)
}
m, err := Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := Manifest{
Schema: SchemaVersion,
ID: "acme.blog",
Module: "example.test/sm-acme-plugin",
Package: "example.test/sm-acme-plugin",
Name: "Blog",
Description: "Posts and categories for an acme application, with a feed.",
Framework: "v0.5.3",
}
if m.Schema != want.Schema || m.ID != want.ID || m.Module != want.Module || m.Package != want.Package ||
m.Name != want.Name || m.Description != want.Description || m.Framework != want.Framework ||
len(m.Requires) != 0 || m.Overrides != nil {
t.Errorf("manifest = %+v, want %+v", m, want)
}
wantOrder := []string{"schema:", "id:", "module:", "package:", "name:", "description:", "framework:"}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")[1:]
if len(lines) != len(wantOrder) {
t.Fatalf("lines = %q", lines)
}
for i, prefix := range wantOrder {
if !strings.HasPrefix(lines[i], prefix) {
t.Errorf("line %d = %q, want prefix %q", i+2, lines[i], prefix)
}
}
if err := Check(ctx, dir); err != nil {
t.Fatalf("Check on a fresh plugin.yaml: %v", err)
}
readme := filepath.Join(dir, "README.md")
src, _ := os.ReadFile(readme)
edited := strings.Replace(string(src), "with a feed.", "with an Atom feed.", 1)
if err := os.WriteFile(readme, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
var stale *StaleError
if err := Check(ctx, dir); !errors.As(err, &stale) || stale.Missing {
t.Fatalf("Check after a README edit = %v, want a stale *StaleError", err)
}
if !strings.Contains(stale.Error(), path) || !strings.Contains(stale.Error(), "line ") {
t.Errorf("StaleError text = %q", stale.Error())
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := Check(ctx, dir); !errors.As(err, &stale) || !stale.Missing {
t.Fatalf("Check without plugin.yaml = %v, want Missing", err)
}
if len(stale.Want) == 0 {
t.Error("a missing StaleError carries no Want bytes")
}
})
}

View File

@@ -0,0 +1,7 @@
# Blog
Posts and categories for an `acme` application, with a feed.
## Overview
More text that is not part of the manifest.

5
modules/kiosk/testdata/literal/go.mod vendored Normal file
View File

@@ -0,0 +1,5 @@
module example.test/sm-acme-plugin
go 1.27.0
require git.golem15.com/golem15/summercms v0.5.3

View File

@@ -0,0 +1,12 @@
// Package blog is a test fixture: a plugin whose ID() and Requires()
// return literals. kiosk reads it with go/parser; it is never built.
package blog
// Plugin is the fixture plugin.
type Plugin struct{}
// ID returns the plugin ID.
func (*Plugin) ID() string { return "acme.blog" }
// Requires returns no plugins.
func (*Plugin) Requires() []string { return nil }