feat(plugin): add RequiredIconPacks to PluginRegistration and ModPlugin

Lets plugins declare icon-pack dependencies (e.g. "tabler", "phosphor")
in plugin.mod and PluginRegistration. The CMS loader auto-installs
declared packs from the bundled registry before the plugin loads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-06-07 15:14:15 +08:00
parent c3c7b2d441
commit ba87684696
4 changed files with 107 additions and 0 deletions

View File

@ -30,6 +30,13 @@ type ModPlugin struct {
Version string `toml:"version"` Version string `toml:"version"`
Kind string `toml:"kind,omitempty"` Kind string `toml:"kind,omitempty"`
Categories []string `toml:"categories,omitempty"` Categories []string `toml:"categories,omitempty"`
// RequiredIconPacks names icon-pack slugs the host CMS must ensure are
// installed before the plugin is loaded (e.g. "tabler", "phosphor"). The
// standalone-plugin loader honours this best-effort by auto-installing any
// missing packs from the bundled registry; slugs outside that whitelist are
// logged and skipped (admins install them manually). Empty / omitted means
// the plugin has no icon-pack dependencies.
RequiredIconPacks []string `toml:"required_icon_packs,omitempty"`
// Private marks the plugin as account-scoped. When true, Coords() returns // Private marks the plugin as account-scoped. When true, Coords() returns
// the canonical "@private/<name>@<version>" form regardless of the Scope // the canonical "@private/<name>@<version>" form regardless of the Scope
// field, and the publish flow attributes the plugin to the publisher's // field, and the publish flow attributes the plugin to the publisher's

View File

@ -169,6 +169,73 @@ func TestCoords_PrivateNoScope(t *testing.T) {
} }
} }
func TestParseModFull_RequiredIconPacks(t *testing.T) {
src := []byte(`
[plugin]
name = "neon"
version = "0.1.0"
required_icon_packs = ["tabler", "phosphor"]
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if len(m.Plugin.RequiredIconPacks) != 2 {
t.Fatalf("RequiredIconPacks len = %d, want 2", len(m.Plugin.RequiredIconPacks))
}
if m.Plugin.RequiredIconPacks[0] != "tabler" || m.Plugin.RequiredIconPacks[1] != "phosphor" {
t.Errorf("RequiredIconPacks = %v", m.Plugin.RequiredIconPacks)
}
}
func TestParseModFull_RequiredIconPacksOmitted(t *testing.T) {
src := []byte(`
[plugin]
name = "noicons"
version = "0.1.0"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if len(m.Plugin.RequiredIconPacks) != 0 {
t.Errorf("RequiredIconPacks should be empty when omitted, got %v", m.Plugin.RequiredIconPacks)
}
}
func TestParseRequiredIconPacks(t *testing.T) {
src := []byte(`
[plugin]
name = "neon"
version = "0.1.0"
required_icon_packs = ["tabler", " phosphor ", ""]
`)
got := ParseRequiredIconPacks(src)
if len(got) != 2 {
t.Fatalf("ParseRequiredIconPacks len = %d (%v), want 2", len(got), got)
}
if got[0] != "tabler" || got[1] != "phosphor" {
t.Errorf("ParseRequiredIconPacks = %v, want [tabler phosphor]", got)
}
}
func TestParseRequiredIconPacks_NilOnAbsent(t *testing.T) {
src := []byte(`
[plugin]
name = "noicons"
version = "0.1.0"
`)
if got := ParseRequiredIconPacks(src); got != nil {
t.Errorf("ParseRequiredIconPacks on absent field = %v, want nil", got)
}
}
func TestParseRequiredIconPacks_NilOnInvalidTOML(t *testing.T) {
if got := ParseRequiredIconPacks([]byte("not valid = = =")); got != nil {
t.Errorf("ParseRequiredIconPacks on invalid TOML = %v, want nil", got)
}
}
func TestParseModFull_RequiresAndCompat(t *testing.T) { func TestParseModFull_RequiresAndCompat(t *testing.T) {
src := []byte(` src := []byte(`
[plugin] [plugin]

View File

@ -45,4 +45,10 @@ type PluginRegistration struct {
Dependencies []Dependency Dependencies []Dependency
Migrations func() fs.FS Migrations func() fs.FS
Version string Version string
// RequiredIconPacks are icon-pack slugs the CMS must ensure are installed
// before the theme loads (e.g. "tabler", "phosphor"). Loader auto-installs
// from the bundled registry; out-of-registry slugs are logged and require
// manual install.
RequiredIconPacks []string
} }

View File

@ -31,6 +31,33 @@ func ParseModVersion(data []byte) string {
return "0.0.0" return "0.0.0"
} }
// ParseRequiredIconPacks extracts the required_icon_packs list from an embedded
// plugin.mod file. Returns nil if the field is absent or empty. Mirrors the
// "read straight from TOML bytes" style of ParseModVersion so plugin
// registration.go files can populate PluginRegistration.RequiredIconPacks
// without having to duplicate the slugs in Go.
func ParseRequiredIconPacks(data []byte) []string {
m, err := ParseModFull(data)
if err != nil || m == nil {
return nil
}
if len(m.Plugin.RequiredIconPacks) == 0 {
return nil
}
out := make([]string, 0, len(m.Plugin.RequiredIconPacks))
for _, slug := range m.Plugin.RequiredIconPacks {
s := strings.TrimSpace(slug)
if s == "" {
continue
}
out = append(out, s)
}
if len(out) == 0 {
return nil
}
return out
}
// CompareVersions compares two semver strings. // CompareVersions compares two semver strings.
// Returns -1 if v1 < v2, 0 if equal, +1 if v1 > v2. // Returns -1 if v1 < v2, 0 if equal, +1 if v1 > v2.
// Returns 0 if either version is invalid. // Returns 0 if either version is invalid.