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:
119
phrasebook/backend/lang/en/lang.yaml
Normal file
119
phrasebook/backend/lang/en/lang.yaml
Normal file
@@ -0,0 +1,119 @@
|
||||
# Framework admin strings (D-20), resolved as backend::lang.<group>.<key>.
|
||||
# English equivalents of the Polish copy; plural texts are CLDR maps (one,
|
||||
# other), never pipe strings. Placeholders use :count, :name, :term (D-24).
|
||||
auth:
|
||||
title: Welcome back
|
||||
login: Login or email
|
||||
password: Password
|
||||
invalid: Invalid login details
|
||||
submit: Sign in
|
||||
logout: Sign out
|
||||
session_expired: Your session has expired. Please sign in again.
|
||||
nav:
|
||||
plugins: Plugins
|
||||
sections: Sections
|
||||
breadcrumbs: Breadcrumbs
|
||||
empty: No sections are available.
|
||||
settings: Settings
|
||||
collapse: Collapse menu
|
||||
expand: Expand menu
|
||||
user_menu: User menu
|
||||
page:
|
||||
not_found: Page not found.
|
||||
list:
|
||||
search_prompt: Search…
|
||||
no_records: There are no records in this view.
|
||||
no_results: 0 results
|
||||
loading: Loading…
|
||||
load_failed: The list could not be loaded.
|
||||
delete_selected: Delete selected
|
||||
prev_page: Previous
|
||||
next_page: Next
|
||||
records_per_page: Per page
|
||||
column_switch_true: "Yes"
|
||||
column_switch_false: "No"
|
||||
column_created: Created
|
||||
column_updated: Updated
|
||||
pagination_range: ":from–:to of :total"
|
||||
results:
|
||||
one: ":count result"
|
||||
other: ":count results"
|
||||
empty_value: "—"
|
||||
clear_search: Clear search
|
||||
select_all: Select all on this page
|
||||
select_row: Select row
|
||||
sort_ascending: Sort ascending
|
||||
sort_descending: Sort descending
|
||||
form:
|
||||
save: Save
|
||||
save_and_close: Save and close
|
||||
cancel: Cancel
|
||||
delete: Delete
|
||||
none: — none —
|
||||
update: Edit
|
||||
create: Create
|
||||
return_to_list: Back to list
|
||||
close: Close
|
||||
confirm: Confirm
|
||||
saving: Saving…
|
||||
deleting: Deleting…
|
||||
add: Add
|
||||
remove_item: "Remove: :name"
|
||||
add_item: Add…
|
||||
select_placeholder: Select…
|
||||
loading_options: Loading…
|
||||
no_options: No results
|
||||
unsupported_field: "Unsupported field type: :type"
|
||||
error_title: Could not save.
|
||||
error_fields:
|
||||
one: "Fix :count field marked below."
|
||||
other: "Fix :count fields marked below."
|
||||
error_generic: The changes could not be saved. Please try again.
|
||||
unsaved_confirm: You have unsaved changes. Discard them?
|
||||
tab_errors:
|
||||
one: ":count error"
|
||||
other: ":count errors"
|
||||
relation:
|
||||
add: Add
|
||||
link: Link
|
||||
unlink: Unlink
|
||||
remove: Remove
|
||||
search: Search…
|
||||
add_count: "Add (:count)"
|
||||
candidates_range: ":from–:to of :total"
|
||||
settings:
|
||||
title: Settings
|
||||
empty: No settings are available.
|
||||
saved: Settings saved
|
||||
messages:
|
||||
list:
|
||||
record_count:
|
||||
one: ":count record"
|
||||
other: ":count records"
|
||||
create: New record
|
||||
empty_search: Nothing found
|
||||
empty_search_hint: No record matches “:term”. Try another search.
|
||||
selected: ":count selected"
|
||||
delete_confirm: "Delete the selected (:count)?"
|
||||
deleted:
|
||||
one: "Deleted :count record"
|
||||
other: "Deleted :count records"
|
||||
form:
|
||||
create: New record
|
||||
update: Edit record
|
||||
saved: Saved
|
||||
delete_confirm: Delete this record?
|
||||
deleted: Record deleted
|
||||
relation:
|
||||
link: Add
|
||||
link_hint: Choose the records to link.
|
||||
candidate_search: Search…
|
||||
linked:
|
||||
one: "Linked :count record"
|
||||
other: "Linked :count records"
|
||||
unlink_selected: Unlink selected
|
||||
unlink_confirm: "Unlink the selected (:count)?"
|
||||
unlinked:
|
||||
one: "Unlinked :count record"
|
||||
other: "Unlinked :count records"
|
||||
empty: No linked records.
|
||||
135
phrasebook/backend/lang/pl/lang.yaml
Normal file
135
phrasebook/backend/lang/pl/lang.yaml
Normal file
@@ -0,0 +1,135 @@
|
||||
# Framework admin strings (D-20), resolved as backend::lang.<group>.<key>.
|
||||
# Winter backend key names are kept where Winter has them; values follow the
|
||||
# admin design copy (D-06). Plural texts are CLDR maps (one, few, many,
|
||||
# other), never pipe strings, so every key converts to the SPA's plural forms.
|
||||
# Placeholders use :count, :name, :term and friends (D-24).
|
||||
auth:
|
||||
title: Witaj ponownie
|
||||
login: Login lub e-mail
|
||||
password: Hasło
|
||||
invalid: Nieprawidłowe dane logowania
|
||||
submit: Zaloguj
|
||||
logout: Wyloguj
|
||||
session_expired: Sesja wygasła. Zaloguj się ponownie.
|
||||
nav:
|
||||
plugins: Wtyczki
|
||||
sections: Sekcje
|
||||
breadcrumbs: Ścieżka nawigacji
|
||||
empty: Brak dostępnych sekcji.
|
||||
settings: Ustawienia
|
||||
collapse: Zwiń menu
|
||||
expand: Rozwiń menu
|
||||
user_menu: Menu użytkownika
|
||||
page:
|
||||
not_found: Nie znaleziono strony.
|
||||
list:
|
||||
search_prompt: Szukaj…
|
||||
no_records: Brak rekordów w tym widoku.
|
||||
no_results: 0 wyników
|
||||
loading: Wczytywanie…
|
||||
load_failed: Nie udało się wczytać listy.
|
||||
delete_selected: Usuń zaznaczone
|
||||
prev_page: Poprzednia
|
||||
next_page: Następna
|
||||
records_per_page: Na stronę
|
||||
column_switch_true: Tak
|
||||
column_switch_false: Nie
|
||||
column_created: Utworzono
|
||||
column_updated: Zaktualizowano
|
||||
pagination_range: ":from–:to z :total"
|
||||
results:
|
||||
one: ":count wynik"
|
||||
few: ":count wyniki"
|
||||
many: ":count wyników"
|
||||
other: ":count wyniku"
|
||||
empty_value: "—"
|
||||
clear_search: Wyczyść wyszukiwanie
|
||||
select_all: Zaznacz wszystkie na stronie
|
||||
select_row: Zaznacz wiersz
|
||||
sort_ascending: Sortuj rosnąco
|
||||
sort_descending: Sortuj malejąco
|
||||
form:
|
||||
save: Zapisz
|
||||
save_and_close: Zapisz i zamknij
|
||||
cancel: Anuluj
|
||||
delete: Usuń
|
||||
none: — brak —
|
||||
update: Edytuj
|
||||
create: Utwórz
|
||||
return_to_list: Wróć do listy
|
||||
close: Zamknij
|
||||
confirm: Potwierdź
|
||||
saving: Zapisywanie…
|
||||
deleting: Usuwanie…
|
||||
add: Dodaj
|
||||
remove_item: "Usuń: :name"
|
||||
add_item: Dodaj…
|
||||
select_placeholder: Wybierz…
|
||||
loading_options: Wczytywanie…
|
||||
no_options: Brak wyników
|
||||
unsupported_field: "Nieobsługiwany typ pola: :type"
|
||||
error_title: Nie udało się zapisać.
|
||||
error_fields:
|
||||
one: "Popraw :count pole oznaczone poniżej."
|
||||
few: "Popraw :count pola oznaczone poniżej."
|
||||
many: "Popraw :count pól oznaczonych poniżej."
|
||||
other: "Popraw :count pola oznaczonego poniżej."
|
||||
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
|
||||
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
|
||||
tab_errors:
|
||||
one: ":count błąd"
|
||||
few: ":count błędy"
|
||||
many: ":count błędów"
|
||||
other: ":count błędu"
|
||||
relation:
|
||||
add: Dodaj
|
||||
link: Dołącz
|
||||
unlink: Odłącz
|
||||
remove: Usuń
|
||||
search: Szukaj…
|
||||
add_count: "Dodaj (:count)"
|
||||
candidates_range: ":from–:to z :total"
|
||||
settings:
|
||||
title: Ustawienia
|
||||
empty: Brak dostępnych ustawień.
|
||||
saved: Zapisano ustawienia
|
||||
messages:
|
||||
list:
|
||||
record_count:
|
||||
one: ":count rekord"
|
||||
few: ":count rekordy"
|
||||
many: ":count rekordów"
|
||||
other: ":count rekordu"
|
||||
create: Nowy rekord
|
||||
empty_search: Nic nie znaleziono
|
||||
empty_search_hint: Żaden rekord nie pasuje do frazy „:term”. Spróbuj innej frazy.
|
||||
selected: "Zaznaczono :count"
|
||||
delete_confirm: "Usunąć zaznaczone (:count)?"
|
||||
deleted:
|
||||
one: "Usunięto :count rekord"
|
||||
few: "Usunięto :count rekordy"
|
||||
many: "Usunięto :count rekordów"
|
||||
other: "Usunięto :count rekordu"
|
||||
form:
|
||||
create: Nowy rekord
|
||||
update: Edycja rekordu
|
||||
saved: Zapisano
|
||||
delete_confirm: Usunąć ten rekord?
|
||||
deleted: Usunięto rekord
|
||||
relation:
|
||||
link: Dodaj
|
||||
link_hint: Wybierz rekordy, które chcesz dołączyć.
|
||||
candidate_search: Szukaj…
|
||||
linked:
|
||||
one: "Dołączono :count rekord"
|
||||
few: "Dołączono :count rekordy"
|
||||
many: "Dołączono :count rekordów"
|
||||
other: "Dołączono :count rekordu"
|
||||
unlink_selected: Odłącz zaznaczone
|
||||
unlink_confirm: "Odłączyć zaznaczone (:count)?"
|
||||
unlinked:
|
||||
one: "Odłączono :count rekord"
|
||||
few: "Odłączono :count rekordy"
|
||||
many: "Odłączono :count rekordów"
|
||||
other: "Odłączono :count rekordu"
|
||||
empty: Brak powiązanych rekordów.
|
||||
@@ -1,6 +1,22 @@
|
||||
package phrasebook
|
||||
|
||||
import "embed"
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed lang
|
||||
var systemLangFS embed.FS
|
||||
|
||||
//go:embed backend/lang
|
||||
var backendLangFS embed.FS
|
||||
|
||||
// backendLang is the framework admin string tree (D-20), laid out as
|
||||
// lang/<locale>/lang.yaml so its keys resolve as backend::lang.<group>.<key>.
|
||||
func backendLang() fs.FS {
|
||||
sub, err := fs.Sub(backendLangFS, "backend")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
@@ -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, "/")
|
||||
|
||||
199
phrasebook/phase10_test.go
Normal file
199
phrasebook/phase10_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package phrasebook
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
)
|
||||
|
||||
func TestPhase10Forms(t *testing.T) {
|
||||
cat := NewCatalog()
|
||||
if err := cat.Load("acme.demo", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte(`plain: Hello :name
|
||||
plural:
|
||||
one: ":count apple"
|
||||
other: ":count apples"
|
||||
pipe: ":count apple|:count apples"
|
||||
exact: "{0} No apples|[1,*] :count apples"
|
||||
`)},
|
||||
"lang/pl/lang.yaml": {Data: []byte(`pipe: ":count jabłko|:count jabłka|:count jabłek|:count jabłka"
|
||||
`)},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := NewTranslator(cat, Options{Locale: "en", Fallback: "en"})
|
||||
|
||||
check := func(locale, key string, want map[string]string) {
|
||||
t.Helper()
|
||||
got, ok := tr.Forms(locale, key)
|
||||
if !ok {
|
||||
t.Fatalf("%s %s: not convertible", locale, key)
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s %s = %v want %v", locale, key, got, want)
|
||||
}
|
||||
for cat, text := range want {
|
||||
if got[cat] != text {
|
||||
t.Fatalf("%s %s = %v want %v", locale, key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
check("en", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"})
|
||||
check("en", "acme.demo::lang.plural", map[string]string{"one": ":count apple", "other": ":count apples"})
|
||||
check("en", "acme.demo::lang.pipe", map[string]string{"one": ":count apple", "other": ":count apples"})
|
||||
check("pl", "acme.demo::lang.pipe", map[string]string{"one": ":count jabłko", "few": ":count jabłka", "many": ":count jabłek", "other": ":count jabłka"})
|
||||
// pl falls back to en for a key it does not define.
|
||||
check("pl", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"})
|
||||
|
||||
if forms, ok := tr.Forms("en", "acme.demo::lang.exact"); ok {
|
||||
t.Fatalf("exact/range pipe converted to %v", forms)
|
||||
}
|
||||
if forms, ok := tr.Forms("en", "acme.demo::lang.missing"); ok {
|
||||
t.Fatalf("missing key converted to %v", forms)
|
||||
}
|
||||
|
||||
got, ok := tr.Forms("en", "acme.demo::lang.plural")
|
||||
if !ok {
|
||||
t.Fatal("plural not convertible")
|
||||
}
|
||||
got["one"] = "mutated"
|
||||
if again, _ := tr.Forms("en", "acme.demo::lang.plural"); again["one"] != ":count apple" {
|
||||
t.Fatal("Forms returned the catalog's own map")
|
||||
}
|
||||
}
|
||||
|
||||
type overridePlugin struct {
|
||||
id string
|
||||
lang fs.FS
|
||||
overrides fs.FS
|
||||
}
|
||||
|
||||
func (p overridePlugin) ID() string { return p.id }
|
||||
func (p overridePlugin) LangFS() fs.FS { return p.lang }
|
||||
func (p overridePlugin) LangOverridesFS() fs.FS { return p.overrides }
|
||||
|
||||
func activateWith(t *testing.T, plugins ...overridePlugin) (*Translator, error) {
|
||||
t.Helper()
|
||||
app := backpack.New(nil)
|
||||
if err := Activate(app, plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr, ok := app.Lookup[*Translator]()
|
||||
if !ok || tr == nil {
|
||||
t.Fatal("translator was not published")
|
||||
}
|
||||
return tr, nil
|
||||
}
|
||||
|
||||
func TestPhase10LangOverride(t *testing.T) {
|
||||
demo := fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("title: Demo\n")}}
|
||||
tr, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: fstest.MapFS{
|
||||
"lang/pl/backend/lang.yaml": {Data: []byte("form:\n save: Zachowaj\n extra: Dodatkowy\n")},
|
||||
"lang/de/backend/lang.yaml": {Data: []byte("form:\n save: Speichern\n")},
|
||||
"lang/en/acme.demo/lang.yaml": {Data: []byte("title: Demo app\n")},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
for _, tc := range []struct{ locale, key, want string }{
|
||||
{"pl", "backend::lang.form.save", "Zachowaj"},
|
||||
{"pl", "backend::lang.form.extra", "Dodatkowy"},
|
||||
{"pl", "backend::lang.form.cancel", "Anuluj"},
|
||||
{"de", "backend::lang.form.save", "Speichern"},
|
||||
{"en", "backend::lang.form.save", "Save"},
|
||||
{"en", "acme.demo::lang.title", "Demo app"},
|
||||
} {
|
||||
if got := tr.GetIn(tc.locale, tc.key, nil); got != tc.want {
|
||||
t.Fatalf("%s %s = %q want %q", tc.locale, tc.key, got, tc.want)
|
||||
}
|
||||
}
|
||||
if bundle := tr.Bundle("de", "backend::lang."); bundle["backend::lang.form.save"]["other"] != "Speichern" || bundle["backend::lang.form.cancel"]["other"] != "Cancel" {
|
||||
t.Fatalf("de bundle did not merge the fallback: save=%v cancel=%v", bundle["backend::lang.form.save"], bundle["backend::lang.form.cancel"])
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
overrides fstest.MapFS
|
||||
want []string
|
||||
}{
|
||||
{"missing namespace segment", fstest.MapFS{"lang/pl/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
|
||||
{"not yaml", fstest.MapFS{"lang/pl/backend/lang.txt": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
|
||||
{"too deep", fstest.MapFS{"lang/pl/backend/sub/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
|
||||
{"unknown namespace", fstest.MapFS{"lang/pl/acme.missing/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "acme.missing"}},
|
||||
{"unconvertible backend key", fstest.MapFS{"lang/en/backend/lang.yaml": {Data: []byte("list:\n results: \"{0} No results|[1,*] :count results\"\n")}}, []string{"backend::lang.list.results", "CLDR"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: tc.overrides})
|
||||
if err == nil {
|
||||
t.Fatal("activation accepted a bad override")
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("err=%v missing %q", err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var spaKey = regexp.MustCompile(`backend::lang\.[A-Za-z0-9_.]*[A-Za-z0-9_]`)
|
||||
|
||||
// TestPhase10SPAKeysResolve walks the admin SPA sources and resolves every
|
||||
// backend::lang key literal in pl and en, each without falling back to the
|
||||
// other locale.
|
||||
func TestPhase10SPAKeysResolve(t *testing.T) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("caller")
|
||||
}
|
||||
root := filepath.Join(filepath.Dir(file), "..", "admin", "src")
|
||||
keys := map[string]bool{}
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !(strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".vue")) {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range spaKey.FindAllString(string(raw), -1) {
|
||||
keys[key] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) < 10 {
|
||||
t.Fatalf("found only %d backend::lang keys under %s", len(keys), root)
|
||||
}
|
||||
sorted := make([]string, 0, len(keys))
|
||||
for key := range keys {
|
||||
sorted = append(sorted, key)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
cat := NewCatalog()
|
||||
if err := cat.Load("backend", backendLang()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, locale := range []string{"pl", "en"} {
|
||||
tr := NewTranslator(cat, Options{Locale: locale, Fallback: locale})
|
||||
for _, key := range sorted {
|
||||
forms, ok := tr.Forms(locale, key)
|
||||
if !ok || forms["other"] == "" {
|
||||
t.Errorf("%s: %s does not resolve", locale, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user