feat: load declared plugin locale catalogs

This commit is contained in:
2026-07-11 02:02:47 +08:00
parent 15f1f413d0
commit 8b1151e03a
4 changed files with 242 additions and 24 deletions
+69 -17
View File
@@ -7,28 +7,36 @@ import (
"log"
"os"
"path/filepath"
"regexp"
"strings"
)
// Manifest represents a Verstak plugin.json manifest.
type Manifest struct {
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"apiVersion"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Icon string `json:"icon,omitempty"`
Provides []string `json:"provides"`
Requires []string `json:"requires,omitempty"`
OptionalRequires []string `json:"optionalRequires,omitempty"`
Permissions []string `json:"permissions"`
Frontend *FrontendConfig `json:"frontend,omitempty"`
Backend *BackendConfig `json:"backend,omitempty"`
Migrations *MigrationConfig `json:"migrations,omitempty"`
Contributes *Contributions `json:"contributes,omitempty"`
Sync *SyncConfig `json:"sync,omitempty"`
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"apiVersion"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Icon string `json:"icon,omitempty"`
Localization *LocalizationConfig `json:"localization,omitempty"`
Provides []string `json:"provides"`
Requires []string `json:"requires,omitempty"`
OptionalRequires []string `json:"optionalRequires,omitempty"`
Permissions []string `json:"permissions"`
Frontend *FrontendConfig `json:"frontend,omitempty"`
Backend *BackendConfig `json:"backend,omitempty"`
Migrations *MigrationConfig `json:"migrations,omitempty"`
Contributes *Contributions `json:"contributes,omitempty"`
Sync *SyncConfig `json:"sync,omitempty"`
}
// LocalizationConfig declares plugin-owned UI message catalogs.
type LocalizationConfig struct {
DefaultLocale string `json:"defaultLocale"`
Locales map[string]string `json:"locales"`
}
// FrontendConfig describes the plugin's frontend bundle.
@@ -237,6 +245,25 @@ func ValidateManifest(m *Manifest) []string {
if len(m.Permissions) == 0 {
errs.add("permissions must have at least one permission")
}
if m.Localization != nil {
if !isValidLocaleTag(m.Localization.DefaultLocale) {
errs.add("localization.defaultLocale %q is not a valid lower-case locale", m.Localization.DefaultLocale)
}
if len(m.Localization.Locales) == 0 {
errs.add("localization.locales must contain at least one catalog")
}
if _, ok := m.Localization.Locales[m.Localization.DefaultLocale]; !ok {
errs.add("localization.defaultLocale %q must have a declared catalog", m.Localization.DefaultLocale)
}
for locale, catalogPath := range m.Localization.Locales {
if !isValidLocaleTag(locale) {
errs.add("localization locale %q is not a valid lower-case locale", locale)
}
if err := ValidateLocalizationPath(catalogPath); err != nil {
errs.add("localization locale %q: %v", locale, err)
}
}
}
if m.Contributes != nil {
for i, provider := range m.Contributes.OpenProviders {
if provider.ID == "" {
@@ -262,6 +289,31 @@ func ValidateManifest(m *Manifest) []string {
return errs.errors
}
var localeTagPattern = regexp.MustCompile(`^[a-z]{2}(?:-[a-z0-9]+)*$`)
func isValidLocaleTag(locale string) bool {
return localeTagPattern.MatchString(locale)
}
// ValidateLocalizationPath accepts only slash-separated plugin-relative paths.
func ValidateLocalizationPath(catalogPath string) error {
if strings.TrimSpace(catalogPath) == "" {
return fmt.Errorf("catalog path is required")
}
if filepath.IsAbs(catalogPath) || filepath.VolumeName(catalogPath) != "" || strings.HasPrefix(catalogPath, "/") {
return fmt.Errorf("catalog path must be relative")
}
if strings.Contains(catalogPath, `\`) {
return fmt.Errorf("catalog path must not contain backslash")
}
for _, part := range strings.Split(catalogPath, "/") {
if part == ".." {
return fmt.Errorf("catalog path traversal is not allowed")
}
}
return nil
}
func isValidPluginID(id string) bool {
if id == "" {
return false
+44
View File
@@ -233,6 +233,50 @@ func TestValidateManifest_OpenProviders(t *testing.T) {
}
}
func TestValidateManifest_Localization(t *testing.T) {
base := &Manifest{
SchemaVersion: 1,
ID: "localized.plugin",
Name: "Localized",
Version: "1.0.0",
APIVersion: "1.0",
Provides: []string{"localized.capability"},
Permissions: []string{"ui.register"},
Localization: &LocalizationConfig{
DefaultLocale: "en",
Locales: map[string]string{
"en": "locales/en.json",
"ru": "locales/ru.json",
},
},
}
if errs := ValidateManifest(base); len(errs) != 0 {
t.Fatalf("valid localization errors = %v", errs)
}
tests := []struct {
name string
localization *LocalizationConfig
want string
}{
{"missing default catalog", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"ru": "locales/ru.json"}}, "defaultLocale"},
{"invalid locale", &LocalizationConfig{DefaultLocale: "EN", Locales: map[string]string{"EN": "locales/en.json"}}, "locale"},
{"absolute path", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "/tmp/en.json"}}, "relative"},
{"traversal", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "../en.json"}}, "traversal"},
{"backslash", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": `locales\en.json`}}, "backslash"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
manifest := *base
manifest.Localization = tc.localization
combined := strings.Join(ValidateManifest(&manifest), "\n")
if !strings.Contains(combined, tc.want) {
t.Fatalf("errors = %q, want %q", combined, tc.want)
}
})
}
}
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
dir1 := t.TempDir()