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:
Jakub Zych
2026-09-28 02:21:02 +02:00
parent ac1f6d14f4
commit 5e50b166ef
277 changed files with 303 additions and 303 deletions

View 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.

View 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.

View 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
}

View 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."

View 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."

View 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)
}
}

View 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")
}
}

View 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
}
}

View 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)
}
})
}