feat(10-02): backend strings, controller messages and declarative toolbar

- phrasebook ships the backend::lang admin strings (pl, en) with CLDR
  plural maps, loads them as namespace backend, applies
  pact.HasLangOverrides trees (lang/<locale>/<namespace>/<group>.yaml)
  after every namespace, and fails activation when a backend key cannot
  convert to plural forms
- Translator.Forms, Bundle, Resolved and Has serve keys as CLDR form maps
- Public GET /lang returns every backend::lang key for the request
  locale over the fallback locale, Cache-Control no-cache
- config_list, config_form and config_relation accept a strict messages
  block; omitted keys take framework defaults, schemas serve every message
  as CLDR forms, and activation fails on a missing phrase key
- toolbar.buttons is an ordered [create, delete] list; the Winter string
  form, duplicates, unknown actions and delete without showCheckboxes fail
  at boot, and create is dropped when the controller has no form
- Form schema serves the raw Winter redirects; scaffold emits the list
  syntax; form and relation schema routes are typed in the admin OpenAPI
This commit is contained in:
Jakub Zych
2026-09-27 16:16:32 +02:00
parent fe04dbc89e
commit c87148a34f
24 changed files with 2148 additions and 45 deletions

View File

@@ -19,6 +19,8 @@ type Catalog struct {
messages map[string]map[string]entry
// namespace (plugin ID) -> owning plugin ID
namespaces map[string]string
// replace lets put overwrite an existing key; only Override sets it.
replace bool
}
type entry struct {
@@ -221,7 +223,7 @@ func (c *Catalog) put(pluginID, file, locale, key string, e entry) error {
byKey = make(map[string]entry)
c.messages[locale] = byKey
}
if _, exists := byKey[key]; exists {
if _, exists := byKey[key]; exists && !c.replace {
return catalogErrorf(pluginID, file, key, "duplicate key")
}
byKey[key] = e
@@ -240,6 +242,106 @@ func (c *Catalog) lookup(locale, key string) (entry, bool) {
return e, ok
}
// Override applies owner's override tree, laid out as
// lang/<locale>/<namespace>/<group>.yaml, over the loaded catalog. It may
// replace existing keys of a loaded namespace and add locales; a malformed
// path or an unknown namespace fails.
func (c *Catalog) Override(owner string, fsys fs.FS) error {
if c == nil {
return fmt.Errorf("phrasebook: catalog is nil")
}
if fsys == nil {
return nil
}
var files []string
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
files = append(files, p)
}
return nil
})
if err != nil {
return catalogErrorf(owner, "", "", "walk lang overrides: %s", err.Error())
}
sort.Strings(files)
c.replace = true
defer func() { c.replace = false }()
for _, file := range files {
locale, namespace, group, err := parseOverridePath(file)
if err != nil {
return catalogErrorf(owner, file, "", "%s", err.Error())
}
if _, loaded := c.namespaces[namespace]; !loaded {
return catalogErrorf(owner, file, "", "override names unknown namespace %s", namespace)
}
raw, err := fs.ReadFile(fsys, file)
if err != nil {
return catalogErrorf(owner, file, "", "read: %s", err.Error())
}
if err := c.loadFile(namespace, file, locale, group, raw); err != nil {
return fmt.Errorf("phrasebook: override by %s: %w", owner, err)
}
}
return nil
}
func parseOverridePath(p string) (locale, namespace, group string, err error) {
bad := fmt.Errorf("malformed override path (want lang/<locale>/<namespace>/<group>.yaml)")
if strings.Contains(p, "\\") || path.Clean(p) != p {
return "", "", "", bad
}
parts := strings.Split(p, "/")
if len(parts) != 4 || parts[0] != "lang" || !strings.HasSuffix(parts[3], ".yaml") {
return "", "", "", bad
}
locale, namespace, group = parts[1], parts[2], strings.TrimSuffix(parts[3], ".yaml")
if !validLocale(locale) || !validNamespace(namespace) || !validGroup(group) {
return "", "", "", bad
}
return locale, namespace, group, nil
}
func validNamespace(s string) bool {
if s == "" || s == "." || s == ".." || strings.HasPrefix(s, ".") || strings.HasSuffix(s, ".") {
return false
}
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' {
continue
}
return false
}
return true
}
// checkForms fails when a key under prefix cannot convert to CLDR forms, so
// strings served to the admin SPA never need Laravel exact or range pipes.
func (c *Catalog) checkForms(prefix string) error {
locales := make([]string, 0, len(c.messages))
for locale := range c.messages {
locales = append(locales, locale)
}
sort.Strings(locales)
for _, locale := range locales {
keys := make([]string, 0)
for key := range c.messages[locale] {
if strings.HasPrefix(key, prefix) {
keys = append(keys, key)
}
}
sort.Strings(keys)
for _, key := range keys {
if _, ok := entryForms(c.messages[locale][key]); !ok {
return fmt.Errorf("phrasebook: %s key %s cannot convert to CLDR plural forms (use a plural map, not an exact or range pipe)", locale, key)
}
}
}
return nil
}
func parseLangPath(p string) (locale, group string, err error) {
p = path.Clean("/" + strings.ReplaceAll(p, "\\", "/"))
p = strings.TrimPrefix(p, "/")