package plugin import ( "fmt" "regexp" "strings" tomlpkg "github.com/BurntSushi/toml" ) type ModFile struct { Plugin ModPlugin `toml:"plugin"` Compatibility *ModCompat `toml:"compatibility"` Requires []ModRequirement `toml:"requires"` } type ModPlugin struct { // Name is the lowercase identifier used for the plugin slug in URLs and DB // lookups. The CLI normalises this on write; the registry normalises on // create. Use DisplayName for human-readable presentation. Name string `toml:"name"` // DisplayName is the human-readable form (any case). Optional; if empty // the registry falls back to the input name with its original case. DisplayName string `toml:"display_name,omitempty"` // Description is the short summary surfaced in the registry. Optional. Description string `toml:"description,omitempty"` // Scope is the plugin owner namespace as it appears in plugin.mod. It may // include the leading "@" (e.g. "@themes") or omit it (e.g. "themes") — // both forms are accepted. Consumers comparing scopes should trim the "@" // before comparing; use ModFile.Coords() for a normalised display string. Scope string `toml:"scope"` Version string `toml:"version"` Kind string `toml:"kind,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 // the canonical "@private/@" form regardless of the Scope // field, and the publish flow attributes the plugin to the publisher's // active account rather than to a public scope. Private bool `toml:"private,omitempty"` } type ModCompat struct { BlockCore string `toml:"block_core"` } type ModRequirement struct { Name string `toml:"name"` Version string `toml:"version"` } func ParseModFull(b []byte) (*ModFile, error) { var m ModFile if err := tomlpkg.Unmarshal(b, &m); err != nil { return nil, err } return &m, nil } // Coords returns the canonical display coordinate for the plugin in the form // "@scope/name@version" (or "name@version" when no scope is set). // // The leading "@" on m.Plugin.Scope is intentionally trimmed before // re-prefixing so that authors may write either "@themes" or "themes" in // plugin.mod and get the same output. Callers that need the raw scope as // written should read m.Plugin.Scope directly. func (m *ModFile) Coords() string { if m == nil { return "" } if m.Plugin.Private { return "@" + PrivateScopeSlug + "/" + m.Plugin.Name + "@" + m.Plugin.Version } scope := strings.TrimPrefix(m.Plugin.Scope, "@") if scope == "" { return m.Plugin.Name + "@" + m.Plugin.Version } return "@" + scope + "/" + m.Plugin.Name + "@" + m.Plugin.Version } // PrivateScopeSlug is the registry namespace under which all private plugins // live. Coords for private plugins resolve to "@private/@"; // uniqueness is enforced by (owner_account_id, name), not by the slug. const PrivateScopeSlug = "private" const ( TagMinLen = 2 TagMaxLen = 30 TagMaxCount = 10 ) // tagSlugRe matches lowercase a-z, 0-9, with single hyphens between groups. // Rejects leading/trailing/consecutive hyphens. var tagSlugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) // NormalizeTags trims, lowercases, dedupes (case-insensitively), validates, // and caps a slice of tags. Returns the cleaned slice or an error listing // every offending input so authors fix them in one pass. // // Rules: // - trim surrounding whitespace; drop empty entries silently // - lowercase // - require [a-z0-9-]{TagMinLen..TagMaxLen}, no leading/trailing/consecutive hyphens // - dedupe case-insensitively, preserving first occurrence order // - at most TagMaxCount entries (counted after dedupe) func NormalizeTags(in []string) ([]string, error) { seen := make(map[string]struct{}, len(in)) out := make([]string, 0, len(in)) var bad []string for _, raw := range in { t := strings.ToLower(strings.TrimSpace(raw)) if t == "" { continue } if _, dup := seen[t]; dup { continue } if len(t) < TagMinLen || len(t) > TagMaxLen || !tagSlugRe.MatchString(t) { bad = append(bad, raw) continue } seen[t] = struct{}{} out = append(out, t) } if len(bad) > 0 { return nil, fmt.Errorf( "invalid tags (must be %d-%d chars, lowercase a-z 0-9 and single hyphens, no leading/trailing hyphen): %s", TagMinLen, TagMaxLen, strings.Join(bad, ", "), ) } if len(out) > TagMaxCount { return nil, fmt.Errorf("too many tags: got %d, max %d", len(out), TagMaxCount) } return out, nil }