feat(10-02): backend strings, controller messages and declarative toolbar

- phrasebook ships the backend::lang admin strings (pl, en) with CLDR
  plural maps, loads them as namespace backend, applies
  pact.HasLangOverrides trees (lang/<locale>/<namespace>/<group>.yaml)
  after every namespace, and fails activation when a backend key cannot
  convert to plural forms
- Translator.Forms, Bundle, Resolved and Has serve keys as CLDR form maps
- Public GET /lang returns every backend::lang key for the request
  locale over the fallback locale, Cache-Control no-cache
- config_list, config_form and config_relation accept a strict messages
  block; omitted keys take framework defaults, schemas serve every message
  as CLDR forms, and activation fails on a missing phrase key
- toolbar.buttons is an ordered [create, delete] list; the Winter string
  form, duplicates, unknown actions and delete without showCheckboxes fail
  at boot, and create is dropped when the controller has no form
- Form schema serves the raw Winter redirects; scaffold emits the list
  syntax; form and relation schema routes are typed in the admin OpenAPI
This commit is contained in:
Jakub Zych
2026-09-27 16:16:32 +02:00
parent fe04dbc89e
commit c87148a34f
24 changed files with 2148 additions and 45 deletions

View File

@@ -0,0 +1,119 @@
# Framework admin strings (D-20), resolved as backend::lang.<group>.<key>.
# English equivalents of the Polish copy; plural texts are CLDR maps (one,
# other), never pipe strings. Placeholders use :count, :name, :term (D-24).
auth:
title: Welcome back
login: Login or email
password: Password
invalid: Invalid login details
submit: Sign in
logout: Sign out
session_expired: Your session has expired. Please sign in again.
nav:
plugins: Plugins
sections: Sections
breadcrumbs: Breadcrumbs
empty: No sections are available.
settings: Settings
collapse: Collapse menu
expand: Expand menu
user_menu: User menu
page:
not_found: Page not found.
list:
search_prompt: Search…
no_records: There are no records in this view.
no_results: 0 results
loading: Loading…
load_failed: The list could not be loaded.
delete_selected: Delete selected
prev_page: Previous
next_page: Next
records_per_page: Per page
column_switch_true: "Yes"
column_switch_false: "No"
column_created: Created
column_updated: Updated
pagination_range: ":from–:to of :total"
results:
one: ":count result"
other: ":count results"
empty_value: "—"
clear_search: Clear search
select_all: Select all on this page
select_row: Select row
sort_ascending: Sort ascending
sort_descending: Sort descending
form:
save: Save
save_and_close: Save and close
cancel: Cancel
delete: Delete
none: — none —
update: Edit
create: Create
return_to_list: Back to list
close: Close
confirm: Confirm
saving: Saving…
deleting: Deleting…
add: Add
remove_item: "Remove: :name"
add_item: Add…
select_placeholder: Select…
loading_options: Loading…
no_options: No results
unsupported_field: "Unsupported field type: :type"
error_title: Could not save.
error_fields:
one: "Fix :count field marked below."
other: "Fix :count fields marked below."
error_generic: The changes could not be saved. Please try again.
unsaved_confirm: You have unsaved changes. Discard them?
tab_errors:
one: ":count error"
other: ":count errors"
relation:
add: Add
link: Link
unlink: Unlink
remove: Remove
search: Search…
add_count: "Add (:count)"
candidates_range: ":from–:to of :total"
settings:
title: Settings
empty: No settings are available.
saved: Settings saved
messages:
list:
record_count:
one: ":count record"
other: ":count records"
create: New record
empty_search: Nothing found
empty_search_hint: No record matches “:term”. Try another search.
selected: ":count selected"
delete_confirm: "Delete the selected (:count)?"
deleted:
one: "Deleted :count record"
other: "Deleted :count records"
form:
create: New record
update: Edit record
saved: Saved
delete_confirm: Delete this record?
deleted: Record deleted
relation:
link: Add
link_hint: Choose the records to link.
candidate_search: Search…
linked:
one: "Linked :count record"
other: "Linked :count records"
unlink_selected: Unlink selected
unlink_confirm: "Unlink the selected (:count)?"
unlinked:
one: "Unlinked :count record"
other: "Unlinked :count records"
empty: No linked records.

View File

@@ -0,0 +1,135 @@
# Framework admin strings (D-20), resolved as backend::lang.<group>.<key>.
# Winter backend key names are kept where Winter has them; values follow the
# admin design copy (D-06). Plural texts are CLDR maps (one, few, many,
# other), never pipe strings, so every key converts to the SPA's plural forms.
# Placeholders use :count, :name, :term and friends (D-24).
auth:
title: Witaj ponownie
login: Login lub e-mail
password: Hasło
invalid: Nieprawidłowe dane logowania
submit: Zaloguj
logout: Wyloguj
session_expired: Sesja wygasła. Zaloguj się ponownie.
nav:
plugins: Wtyczki
sections: Sekcje
breadcrumbs: Ścieżka nawigacji
empty: Brak dostępnych sekcji.
settings: Ustawienia
collapse: Zwiń menu
expand: Rozwiń menu
user_menu: Menu użytkownika
page:
not_found: Nie znaleziono strony.
list:
search_prompt: Szukaj…
no_records: Brak rekordów w tym widoku.
no_results: 0 wyników
loading: Wczytywanie…
load_failed: Nie udało się wczytać listy.
delete_selected: Usuń zaznaczone
prev_page: Poprzednia
next_page: Następna
records_per_page: Na stronę
column_switch_true: Tak
column_switch_false: Nie
column_created: Utworzono
column_updated: Zaktualizowano
pagination_range: ":from–:to z :total"
results:
one: ":count wynik"
few: ":count wyniki"
many: ":count wyników"
other: ":count wyniku"
empty_value: "—"
clear_search: Wyczyść wyszukiwanie
select_all: Zaznacz wszystkie na stronie
select_row: Zaznacz wiersz
sort_ascending: Sortuj rosnąco
sort_descending: Sortuj malejąco
form:
save: Zapisz
save_and_close: Zapisz i zamknij
cancel: Anuluj
delete: Usuń
none: — brak —
update: Edytuj
create: Utwórz
return_to_list: Wróć do listy
close: Zamknij
confirm: Potwierdź
saving: Zapisywanie…
deleting: Usuwanie…
add: Dodaj
remove_item: "Usuń: :name"
add_item: Dodaj…
select_placeholder: Wybierz…
loading_options: Wczytywanie…
no_options: Brak wyników
unsupported_field: "Nieobsługiwany typ pola: :type"
error_title: Nie udało się zapisać.
error_fields:
one: "Popraw :count pole oznaczone poniżej."
few: "Popraw :count pola oznaczone poniżej."
many: "Popraw :count pól oznaczonych poniżej."
other: "Popraw :count pola oznaczonego poniżej."
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
tab_errors:
one: ":count błąd"
few: ":count błędy"
many: ":count błędów"
other: ":count błędu"
relation:
add: Dodaj
link: Dołącz
unlink: Odłącz
remove: Usuń
search: Szukaj…
add_count: "Dodaj (:count)"
candidates_range: ":from–:to z :total"
settings:
title: Ustawienia
empty: Brak dostępnych ustawień.
saved: Zapisano ustawienia
messages:
list:
record_count:
one: ":count rekord"
few: ":count rekordy"
many: ":count rekordów"
other: ":count rekordu"
create: Nowy rekord
empty_search: Nic nie znaleziono
empty_search_hint: Żaden rekord nie pasuje do frazy „:term”. Spróbuj innej frazy.
selected: "Zaznaczono :count"
delete_confirm: "Usunąć zaznaczone (:count)?"
deleted:
one: "Usunięto :count rekord"
few: "Usunięto :count rekordy"
many: "Usunięto :count rekordów"
other: "Usunięto :count rekordu"
form:
create: Nowy rekord
update: Edycja rekordu
saved: Zapisano
delete_confirm: Usunąć ten rekord?
deleted: Usunięto rekord
relation:
link: Dodaj
link_hint: Wybierz rekordy, które chcesz dołączyć.
candidate_search: Szukaj…
linked:
one: "Dołączono :count rekord"
few: "Dołączono :count rekordy"
many: "Dołączono :count rekordów"
other: "Dołączono :count rekordu"
unlink_selected: Odłącz zaznaczone
unlink_confirm: "Odłączyć zaznaczone (:count)?"
unlinked:
one: "Odłączono :count rekord"
few: "Odłączono :count rekordy"
many: "Odłączono :count rekordów"
other: "Odłączono :count rekordu"
empty: Brak powiązanych rekordów.

View File

@@ -1,6 +1,22 @@
package phrasebook
import "embed"
import (
"embed"
"io/fs"
)
//go:embed lang
var systemLangFS embed.FS
//go:embed backend/lang
var backendLangFS embed.FS
// backendLang is the framework admin string tree (D-20), laid out as
// lang/<locale>/lang.yaml so its keys resolve as backend::lang.<group>.<key>.
func backendLang() fs.FS {
sub, err := fs.Sub(backendLangFS, "backend")
if err != nil {
panic(err)
}
return sub
}

View File

@@ -19,6 +19,8 @@ type Catalog struct {
messages map[string]map[string]entry
// namespace (plugin ID) -> owning plugin ID
namespaces map[string]string
// replace lets put overwrite an existing key; only Override sets it.
replace bool
}
type entry struct {
@@ -221,7 +223,7 @@ func (c *Catalog) put(pluginID, file, locale, key string, e entry) error {
byKey = make(map[string]entry)
c.messages[locale] = byKey
}
if _, exists := byKey[key]; exists {
if _, exists := byKey[key]; exists && !c.replace {
return catalogErrorf(pluginID, file, key, "duplicate key")
}
byKey[key] = e
@@ -240,6 +242,106 @@ func (c *Catalog) lookup(locale, key string) (entry, bool) {
return e, ok
}
// Override applies owner's override tree, laid out as
// lang/<locale>/<namespace>/<group>.yaml, over the loaded catalog. It may
// replace existing keys of a loaded namespace and add locales; a malformed
// path or an unknown namespace fails.
func (c *Catalog) Override(owner string, fsys fs.FS) error {
if c == nil {
return fmt.Errorf("phrasebook: catalog is nil")
}
if fsys == nil {
return nil
}
var files []string
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
files = append(files, p)
}
return nil
})
if err != nil {
return catalogErrorf(owner, "", "", "walk lang overrides: %s", err.Error())
}
sort.Strings(files)
c.replace = true
defer func() { c.replace = false }()
for _, file := range files {
locale, namespace, group, err := parseOverridePath(file)
if err != nil {
return catalogErrorf(owner, file, "", "%s", err.Error())
}
if _, loaded := c.namespaces[namespace]; !loaded {
return catalogErrorf(owner, file, "", "override names unknown namespace %s", namespace)
}
raw, err := fs.ReadFile(fsys, file)
if err != nil {
return catalogErrorf(owner, file, "", "read: %s", err.Error())
}
if err := c.loadFile(namespace, file, locale, group, raw); err != nil {
return fmt.Errorf("phrasebook: override by %s: %w", owner, err)
}
}
return nil
}
func parseOverridePath(p string) (locale, namespace, group string, err error) {
bad := fmt.Errorf("malformed override path (want lang/<locale>/<namespace>/<group>.yaml)")
if strings.Contains(p, "\\") || path.Clean(p) != p {
return "", "", "", bad
}
parts := strings.Split(p, "/")
if len(parts) != 4 || parts[0] != "lang" || !strings.HasSuffix(parts[3], ".yaml") {
return "", "", "", bad
}
locale, namespace, group = parts[1], parts[2], strings.TrimSuffix(parts[3], ".yaml")
if !validLocale(locale) || !validNamespace(namespace) || !validGroup(group) {
return "", "", "", bad
}
return locale, namespace, group, nil
}
func validNamespace(s string) bool {
if s == "" || s == "." || s == ".." || strings.HasPrefix(s, ".") || strings.HasSuffix(s, ".") {
return false
}
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' {
continue
}
return false
}
return true
}
// checkForms fails when a key under prefix cannot convert to CLDR forms, so
// strings served to the admin SPA never need Laravel exact or range pipes.
func (c *Catalog) checkForms(prefix string) error {
locales := make([]string, 0, len(c.messages))
for locale := range c.messages {
locales = append(locales, locale)
}
sort.Strings(locales)
for _, locale := range locales {
keys := make([]string, 0)
for key := range c.messages[locale] {
if strings.HasPrefix(key, prefix) {
keys = append(keys, key)
}
}
sort.Strings(keys)
for _, key := range keys {
if _, ok := entryForms(c.messages[locale][key]); !ok {
return fmt.Errorf("phrasebook: %s key %s cannot convert to CLDR plural forms (use a plural map, not an exact or range pipe)", locale, key)
}
}
}
return nil
}
func parseLangPath(p string) (locale, group string, err error) {
p = path.Clean("/" + strings.ReplaceAll(p, "\\", "/"))
p = strings.TrimPrefix(p, "/")

199
phrasebook/phase10_test.go Normal file
View File

@@ -0,0 +1,199 @@
package phrasebook
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/backpack"
)
func TestPhase10Forms(t *testing.T) {
cat := NewCatalog()
if err := cat.Load("acme.demo", fstest.MapFS{
"lang/en/lang.yaml": {Data: []byte(`plain: Hello :name
plural:
one: ":count apple"
other: ":count apples"
pipe: ":count apple|:count apples"
exact: "{0} No apples|[1,*] :count apples"
`)},
"lang/pl/lang.yaml": {Data: []byte(`pipe: ":count jabłko|:count jabłka|:count jabłek|:count jabłka"
`)},
}); err != nil {
t.Fatal(err)
}
tr := NewTranslator(cat, Options{Locale: "en", Fallback: "en"})
check := func(locale, key string, want map[string]string) {
t.Helper()
got, ok := tr.Forms(locale, key)
if !ok {
t.Fatalf("%s %s: not convertible", locale, key)
}
if len(got) != len(want) {
t.Fatalf("%s %s = %v want %v", locale, key, got, want)
}
for cat, text := range want {
if got[cat] != text {
t.Fatalf("%s %s = %v want %v", locale, key, got, want)
}
}
}
check("en", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"})
check("en", "acme.demo::lang.plural", map[string]string{"one": ":count apple", "other": ":count apples"})
check("en", "acme.demo::lang.pipe", map[string]string{"one": ":count apple", "other": ":count apples"})
check("pl", "acme.demo::lang.pipe", map[string]string{"one": ":count jabłko", "few": ":count jabłka", "many": ":count jabłek", "other": ":count jabłka"})
// pl falls back to en for a key it does not define.
check("pl", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"})
if forms, ok := tr.Forms("en", "acme.demo::lang.exact"); ok {
t.Fatalf("exact/range pipe converted to %v", forms)
}
if forms, ok := tr.Forms("en", "acme.demo::lang.missing"); ok {
t.Fatalf("missing key converted to %v", forms)
}
got, ok := tr.Forms("en", "acme.demo::lang.plural")
if !ok {
t.Fatal("plural not convertible")
}
got["one"] = "mutated"
if again, _ := tr.Forms("en", "acme.demo::lang.plural"); again["one"] != ":count apple" {
t.Fatal("Forms returned the catalog's own map")
}
}
type overridePlugin struct {
id string
lang fs.FS
overrides fs.FS
}
func (p overridePlugin) ID() string { return p.id }
func (p overridePlugin) LangFS() fs.FS { return p.lang }
func (p overridePlugin) LangOverridesFS() fs.FS { return p.overrides }
func activateWith(t *testing.T, plugins ...overridePlugin) (*Translator, error) {
t.Helper()
app := backpack.New(nil)
if err := Activate(app, plugins); err != nil {
return nil, err
}
tr, ok := app.Lookup[*Translator]()
if !ok || tr == nil {
t.Fatal("translator was not published")
}
return tr, nil
}
func TestPhase10LangOverride(t *testing.T) {
demo := fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("title: Demo\n")}}
tr, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: fstest.MapFS{
"lang/pl/backend/lang.yaml": {Data: []byte("form:\n save: Zachowaj\n extra: Dodatkowy\n")},
"lang/de/backend/lang.yaml": {Data: []byte("form:\n save: Speichern\n")},
"lang/en/acme.demo/lang.yaml": {Data: []byte("title: Demo app\n")},
}})
if err != nil {
t.Fatalf("activate: %v", err)
}
for _, tc := range []struct{ locale, key, want string }{
{"pl", "backend::lang.form.save", "Zachowaj"},
{"pl", "backend::lang.form.extra", "Dodatkowy"},
{"pl", "backend::lang.form.cancel", "Anuluj"},
{"de", "backend::lang.form.save", "Speichern"},
{"en", "backend::lang.form.save", "Save"},
{"en", "acme.demo::lang.title", "Demo app"},
} {
if got := tr.GetIn(tc.locale, tc.key, nil); got != tc.want {
t.Fatalf("%s %s = %q want %q", tc.locale, tc.key, got, tc.want)
}
}
if bundle := tr.Bundle("de", "backend::lang."); bundle["backend::lang.form.save"]["other"] != "Speichern" || bundle["backend::lang.form.cancel"]["other"] != "Cancel" {
t.Fatalf("de bundle did not merge the fallback: save=%v cancel=%v", bundle["backend::lang.form.save"], bundle["backend::lang.form.cancel"])
}
for _, tc := range []struct {
name string
overrides fstest.MapFS
want []string
}{
{"missing namespace segment", fstest.MapFS{"lang/pl/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
{"not yaml", fstest.MapFS{"lang/pl/backend/lang.txt": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
{"too deep", fstest.MapFS{"lang/pl/backend/sub/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
{"unknown namespace", fstest.MapFS{"lang/pl/acme.missing/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "acme.missing"}},
{"unconvertible backend key", fstest.MapFS{"lang/en/backend/lang.yaml": {Data: []byte("list:\n results: \"{0} No results|[1,*] :count results\"\n")}}, []string{"backend::lang.list.results", "CLDR"}},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: tc.overrides})
if err == nil {
t.Fatal("activation accepted a bad override")
}
for _, want := range tc.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("err=%v missing %q", err, want)
}
}
})
}
}
var spaKey = regexp.MustCompile(`backend::lang\.[A-Za-z0-9_.]*[A-Za-z0-9_]`)
// TestPhase10SPAKeysResolve walks the admin SPA sources and resolves every
// backend::lang key literal in pl and en, each without falling back to the
// other locale.
func TestPhase10SPAKeysResolve(t *testing.T) {
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("caller")
}
root := filepath.Join(filepath.Dir(file), "..", "admin", "src")
keys := map[string]bool{}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !(strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".vue")) {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
for _, key := range spaKey.FindAllString(string(raw), -1) {
keys[key] = true
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(keys) < 10 {
t.Fatalf("found only %d backend::lang keys under %s", len(keys), root)
}
sorted := make([]string, 0, len(keys))
for key := range keys {
sorted = append(sorted, key)
}
sort.Strings(sorted)
cat := NewCatalog()
if err := cat.Load("backend", backendLang()); err != nil {
t.Fatal(err)
}
for _, locale := range []string{"pl", "en"} {
tr := NewTranslator(cat, Options{Locale: locale, Fallback: locale})
for _, key := range sorted {
forms, ok := tr.Forms(locale, key)
if !ok || forms["other"] == "" {
t.Errorf("%s: %s does not resolve", locale, key)
}
}
}
}

View File

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