refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
133
modules/phrasebook/backend/lang/en/lang.yaml
Normal file
133
modules/phrasebook/backend/lang/en/lang.yaml
Normal file
@@ -0,0 +1,133 @@
|
||||
# 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
|
||||
search: Search
|
||||
delete_failed: The selected records could not be deleted.
|
||||
filters: Filters
|
||||
filter_all: All
|
||||
filter_from: From
|
||||
filter_to: To
|
||||
pagination: Pagination
|
||||
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
|
||||
more_options: More results
|
||||
tab_default: General
|
||||
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."
|
||||
load_failed: The form could not be loaded.
|
||||
error_generic: The changes could not be saved. Please try again.
|
||||
unsaved_confirm: You have unsaved changes. Discard them?
|
||||
discard: Discard changes
|
||||
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"
|
||||
no_candidates: There are no records to add.
|
||||
load_failed: The linked records could not be loaded.
|
||||
settings:
|
||||
title: Settings
|
||||
empty: No settings are available.
|
||||
saved: Settings saved
|
||||
back: Back to settings
|
||||
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.
|
||||
149
modules/phrasebook/backend/lang/pl/lang.yaml
Normal file
149
modules/phrasebook/backend/lang/pl/lang.yaml
Normal file
@@ -0,0 +1,149 @@
|
||||
# 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
|
||||
search: Szukaj
|
||||
delete_failed: Nie udało się usunąć zaznaczonych rekordów.
|
||||
filters: Filtry
|
||||
filter_all: Wszystkie
|
||||
filter_from: Od
|
||||
filter_to: Do
|
||||
pagination: Stronicowanie
|
||||
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
|
||||
more_options: Więcej wyników
|
||||
tab_default: Podstawowe
|
||||
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."
|
||||
load_failed: Nie udało się wczytać formularza.
|
||||
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
|
||||
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
|
||||
discard: Porzuć zmiany
|
||||
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"
|
||||
no_candidates: Brak rekordów do dodania.
|
||||
load_failed: Nie udało się wczytać powiązanych rekordów.
|
||||
settings:
|
||||
title: Ustawienia
|
||||
empty: Brak dostępnych ustawień.
|
||||
saved: Zapisano ustawienia
|
||||
back: Wróć do ustawień
|
||||
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.
|
||||
22
modules/phrasebook/lang.go
Normal file
22
modules/phrasebook/lang.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package phrasebook
|
||||
|
||||
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
|
||||
}
|
||||
13
modules/phrasebook/lang/en/validate.yaml
Normal file
13
modules/phrasebook/lang/en/validate.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
required: "The :attribute field is required."
|
||||
email: "The :attribute must be a valid email address."
|
||||
between: "The :attribute must be between :min and :max characters."
|
||||
boolean: "The :attribute field must be true or false."
|
||||
unique: "The :attribute has already been taken."
|
||||
confirmed: "The :attribute confirmation does not match."
|
||||
different: "The :attribute and :other must be different."
|
||||
integer: "The :attribute must be an integer."
|
||||
numeric: "The :attribute must be a number."
|
||||
max: "The :attribute may not be greater than :max."
|
||||
min: "The :attribute must be at least :min."
|
||||
oneof: "The selected :attribute is invalid."
|
||||
mimes: "The :attribute must be a file of the allowed types."
|
||||
13
modules/phrasebook/lang/pl/validate.yaml
Normal file
13
modules/phrasebook/lang/pl/validate.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
required: "Pole :attribute jest wymagane."
|
||||
email: "Format :attribute jest nieprawidłowy."
|
||||
between: ":attribute musi zawierać się w granicach :min - :max znaków."
|
||||
boolean: ":attribute musi mieć wartość prawda albo fałsz"
|
||||
unique: "Taki :attribute już występuje."
|
||||
confirmed: "Potwierdzenie :attribute nie zgadza się."
|
||||
different: ":attribute oraz :other muszą się różnić."
|
||||
integer: ":attribute musi być liczbą całkowitą."
|
||||
numeric: ":attribute musi być liczbą."
|
||||
max: ":attribute nie może być dłuższy niż :max znaków."
|
||||
min: ":attribute musi mieć przynajmniej :min znaków."
|
||||
oneof: "Zaznaczony :attribute jest nieprawidłowy."
|
||||
mimes: ":attribute musi być plikiem typu :values."
|
||||
520
modules/phrasebook/loader.go
Normal file
520
modules/phrasebook/loader.go
Normal file
@@ -0,0 +1,520 @@
|
||||
package phrasebook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// Catalog is an immutable-after-load set of namespaced translation entries.
|
||||
type Catalog struct {
|
||||
// locale -> full key (vendor.plugin::group.dot.path) -> entry
|
||||
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 {
|
||||
text string
|
||||
plurals map[string]string
|
||||
pipes []pipePart
|
||||
}
|
||||
|
||||
type pipePart struct {
|
||||
exact *float64
|
||||
lo *float64
|
||||
hi *float64
|
||||
text string
|
||||
cat string
|
||||
}
|
||||
|
||||
var cldrNames = map[string]struct{}{
|
||||
"zero": {},
|
||||
"one": {},
|
||||
"two": {},
|
||||
"few": {},
|
||||
"many": {},
|
||||
"other": {},
|
||||
}
|
||||
|
||||
var cldrOrder = []string{"zero", "one", "two", "few", "many", "other"}
|
||||
|
||||
// NewCatalog returns an empty translation catalog.
|
||||
func NewCatalog() *Catalog {
|
||||
return &Catalog{
|
||||
messages: make(map[string]map[string]entry),
|
||||
namespaces: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Load walks fsys for lang/<locale>/<group>.yaml files, flattens nested YAML
|
||||
// maps into vendor.plugin::group.dot.path keys, and merges them into c.
|
||||
func (c *Catalog) Load(pluginID string, fsys fs.FS) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("phrasebook: catalog is nil")
|
||||
}
|
||||
if pluginID == "" {
|
||||
return fmt.Errorf("phrasebook: plugin id is empty")
|
||||
}
|
||||
if fsys == nil {
|
||||
return nil
|
||||
}
|
||||
if owner, ok := c.namespaces[pluginID]; ok {
|
||||
return catalogErrorf(pluginID, "", "", "duplicate namespace owner %q", owner)
|
||||
}
|
||||
if c.messages == nil {
|
||||
c.messages = make(map[string]map[string]entry)
|
||||
}
|
||||
if c.namespaces == nil {
|
||||
c.namespaces = make(map[string]string)
|
||||
}
|
||||
|
||||
var files []string
|
||||
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
files = append(files, path.Clean(p))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return catalogErrorf(pluginID, "", "", "walk lang fs: %s", err.Error())
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, file := range files {
|
||||
locale, group, err := parseLangPath(file)
|
||||
if err != nil {
|
||||
return catalogErrorf(pluginID, file, "", "%s", err.Error())
|
||||
}
|
||||
raw, err := fs.ReadFile(fsys, file)
|
||||
if err != nil {
|
||||
return catalogErrorf(pluginID, file, "", "read: %s", err.Error())
|
||||
}
|
||||
if err := c.loadFile(pluginID, file, locale, group, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.namespaces[pluginID] = pluginID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) loadFile(pluginID, file, locale, group string, raw []byte) error {
|
||||
var decoded any
|
||||
if err := yaml.Unmarshal(raw, &decoded); err != nil {
|
||||
return catalogErrorf(pluginID, file, "", "parse: %s", err.Error())
|
||||
}
|
||||
if decoded == nil {
|
||||
return nil
|
||||
}
|
||||
root, err := asStringMap(decoded)
|
||||
if err != nil {
|
||||
return catalogErrorf(pluginID, file, "", "%s", err.Error())
|
||||
}
|
||||
prefix := pluginID + "::" + group
|
||||
return flatten(c, pluginID, file, locale, prefix, root)
|
||||
}
|
||||
|
||||
func flatten(c *Catalog, pluginID, file, locale, prefix string, v any) error {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return c.putString(pluginID, file, locale, prefix, val)
|
||||
case map[string]any:
|
||||
if len(val) == 0 {
|
||||
return nil
|
||||
}
|
||||
if isPluralMap(val) {
|
||||
return c.putPluralMap(pluginID, file, locale, prefix, val)
|
||||
}
|
||||
keys := make([]string, 0, len(val))
|
||||
for k := range val {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
next := joinKey(prefix, k)
|
||||
if err := flatten(c, pluginID, file, locale, next, val[k]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case map[any]any:
|
||||
converted, err := anyMapToStringMap(val)
|
||||
if err != nil {
|
||||
return catalogErrorf(pluginID, file, prefix, "%s", err.Error())
|
||||
}
|
||||
return flatten(c, pluginID, file, locale, prefix, converted)
|
||||
case nil:
|
||||
return catalogErrorf(pluginID, file, prefix, "non-string leaf (null)")
|
||||
default:
|
||||
return catalogErrorf(pluginID, file, prefix, "non-string leaf (%T)", v)
|
||||
}
|
||||
}
|
||||
|
||||
func isPluralMap(m map[string]any) bool {
|
||||
if len(m) == 0 {
|
||||
return false
|
||||
}
|
||||
for k := range m {
|
||||
if _, ok := cldrNames[k]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Catalog) putString(pluginID, file, locale, key, text string) error {
|
||||
e := entry{text: text}
|
||||
if strings.Contains(text, "|") {
|
||||
parts, err := parsePipe(text, locale)
|
||||
if err != nil {
|
||||
return catalogErrorf(pluginID, file, key, "%s", err.Error())
|
||||
}
|
||||
e.pipes = parts
|
||||
}
|
||||
return c.put(pluginID, file, locale, key, e)
|
||||
}
|
||||
|
||||
func (c *Catalog) putPluralMap(pluginID, file, locale, key string, raw map[string]any) error {
|
||||
if _, ok := raw["other"]; !ok {
|
||||
return catalogErrorf(pluginID, file, key, "plural map requires other")
|
||||
}
|
||||
allowed, err := cldrCategorySet(locale)
|
||||
if err != nil {
|
||||
return catalogErrorf(pluginID, file, key, "%s", err.Error())
|
||||
}
|
||||
plurals := make(map[string]string, len(raw))
|
||||
keys := make([]string, 0, len(raw))
|
||||
for k := range raw {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if _, ok := allowed[k]; !ok {
|
||||
return catalogErrorf(pluginID, file, key, "invalid plural category %q for locale %s", k, locale)
|
||||
}
|
||||
s, ok := raw[k].(string)
|
||||
if !ok {
|
||||
return catalogErrorf(pluginID, file, joinKey(key, k), "non-string leaf (%T)", raw[k])
|
||||
}
|
||||
plurals[k] = s
|
||||
}
|
||||
return c.put(pluginID, file, locale, key, entry{plurals: plurals})
|
||||
}
|
||||
|
||||
func (c *Catalog) put(pluginID, file, locale, key string, e entry) error {
|
||||
if key == "" || strings.HasSuffix(key, "::") || strings.HasSuffix(key, ".") {
|
||||
return catalogErrorf(pluginID, file, key, "empty key")
|
||||
}
|
||||
byKey := c.messages[locale]
|
||||
if byKey == nil {
|
||||
byKey = make(map[string]entry)
|
||||
c.messages[locale] = byKey
|
||||
}
|
||||
if _, exists := byKey[key]; exists && !c.replace {
|
||||
return catalogErrorf(pluginID, file, key, "duplicate key")
|
||||
}
|
||||
byKey[key] = e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) lookup(locale, key string) (entry, bool) {
|
||||
if c == nil {
|
||||
return entry{}, false
|
||||
}
|
||||
byKey := c.messages[locale]
|
||||
if byKey == nil {
|
||||
return entry{}, false
|
||||
}
|
||||
e, ok := byKey[key]
|
||||
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, "/")
|
||||
if p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") {
|
||||
return "", "", fmt.Errorf("malformed path")
|
||||
}
|
||||
parts := strings.Split(p, "/")
|
||||
if len(parts) != 3 || parts[0] != "lang" {
|
||||
return "", "", fmt.Errorf("malformed path")
|
||||
}
|
||||
locale = parts[1]
|
||||
base := parts[2]
|
||||
if !strings.HasSuffix(base, ".yaml") {
|
||||
return "", "", fmt.Errorf("malformed path")
|
||||
}
|
||||
group = strings.TrimSuffix(base, ".yaml")
|
||||
if !validLocale(locale) || !validGroup(group) {
|
||||
return "", "", fmt.Errorf("malformed path")
|
||||
}
|
||||
return locale, group, nil
|
||||
}
|
||||
|
||||
func validLocale(s string) bool {
|
||||
if s == "" || s == "." || s == ".." {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
continue
|
||||
}
|
||||
if (r == '-' || r == '_') && i > 0 && i < len(s)-1 {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validGroup(s string) bool {
|
||||
if s == "" || s == "." || s == ".." {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func joinKey(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
if key == "" {
|
||||
return prefix
|
||||
}
|
||||
return prefix + "." + key
|
||||
}
|
||||
|
||||
func asStringMap(v any) (map[string]any, error) {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
return val, nil
|
||||
case map[any]any:
|
||||
return anyMapToStringMap(val)
|
||||
default:
|
||||
return nil, fmt.Errorf("root must be a mapping")
|
||||
}
|
||||
}
|
||||
|
||||
func anyMapToStringMap(v map[any]any) (map[string]any, error) {
|
||||
out := make(map[string]any, len(v))
|
||||
for k, val := range v {
|
||||
sk, ok := k.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("non-string key (%T)", k)
|
||||
}
|
||||
out[sk] = val
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var (
|
||||
pipeExact = regexp.MustCompile(`^\{(-?\d+)\}\s*(.*)$`)
|
||||
pipeRange = regexp.MustCompile(`^\[(-?\d+),(-?\d+|\*)\]\s*(.*)$`)
|
||||
)
|
||||
|
||||
func parsePipe(text, locale string) ([]pipePart, error) {
|
||||
raw := strings.Split(text, "|")
|
||||
if len(raw) < 2 {
|
||||
return nil, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
cats, _, err := cldrCategories(locale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := make([]pipePart, 0, len(raw))
|
||||
var forms []pipePart
|
||||
for _, seg := range raw {
|
||||
seg = strings.TrimSpace(seg)
|
||||
if seg == "" {
|
||||
return nil, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
part, err := parsePipeSegment(seg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, part)
|
||||
if part.exact == nil && part.lo == nil {
|
||||
forms = append(forms, part)
|
||||
}
|
||||
}
|
||||
if len(forms) > 0 && len(forms) != len(cats) {
|
||||
return nil, fmt.Errorf("incomplete pipe")
|
||||
}
|
||||
fi := 0
|
||||
for i := range parts {
|
||||
if parts[i].exact == nil && parts[i].lo == nil {
|
||||
parts[i].cat = cats[fi]
|
||||
fi++
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func parsePipeSegment(seg string) (pipePart, error) {
|
||||
if strings.HasPrefix(seg, "{") {
|
||||
m := pipeExact.FindStringSubmatch(seg)
|
||||
if m == nil {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
n, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
return pipePart{exact: &n, text: m[2]}, nil
|
||||
}
|
||||
if strings.HasPrefix(seg, "[") {
|
||||
m := pipeRange.FindStringSubmatch(seg)
|
||||
if m == nil {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
lo, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
part := pipePart{lo: &lo, text: m[3]}
|
||||
if m[2] == "*" {
|
||||
return part, nil
|
||||
}
|
||||
hi, err := strconv.ParseFloat(m[2], 64)
|
||||
if err != nil {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
if lo > hi {
|
||||
return pipePart{}, fmt.Errorf("malformed pipe")
|
||||
}
|
||||
part.hi = &hi
|
||||
return part, nil
|
||||
}
|
||||
return pipePart{text: seg}, nil
|
||||
}
|
||||
|
||||
func catalogErrorf(pluginID, file, key, format string, args ...any) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
switch {
|
||||
case key != "":
|
||||
return fmt.Errorf("phrasebook: plugin %s file %s key %s: %s", pluginID, file, key, msg)
|
||||
case file != "":
|
||||
return fmt.Errorf("phrasebook: plugin %s file %s: %s", pluginID, file, msg)
|
||||
default:
|
||||
return fmt.Errorf("phrasebook: plugin %s: %s", pluginID, msg)
|
||||
}
|
||||
}
|
||||
333
modules/phrasebook/phase10_test.go
Normal file
333
modules/phrasebook/phase10_test.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package phrasebook
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhase10LangPrecedence pins the D-20 layering: an override beats the
|
||||
// plugin's own strings and the framework's backend strings, a later plugin's
|
||||
// override beats an earlier one, and an override can add a whole locale.
|
||||
func TestPhase10LangPrecedence(t *testing.T) {
|
||||
demoLang := fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("title: Demo\nsubtitle: Widgets\n")},
|
||||
"lang/pl/lang.yaml": {Data: []byte("title: Demo PL\n")},
|
||||
}
|
||||
first := overridePlugin{id: "acme.demo", lang: demoLang, overrides: fstest.MapFS{
|
||||
"lang/pl/backend/lang.yaml": {Data: []byte("form:\n save: Pierwszy\n cancel: Pierwsze anuluj\n")},
|
||||
"lang/pl/acme.demo/lang.yaml": {Data: []byte("title: Demo nadpisane\n")},
|
||||
}}
|
||||
second := overridePlugin{id: "acme.site", overrides: fstest.MapFS{
|
||||
"lang/pl/backend/lang.yaml": {Data: []byte("form:\n save: Drugi\n")},
|
||||
"lang/fr/backend/lang.yaml": {Data: []byte("form:\n save: Enregistrer\n")},
|
||||
"lang/fr/acme.demo/lang.yaml": {Data: []byte("title: Démo\n")},
|
||||
}}
|
||||
tr, err := activateWith(t, first, second)
|
||||
if err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
for _, tc := range []struct{ locale, key, want string }{
|
||||
// Framework string untouched by any override.
|
||||
{"pl", "backend::lang.form.delete", "Usuń"},
|
||||
// Override beats framework; the later plugin's override wins.
|
||||
{"pl", "backend::lang.form.save", "Drugi"},
|
||||
{"pl", "backend::lang.form.cancel", "Pierwsze anuluj"},
|
||||
// Override beats the plugin's own string; the plugin beats its fallback.
|
||||
{"pl", "acme.demo::lang.title", "Demo nadpisane"},
|
||||
{"en", "acme.demo::lang.title", "Demo"},
|
||||
{"pl", "acme.demo::lang.subtitle", "Widgets"},
|
||||
// A locale added only through overrides.
|
||||
{"fr", "backend::lang.form.save", "Enregistrer"},
|
||||
{"fr", "acme.demo::lang.title", "Démo"},
|
||||
{"fr", "backend::lang.form.delete", "Delete"},
|
||||
} {
|
||||
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 got := tr.Resolved("fr", "backend::lang."); got != "fr" {
|
||||
t.Fatalf("Resolved(fr) = %q", got)
|
||||
}
|
||||
if got := tr.Resolved("de", "backend::lang."); got != "en" {
|
||||
t.Fatalf("Resolved(de) = %q, want the en fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhase10BundleMerge proves Bundle layers the requested locale over its
|
||||
// region parent and the fallback, filters by prefix and skips keys that do
|
||||
// not convert to CLDR forms.
|
||||
func TestPhase10BundleMerge(t *testing.T) {
|
||||
cat := NewCatalog()
|
||||
if err := cat.Load("acme.demo", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("a: A en\nb: B en\nc: C en\nexact: \"{0} none|[1,*] :count\"\n")},
|
||||
"lang/pt/lang.yaml": {Data: []byte("a: A pt\nb: B pt\n")},
|
||||
"lang/pt-BR/lang.yaml": {Data: []byte("a: A pt-BR\n")},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cat.Load("acme.other", fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("a: other\n")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := NewTranslator(cat, Options{Locale: "en", Fallback: "en"})
|
||||
bundle := tr.Bundle("pt-BR", "acme.demo::")
|
||||
want := map[string]string{
|
||||
"acme.demo::lang.a": "A pt-BR",
|
||||
"acme.demo::lang.b": "B pt",
|
||||
"acme.demo::lang.c": "C en",
|
||||
}
|
||||
if len(bundle) != len(want) {
|
||||
t.Fatalf("bundle = %v", bundle)
|
||||
}
|
||||
for key, text := range want {
|
||||
if bundle[key]["other"] != text {
|
||||
t.Fatalf("%s = %v want %q", key, bundle[key], text)
|
||||
}
|
||||
}
|
||||
if _, ok := bundle["acme.demo::lang.exact"]; ok {
|
||||
t.Fatal("an exact/range pipe entered the bundle")
|
||||
}
|
||||
if got := tr.Resolved("pt-BR", "acme.demo::"); got != "pt-BR" {
|
||||
t.Fatalf("Resolved = %q", got)
|
||||
}
|
||||
var nilTr *Translator
|
||||
if b := nilTr.Bundle("en", ""); len(b) != 0 {
|
||||
t.Fatalf("nil translator bundle = %v", b)
|
||||
}
|
||||
if _, ok := nilTr.Forms("en", "x"); ok {
|
||||
t.Fatal("nil translator produced forms")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhase10FormsShapes converts every entry shape the catalog stores: plain
|
||||
// text, a YAML plural map, a category pipe, and pipes that cannot convert
|
||||
// (exact, range, or without an other form).
|
||||
func TestPhase10FormsShapes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
e entry
|
||||
want map[string]string
|
||||
ok bool
|
||||
}{
|
||||
{"plain", entry{text: "Hi"}, map[string]string{"other": "Hi"}, true},
|
||||
{"empty plain", entry{}, map[string]string{"other": ""}, true},
|
||||
{"plural map", entry{plurals: map[string]string{"one": "1", "other": "n"}}, map[string]string{"one": "1", "other": "n"}, true},
|
||||
{"category pipe", entry{pipes: []pipePart{{cat: "one", text: "1"}, {cat: "other", text: "n"}}}, map[string]string{"one": "1", "other": "n"}, true},
|
||||
{"pipe without other", entry{pipes: []pipePart{{cat: "one", text: "1"}}}, nil, false},
|
||||
{"pipe with an uncategorised part", entry{pipes: []pipePart{{text: "x"}, {cat: "other", text: "n"}}}, nil, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := entryForms(tc.e)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("ok=%v want %v (%v)", ok, tc.ok, got)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("forms=%v want %v", got, tc.want)
|
||||
}
|
||||
for cat, text := range tc.want {
|
||||
if got[cat] != text {
|
||||
t.Fatalf("forms=%v want %v", got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
one, two := 0.0, 1.0
|
||||
if _, ok := entryForms(entry{pipes: []pipePart{{exact: &one, text: "none"}, {cat: "other", text: "n"}}}); ok {
|
||||
t.Fatal("exact pipe converted")
|
||||
}
|
||||
if _, ok := entryForms(entry{pipes: []pipePart{{lo: &two, text: "some"}, {cat: "other", text: "n"}}}); ok {
|
||||
t.Fatal("range pipe converted")
|
||||
}
|
||||
}
|
||||
579
modules/phrasebook/translator.go
Normal file
579
modules/phrasebook/translator.go
Normal file
@@ -0,0 +1,579 @@
|
||||
package phrasebook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n/template"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLocale = "en"
|
||||
defaultFallback = "en"
|
||||
)
|
||||
|
||||
var (
|
||||
bundleCache sync.Map // locale -> *i18n.Bundle
|
||||
categoryCache sync.Map // locale -> []string
|
||||
identityParse = template.IdentityParser{}
|
||||
pluralSamples = []any{
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
|
||||
20, 21, 22, 23, 24, 25, 100, 101, 102, 111, 1000, 1000000,
|
||||
"0.0", "0.1", "0.5", "1.0", "1.1", "1.5", "2.0", "2.5", "10.0", "100.0",
|
||||
}
|
||||
categoryMessage = &i18n.Message{
|
||||
ID: "phrasebook.plural.category",
|
||||
Zero: "zero",
|
||||
One: "one",
|
||||
Two: "two",
|
||||
Few: "few",
|
||||
Many: "many",
|
||||
Other: "other",
|
||||
}
|
||||
)
|
||||
|
||||
// Options configures an app-scoped translator.
|
||||
type Options struct {
|
||||
Locale string
|
||||
Fallback string
|
||||
Production bool
|
||||
}
|
||||
|
||||
// Translator resolves namespaced translation keys from a loaded catalog.
|
||||
type Translator struct {
|
||||
cat *Catalog
|
||||
locale string
|
||||
fallback string
|
||||
production bool
|
||||
missing sync.Map
|
||||
}
|
||||
|
||||
// NewTranslator returns a translator that reads from cat. Empty locale or
|
||||
// fallback values become the framework defaults en/en.
|
||||
func NewTranslator(cat *Catalog, opts Options) *Translator {
|
||||
if cat == nil {
|
||||
cat = NewCatalog()
|
||||
}
|
||||
locale := strings.TrimSpace(opts.Locale)
|
||||
if locale == "" {
|
||||
locale = defaultLocale
|
||||
}
|
||||
fallback := strings.TrimSpace(opts.Fallback)
|
||||
if fallback == "" {
|
||||
fallback = defaultFallback
|
||||
}
|
||||
return &Translator{
|
||||
cat: cat,
|
||||
locale: locale,
|
||||
fallback: fallback,
|
||||
production: opts.Production,
|
||||
}
|
||||
}
|
||||
|
||||
// Activate loads HasLang catalogs in plugin order, then publishes one
|
||||
// Translator on app before Boot. app.locale and app.fallback_locale default to en.
|
||||
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("phrasebook: app is nil")
|
||||
}
|
||||
cat := NewCatalog()
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
if err := cat.Load(p.ID(), hl.LangFS()); err != nil {
|
||||
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 != "" {
|
||||
opts.Locale = v
|
||||
}
|
||||
if v := strings.TrimSpace(app.Config.String("app.fallback_locale")); v != "" {
|
||||
opts.Fallback = v
|
||||
}
|
||||
opts.Production = app.Config.Environment() == "production"
|
||||
}
|
||||
if err := app.Publish(NewTranslator(cat, opts)); err != nil {
|
||||
return fmt.Errorf("phrasebook: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Locale returns the configured app locale. Request locales stay on the context.
|
||||
func (t *Translator) Locale() string {
|
||||
if t == nil || strings.TrimSpace(t.locale) == "" {
|
||||
return defaultLocale
|
||||
}
|
||||
return t.locale
|
||||
}
|
||||
|
||||
// Get returns the translation for key using the request locale when present,
|
||||
// otherwise the configured app locale.
|
||||
func (t *Translator) Get(ctx context.Context, key string, params map[string]string) string {
|
||||
return t.GetIn(t.localeFrom(ctx), key, params)
|
||||
}
|
||||
|
||||
// GetIn looks up key in an explicit locale.
|
||||
func (t *Translator) GetIn(locale, key string, params map[string]string) string {
|
||||
if t == nil {
|
||||
return key
|
||||
}
|
||||
e, _, ok := t.find(locale, key)
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
text := e.text
|
||||
if text == "" && e.plurals != nil {
|
||||
text = e.plurals["other"]
|
||||
}
|
||||
return interpolate(text, params)
|
||||
}
|
||||
|
||||
// Choice selects a plural form for key using CLDR rules or Laravel pipe conditions.
|
||||
func (t *Translator) Choice(ctx context.Context, key string, count any, params map[string]string) string {
|
||||
return t.ChoiceIn(t.localeFrom(ctx), key, count, params)
|
||||
}
|
||||
|
||||
// ChoiceIn is the explicit-locale form of Choice.
|
||||
func (t *Translator) ChoiceIn(locale, key string, count any, params map[string]string) string {
|
||||
if t == nil {
|
||||
return key
|
||||
}
|
||||
e, found, ok := t.find(locale, key)
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
text := e.text
|
||||
switch {
|
||||
case len(e.pipes) > 0:
|
||||
text = selectPipe(e.pipes, found, count)
|
||||
case e.plurals != nil:
|
||||
text = selectMap(e.plurals, found, count)
|
||||
}
|
||||
return interpolate(text, withCount(params, count))
|
||||
}
|
||||
|
||||
func (t *Translator) find(locale, key string) (entry, string, bool) {
|
||||
if t == nil {
|
||||
return entry{}, locale, false
|
||||
}
|
||||
fallback := defaultFallback
|
||||
if t.fallback != "" {
|
||||
fallback = t.fallback
|
||||
}
|
||||
for _, step := range fallbackChain(locale, fallback) {
|
||||
if e, ok := t.cat.lookup(step, key); ok {
|
||||
return e, step, true
|
||||
}
|
||||
}
|
||||
t.logMissing(key)
|
||||
return entry{}, locale, false
|
||||
}
|
||||
|
||||
func (t *Translator) logMissing(key string) {
|
||||
if t.production {
|
||||
return
|
||||
}
|
||||
if _, loaded := t.missing.LoadOrStore(key, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
slog.Warn("phrasebook: missing translation key", "key", key)
|
||||
}
|
||||
|
||||
func fallbackChain(requested, fallback string) []string {
|
||||
out := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{}, 4)
|
||||
add := func(s string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
return
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
add(requested)
|
||||
if tag, err := language.Parse(requested); err == nil {
|
||||
add(tag.String())
|
||||
for p := tag.Parent(); !p.IsRoot(); p = p.Parent() {
|
||||
add(p.String())
|
||||
}
|
||||
} else if i := strings.IndexAny(requested, "-_"); i > 0 {
|
||||
add(requested[:i])
|
||||
}
|
||||
add(fallback)
|
||||
return out
|
||||
}
|
||||
|
||||
func selectMap(plurals map[string]string, locale string, count any) string {
|
||||
cat, err := cldrCategory(locale, count)
|
||||
if err != nil {
|
||||
return plurals["other"]
|
||||
}
|
||||
if s, ok := plurals[cat]; ok {
|
||||
return s
|
||||
}
|
||||
return plurals["other"]
|
||||
}
|
||||
|
||||
func selectPipe(parts []pipePart, locale string, count any) string {
|
||||
if n, ok := asFloat(count); ok {
|
||||
for _, p := range parts {
|
||||
if p.exact != nil && n == *p.exact {
|
||||
return p.text
|
||||
}
|
||||
if p.lo != nil && n >= *p.lo && (p.hi == nil || n <= *p.hi) {
|
||||
return p.text
|
||||
}
|
||||
}
|
||||
}
|
||||
cat, err := cldrCategory(locale, count)
|
||||
if err != nil {
|
||||
cat = "other"
|
||||
}
|
||||
var other, last string
|
||||
for _, p := range parts {
|
||||
last = p.text
|
||||
if p.cat == cat {
|
||||
return p.text
|
||||
}
|
||||
if p.cat == "other" {
|
||||
other = p.text
|
||||
}
|
||||
}
|
||||
if other != "" {
|
||||
return other
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func cldrCategory(locale string, count any) (string, error) {
|
||||
if strings.TrimSpace(locale) == "" {
|
||||
locale = defaultLocale
|
||||
}
|
||||
loc := i18n.NewLocalizer(bundleFor(locale), locale)
|
||||
return loc.Localize(&i18n.LocalizeConfig{
|
||||
DefaultMessage: categoryMessage,
|
||||
PluralCount: coercePluralCount(count),
|
||||
TemplateParser: identityParse,
|
||||
})
|
||||
}
|
||||
|
||||
func bundleFor(locale string) *i18n.Bundle {
|
||||
if v, ok := bundleCache.Load(locale); ok {
|
||||
return v.(*i18n.Bundle)
|
||||
}
|
||||
tag, err := language.Parse(locale)
|
||||
if err != nil {
|
||||
tag = language.English
|
||||
}
|
||||
b := i18n.NewBundle(tag)
|
||||
actual, _ := bundleCache.LoadOrStore(locale, b)
|
||||
return actual.(*i18n.Bundle)
|
||||
}
|
||||
|
||||
func coercePluralCount(count any) any {
|
||||
switch count.(type) {
|
||||
case float32, float64:
|
||||
return formatCount(count)
|
||||
default:
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
func cldrCategories(locale string) ([]string, map[string]struct{}, error) {
|
||||
if v, ok := categoryCache.Load(locale); ok {
|
||||
ordered := v.([]string)
|
||||
return ordered, setFrom(ordered), nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, n := range pluralSamples {
|
||||
cat, err := cldrCategory(locale, n)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
seen[cat] = struct{}{}
|
||||
}
|
||||
seen["other"] = struct{}{}
|
||||
ordered := make([]string, 0, len(cldrOrder))
|
||||
for _, cat := range cldrOrder {
|
||||
if _, ok := seen[cat]; ok {
|
||||
ordered = append(ordered, cat)
|
||||
}
|
||||
}
|
||||
categoryCache.Store(locale, ordered)
|
||||
return ordered, seen, nil
|
||||
}
|
||||
|
||||
func cldrCategorySet(locale string) (map[string]struct{}, error) {
|
||||
_, set, err := cldrCategories(locale)
|
||||
return set, err
|
||||
}
|
||||
|
||||
func setFrom(vals []string) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(vals))
|
||||
for _, v := range vals {
|
||||
out[v] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func withCount(params map[string]string, count any) map[string]string {
|
||||
out := make(map[string]string, len(params)+1)
|
||||
for k, v := range params {
|
||||
out[k] = v
|
||||
}
|
||||
if _, ok := out["count"]; !ok {
|
||||
out["count"] = formatCount(count)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatCount(count any) string {
|
||||
switch v := count.(type) {
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
case int8:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
case int16:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
case int64:
|
||||
return strconv.FormatInt(v, 10)
|
||||
case uint:
|
||||
return strconv.FormatUint(uint64(v), 10)
|
||||
case uint64:
|
||||
return strconv.FormatUint(v, 10)
|
||||
case float32:
|
||||
return formatFloat(float64(v))
|
||||
case float64:
|
||||
return formatFloat(v)
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func formatFloat(v float64) string {
|
||||
if v == float64(int64(v)) {
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func asFloat(count any) (float64, bool) {
|
||||
switch v := count.(type) {
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int8:
|
||||
return float64(v), true
|
||||
case int16:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case uint:
|
||||
return float64(v), true
|
||||
case uint64:
|
||||
return float64(v), true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case float64:
|
||||
return v, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Translator) localeFrom(ctx context.Context) string {
|
||||
if loc, ok := towel.Locale(ctx); ok {
|
||||
if loc = strings.TrimSpace(loc); loc != "" {
|
||||
return loc
|
||||
}
|
||||
}
|
||||
if t == nil || t.locale == "" {
|
||||
return defaultLocale
|
||||
}
|
||||
return t.locale
|
||||
}
|
||||
|
||||
func interpolate(text string, params map[string]string) string {
|
||||
if text == "" || len(params) == 0 {
|
||||
return text
|
||||
}
|
||||
type pair struct {
|
||||
placeholder string
|
||||
value string
|
||||
}
|
||||
pairs := make([]pair, 0, len(params)*3)
|
||||
for name, value := range params {
|
||||
name = strings.TrimPrefix(name, ":")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs,
|
||||
pair{placeholder: ":" + ucfirst(name), value: ucfirst(value)},
|
||||
pair{placeholder: ":" + strings.ToUpper(name), value: strings.ToUpper(value)},
|
||||
pair{placeholder: ":" + name, value: value},
|
||||
)
|
||||
}
|
||||
for i := 1; i < len(pairs); i++ {
|
||||
j := i
|
||||
for j > 0 && len(pairs[j].placeholder) > len(pairs[j-1].placeholder) {
|
||||
pairs[j], pairs[j-1] = pairs[j-1], pairs[j]
|
||||
j--
|
||||
}
|
||||
}
|
||||
for _, p := range pairs {
|
||||
text = strings.ReplaceAll(text, p.placeholder, p.value)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func ucfirst(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(s)
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
return s
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
345
modules/phrasebook/translator_test.go
Normal file
345
modules/phrasebook/translator_test.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package phrasebook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
func TestTranslationSmoke(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("greeting: Hello\nlabels:\n pluginName: Hello plugin\n")},
|
||||
"lang/pl/lang.yaml": {Data: []byte("greeting: Cześć\nlabels:\n pluginName: Wtyczka Hello\n")},
|
||||
}
|
||||
cat := NewCatalog()
|
||||
if err := cat.Load("golem15.hello", fsys); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
tr := NewTranslator(cat, Options{})
|
||||
if got := tr.GetIn("en", "golem15.hello::lang.labels.pluginName", nil); got != "Hello plugin" {
|
||||
t.Fatalf("en nested = %q", got)
|
||||
}
|
||||
if got := tr.GetIn("pl", "golem15.hello::lang.labels.pluginName", nil); got != "Wtyczka Hello" {
|
||||
t.Fatalf("pl nested = %q", got)
|
||||
}
|
||||
if got := tr.GetIn("en", "golem15.hello::lang.greeting", nil); got != "Hello" {
|
||||
t.Fatalf("en greeting = %q", got)
|
||||
}
|
||||
if got := tr.GetIn("pl", "golem15.hello::lang.greeting", nil); got != "Cześć" {
|
||||
t.Fatalf("pl greeting = %q", got)
|
||||
}
|
||||
|
||||
t.Run("malformed path", func(t *testing.T) {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en.yaml": {Data: []byte("greeting: Hello\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "malformed path") {
|
||||
t.Fatalf("malformed path error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("non-string leaf", func(t *testing.T) {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("count: 1\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "lang/en/lang.yaml") || !strings.Contains(err.Error(), "non-string leaf") {
|
||||
t.Fatalf("non-string leaf error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("duplicate key", func(t *testing.T) {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("labels:\n name: a\nlabels.name: b\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "duplicate key") {
|
||||
t.Fatalf("duplicate key error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("duplicate namespace owner", func(t *testing.T) {
|
||||
cat := NewCatalog()
|
||||
fsys := fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("greeting: Hello\n")}}
|
||||
if err := cat.Load("golem15.hello", fsys); err != nil {
|
||||
t.Fatalf("first Load: %v", err)
|
||||
}
|
||||
err := cat.Load("golem15.hello", fsys)
|
||||
if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "duplicate namespace owner") {
|
||||
t.Fatalf("duplicate namespace error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("activate publishes translator", func(t *testing.T) {
|
||||
app := backpack.New(&compass.Config{})
|
||||
p := &langPlugin{id: "golem15.hello", fsys: fsys}
|
||||
if err := Activate(app, []*langPlugin{p}); err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
tr, ok := app.Lookup[*Translator]()
|
||||
if !ok {
|
||||
t.Fatal("translator not published")
|
||||
}
|
||||
if got := tr.GetIn("pl", "golem15.hello::lang.greeting", nil); got != "Cześć" {
|
||||
t.Fatalf("activated pl = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type langPlugin struct {
|
||||
id string
|
||||
fsys fstest.MapFS
|
||||
}
|
||||
|
||||
func (p *langPlugin) ID() string { return p.id }
|
||||
func (p *langPlugin) Requires() []string { return nil }
|
||||
func (p *langPlugin) Register(*backpack.App) error { return nil }
|
||||
func (p *langPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p *langPlugin) LangFS() fs.FS { return p.fsys }
|
||||
|
||||
func TestPluralSmoke(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("" +
|
||||
"albums:\n one: \":count album\"\n other: \":count albums\"\n" +
|
||||
"items: \"{0} No items|{1} One item|[2,*] :count items\"\n" +
|
||||
"helloName: \"hi :name :Name :NAME\"\n" +
|
||||
"raw:\n other: \"{{.Count}} bottles\"\n")},
|
||||
"lang/pl/lang.yaml": {Data: []byte("" +
|
||||
"albums:\n one: \":count plik\"\n few: \":count pliki\"\n many: \":count plików\"\n other: \":count pliku\"\n" +
|
||||
"posts: \":count wpis|:count wpisy|:count wpisów|:count wpisu\"\n")},
|
||||
}
|
||||
cat := NewCatalog()
|
||||
if err := cat.Load("golem15.hello", fsys); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
tr := NewTranslator(cat, Options{})
|
||||
|
||||
pl := map[any]string{1: "1 plik", 2: "2 pliki", 5: "5 plików", 22: "22 pliki", 0: "0 plików", 1.5: "1.5 pliku"}
|
||||
for n, want := range pl {
|
||||
if got := tr.ChoiceIn("pl", "golem15.hello::lang.albums", n, nil); got != want {
|
||||
t.Fatalf("pl albums %v = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
en := map[any]string{1: "1 album", 2: "2 albums", 0: "0 albums", 1.5: "1.5 albums"}
|
||||
for n, want := range en {
|
||||
if got := tr.ChoiceIn("en", "golem15.hello::lang.albums", n, nil); got != want {
|
||||
t.Fatalf("en albums %v = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
if got := tr.ChoiceIn("pl", "golem15.hello::lang.posts", 1, nil); got != "1 wpis" {
|
||||
t.Fatalf("pl posts 1 = %q", got)
|
||||
}
|
||||
if got := tr.ChoiceIn("pl", "golem15.hello::lang.posts", 2, nil); got != "2 wpisy" {
|
||||
t.Fatalf("pl posts 2 = %q", got)
|
||||
}
|
||||
if got := tr.ChoiceIn("pl", "golem15.hello::lang.posts", 5, nil); got != "5 wpisów" {
|
||||
t.Fatalf("pl posts 5 = %q", got)
|
||||
}
|
||||
if got := tr.ChoiceIn("pl", "golem15.hello::lang.posts", 22, nil); got != "22 wpisy" {
|
||||
t.Fatalf("pl posts 22 = %q", got)
|
||||
}
|
||||
if got := tr.ChoiceIn("en", "golem15.hello::lang.items", 0, nil); got != "No items" {
|
||||
t.Fatalf("items 0 = %q", got)
|
||||
}
|
||||
if got := tr.ChoiceIn("en", "golem15.hello::lang.items", 1, nil); got != "One item" {
|
||||
t.Fatalf("items 1 = %q", got)
|
||||
}
|
||||
if got := tr.ChoiceIn("en", "golem15.hello::lang.items", 5, nil); got != "5 items" {
|
||||
t.Fatalf("items 5 = %q", got)
|
||||
}
|
||||
if got := tr.ChoiceIn("en", "golem15.hello::lang.helloName", 1, map[string]string{"name": "alice"}); got != "hi alice Alice ALICE" {
|
||||
t.Fatalf("params = %q", got)
|
||||
}
|
||||
if got := tr.ChoiceIn("en", "golem15.hello::lang.raw", 2, nil); got != "{{.Count}} bottles" {
|
||||
t.Fatalf("go-i18n must not evaluate YAML as a template, got %q", got)
|
||||
}
|
||||
|
||||
t.Run("invalid category for locale", func(t *testing.T) {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("albums:\n few: x\n other: y\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid plural category") {
|
||||
t.Fatalf("want invalid category, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("missing other", func(t *testing.T) {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("albums:\n one: x\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires other") {
|
||||
t.Fatalf("want missing other, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("malformed pipe", func(t *testing.T) {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("items: \"{x} nope|{1} one\"\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "malformed pipe") {
|
||||
t.Fatalf("want malformed pipe, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("incomplete pipe", func(t *testing.T) {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("items: \":count post|:count posts|:count extra\"\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete pipe") {
|
||||
t.Fatalf("want incomplete pipe, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLocaleFallbackSmoke(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("greeting: Hello\nonlyEn: English only\n")},
|
||||
"lang/pl/lang.yaml": {Data: []byte("greeting: Cześć\n")},
|
||||
}
|
||||
cat := NewCatalog()
|
||||
if err := cat.Load("golem15.hello", fsys); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
tr := NewTranslator(cat, Options{})
|
||||
if tr.locale != "en" || tr.fallback != "en" {
|
||||
t.Fatalf("defaults locale=%q fallback=%q, want en/en", tr.locale, tr.fallback)
|
||||
}
|
||||
if got := tr.GetIn("pl-PL", "golem15.hello::lang.greeting", nil); got != "Cześć" {
|
||||
t.Fatalf("pl-PL parent = %q", got)
|
||||
}
|
||||
if got := tr.GetIn("pl", "golem15.hello::lang.onlyEn", nil); got != "English only" {
|
||||
t.Fatalf("fallback en = %q", got)
|
||||
}
|
||||
const missing = "golem15.hello::lang.missing"
|
||||
if got := tr.GetIn("pl-PL", missing, nil); got != missing {
|
||||
t.Fatalf("raw key = %q", got)
|
||||
}
|
||||
ctx := towel.WithLocale(context.Background(), "pl")
|
||||
if got := tr.Get(ctx, "golem15.hello::lang.greeting", nil); got != "Cześć" {
|
||||
t.Fatalf("towel locale = %q", got)
|
||||
}
|
||||
if got := tr.Get(context.Background(), "golem15.hello::lang.greeting", nil); got != "Hello" {
|
||||
t.Fatalf("app locale default = %q", got)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
logged := "golem15.hello::lang.loggedOnce"
|
||||
params := map[string]string{"secret": "nope"}
|
||||
tr.GetIn("en", logged, params)
|
||||
tr.GetIn("en", logged, params)
|
||||
log := buf.String()
|
||||
if strings.Count(log, logged) != 1 {
|
||||
t.Fatalf("missing key should log once, got %q", log)
|
||||
}
|
||||
if strings.Contains(log, "secret") || strings.Contains(log, "nope") {
|
||||
t.Fatalf("must not log parameters: %q", log)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
prod := NewTranslator(cat, Options{Production: true})
|
||||
prod.GetIn("en", "golem15.hello::lang.otherMissing", nil)
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("production lookup must be silent, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invalid yaml names plugin and file", func(t *testing.T) {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("greeting: [\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "lang/en/lang.yaml") {
|
||||
t.Fatalf("parse error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("nested group file flattens under group name", func(t *testing.T) {
|
||||
cat := NewCatalog()
|
||||
err := cat.Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/validation.yaml": {Data: []byte("fields:\n name: Name is required\n")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := NewTranslator(cat, Options{})
|
||||
if got := tr.GetIn("en", "golem15.hello::validation.fields.name", nil); got != "Name is required" {
|
||||
t.Fatalf("nested group = %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("path extra segments and wrong suffix are malformed", func(t *testing.T) {
|
||||
for _, file := range []string{"lang/en/nested/lang.yaml", "lang/en/lang.yml", "i18n/en/lang.yaml"} {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
file: {Data: []byte("greeting: Hi\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "malformed path") {
|
||||
t.Fatalf("%s error = %v", file, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("inverted bracket range", func(t *testing.T) {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("items: \"[5,2] nope|{1} one\"\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "lang/en/lang.yaml") || !strings.Contains(err.Error(), "malformed pipe") {
|
||||
t.Fatalf("inverted range error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("empty pipe form", func(t *testing.T) {
|
||||
err := NewCatalog().Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("items: \"one||other\"\n")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "malformed pipe") {
|
||||
t.Fatalf("empty form error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("get substitutes name case variants", func(t *testing.T) {
|
||||
cat := NewCatalog()
|
||||
if err := cat.Load("golem15.hello", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("helloName: \"hi :name :Name :NAME\"\n")},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := NewTranslator(cat, Options{})
|
||||
if got := tr.GetIn("en", "golem15.hello::lang.helloName", map[string]string{"name": "alice"}); got != "hi alice Alice ALICE" {
|
||||
t.Fatalf("Get params = %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("configured locale and fallback", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("locale: pl\nfallback_locale: en\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
p := &langPlugin{id: "golem15.hello", fsys: fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("greeting: Hello\nonlyEn: English only\n")},
|
||||
"lang/pl/lang.yaml": {Data: []byte("greeting: Cześć\n")},
|
||||
}}
|
||||
if err := Activate(app, []*langPlugin{p}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr, ok := app.Lookup[*Translator]()
|
||||
if !ok {
|
||||
t.Fatal("translator not published")
|
||||
}
|
||||
if got := tr.Get(context.Background(), "golem15.hello::lang.greeting", nil); got != "Cześć" {
|
||||
t.Fatalf("configured app.locale = %q", got)
|
||||
}
|
||||
if got := tr.GetIn("pl", "golem15.hello::lang.onlyEn", nil); got != "English only" {
|
||||
t.Fatalf("configured fallback = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user