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

@@ -91,6 +91,9 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
if err := cat.Load("lagoon", systemLangFS); err != nil {
return err
}
if err := cat.Load("backend", backendLang()); err != nil {
return err
}
for _, p := range plugins {
hl, ok := any(p).(pact.HasLang)
if !ok {
@@ -100,6 +103,21 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
return err
}
}
// Overrides run after every namespace is loaded (D-20): a project can
// replace framework or plugin strings and add locales without a rebuild
// of the admin SPA.
for _, p := range plugins {
ho, ok := any(p).(pact.HasLangOverrides)
if !ok {
continue
}
if err := cat.Override(p.ID(), ho.LangOverridesFS()); err != nil {
return err
}
}
if err := cat.checkForms("backend::"); err != nil {
return err
}
opts := Options{Locale: defaultLocale, Fallback: defaultFallback}
if app.Config != nil {
if v := strings.TrimSpace(app.Config.String("app.locale")); v != "" {
@@ -460,3 +478,102 @@ func ucfirst(s string) string {
}
return string(unicode.ToUpper(r)) + s[size:]
}
// Forms returns key's text as CLDR plural forms for the SPA (D-13, D-20): a
// plain text is {"other": text}, a YAML plural map is copied, and a pipe of
// category forms maps by category. Exact and range pipes ({0}, [2,*]) have
// no Intl.PluralRules equivalent and report false, as does a missing key.
// Placeholders are left for the client to interpolate.
func (t *Translator) Forms(locale, key string) (map[string]string, bool) {
if t == nil {
return nil, false
}
e, _, ok := t.find(locale, key)
if !ok {
return nil, false
}
return entryForms(e)
}
// Bundle returns every key starting with prefix as CLDR forms, keys of the
// requested locale over those of its fallback chain. Keys that cannot convert
// are left out.
func (t *Translator) Bundle(locale, prefix string) map[string]map[string]string {
out := map[string]map[string]string{}
if t == nil || t.cat == nil {
return out
}
chain := fallbackChain(locale, t.fallbackLocale())
for i := len(chain) - 1; i >= 0; i-- {
for key, e := range t.cat.messages[chain[i]] {
if !strings.HasPrefix(key, prefix) {
continue
}
if forms, ok := entryForms(e); ok {
out[key] = forms
}
}
}
return out
}
// Resolved returns the first locale of locale's fallback chain that the
// catalog has any key for under prefix: the locale a Bundle is mostly in.
func (t *Translator) Resolved(locale, prefix string) string {
if t == nil || t.cat == nil {
return locale
}
for _, step := range fallbackChain(locale, t.fallbackLocale()) {
for key := range t.cat.messages[step] {
if strings.HasPrefix(key, prefix) {
return step
}
}
}
return t.fallbackLocale()
}
// Has reports whether any locale of the catalog defines key.
func (t *Translator) Has(key string) bool {
if t == nil || t.cat == nil {
return false
}
for _, byKey := range t.cat.messages {
if _, ok := byKey[key]; ok {
return true
}
}
return false
}
func (t *Translator) fallbackLocale() string {
if t == nil || strings.TrimSpace(t.fallback) == "" {
return defaultFallback
}
return t.fallback
}
func entryForms(e entry) (map[string]string, bool) {
switch {
case len(e.pipes) > 0:
out := make(map[string]string, len(e.pipes))
for _, part := range e.pipes {
if part.exact != nil || part.lo != nil || part.cat == "" {
return nil, false
}
out[part.cat] = part.text
}
if _, ok := out["other"]; !ok {
return nil, false
}
return out, true
case e.plurals != nil:
out := make(map[string]string, len(e.plurals))
for cat, text := range e.plurals {
out[cat] = text
}
return out, true
default:
return map[string]string{"other": e.text}, true
}
}