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:
@@ -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, "/")
|
||||
|
||||
Reference in New Issue
Block a user