- ReorderView: indented tree with drag handle, move up/down, outdent and indent (buttons and arrow keys on the handle), pointer drag before, after or inside a row and never into its own subtree; one POST with the whole node set 400 ms after the last move, flushed on leave; a failed save restores the saved tree, a 409 reloads; loading, empty, load-failure and 403 states; partition selector - ListView: outline Reorder button before New record, passing the filtered partition - SettingsIndexView opens a singleton controller's record through GET …/singleton; FormView in singleton mode has no Delete and returns to the settings index - reorder.* and messages.list.reorder phrase keys (en, pl), smoke spec, docs
phrasebook
Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization.
import "git.golem15.com/golem15/summercms/modules/phrasebook"
Name: What you pack to speak the local language on holiday; it holds the translations.
Overview
phrasebook owns every translatable string in a SummerCMS application. At boot, phrasebook.Activate loads the framework's own strings plus the lang/ tree of every plugin that implements pact.HasLang, applies overrides from plugins that implement pact.HasLangOverrides, and publishes a single phrasebook.Translator on the backpack.App. Keys use the WinterCMS form namespace::group.dot.path, and message syntax follows Laravel (:name placeholders, | plural pipes), so it is the counterpart of WinterCMS's Lang::get / trans_choice and plugin lang/ directories.
Role in the framework
The translation layer, on backpack, pact and towel. lagoon, party and cabana use it for model, plugin and admin messages. It is separate so translation works the same in the API, mail and admin, and reads the request locale from towel rather than a global.
Use cases:
- Ship a plugin's messages in YAML for each language next to the plugin.
- Pluralize correctly for languages with several plural forms.
- Fall back to a default locale for keys that are missing in one language.
Features
- YAML catalogs laid out as
lang/<locale>/<group>.yaml; nested maps flatten into keys such asacme.blog::posts.title(phrasebook.Catalog.Load). Duplicate keys, duplicate namespaces, malformed paths and non-string leaves fail at load time. - Overrides laid out as
lang/<locale>/<namespace>/<group>.yamlthat replace keys of any loaded namespace, including the framework admin strings, and may add locales (phrasebook.Catalog.Override). - Built-in framework namespaces:
lagoon::validate.*(the messages oflagoon.Validate),lagoon::validation.*(the WinterCMS/Laravel validator catalog used bylagoon.ValidateRequest, with the size messages split intonumeric,file,stringandarray; a key the Polish file lacks, such asafter_or_equal, falls back to English as in WinterCMS) andbackend::lang.*(admin UI strings), shipped forenandpl. - Lookups by request locale (
phrasebook.Translator.Get, read from the context throughtowel.Locale) or by explicit locale (phrasebook.Translator.GetIn), walking the locale, its parent tags (pt-BRtopt) and then the fallback locale. A missing key returns the key itself. - Laravel-style placeholders:
:name,:Name(first letter upper-cased) and:NAME(upper-cased). - Pluralization with
phrasebook.Translator.Choiceandphrasebook.Translator.ChoiceIn: CLDR plural maps (one,few,many,other, ...) validated against the locale's categories, or Laravel pipes with exact ({0}) and range ([2,*]) conditions.:countis filled in automatically. - Export for the admin SPA:
phrasebook.Translator.Formsreturns a key as CLDR plural forms,phrasebook.Translator.Bundlereturns every key under a prefix, andphrasebook.Translator.Resolvedreports which locale such a bundle mostly comes from. Activation fails if abackend::string cannot be expressed as CLDR forms. - Missing keys are logged once per key through
log/slog, except in the production environment.
Usage
Plugins normally only ship a lang/ tree and implement pact.HasLang; the runtime calls phrasebook.Activate and handlers look the translator up from the app. A catalog can also be built directly:
# lang/en/posts.yaml
title: Posts
greeting: "Hello, :name"
count:
one: ":count post"
other: ":count posts"
package blog
import (
"context"
"embed"
"fmt"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
//go:embed lang
var langFS embed.FS
func Example() error {
cat := phrasebook.NewCatalog()
if err := cat.Load("acme.blog", langFS); err != nil {
return err
}
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
ctx := towel.WithLocale(context.Background(), "en")
fmt.Println(tr.Get(ctx, "acme.blog::posts.greeting", map[string]string{"name": "Ada"})) // Hello, Ada
fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.count", 3, nil)) // 3 posts
return nil
}
// Inside a running application, use the translator published at boot.
func Title(ctx context.Context, app *backpack.App) string {
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok {
return "acme.blog::posts.title"
}
return tr.Get(ctx, "acme.blog::posts.title", nil)
}
API reference
| Identifier | Description |
|---|---|
phrasebook.Activate |
Loads framework and plugin catalogs in plugin order, applies overrides and publishes one phrasebook.Translator on the app. |
phrasebook.Catalog |
Set of namespaced translation entries, immutable once loading is done. |
phrasebook.NewCatalog |
Returns an empty catalog. |
phrasebook.Catalog.Load |
Loads a plugin's lang/<locale>/<group>.yaml files under the plugin ID namespace. |
phrasebook.Catalog.Override |
Applies an override tree over already loaded namespaces. |
phrasebook.Options |
Translator settings: app locale, fallback locale and production mode. |
phrasebook.Translator |
Resolves namespaced keys against a catalog. |
phrasebook.NewTranslator |
Builds a translator; empty locale and fallback default to en. |
phrasebook.Translator.Get |
Translates a key in the request locale, or the app locale when the context has none. |
phrasebook.Translator.GetIn |
Translates a key in an explicit locale. |
phrasebook.Translator.Choice |
Selects a plural form in the request locale. |
phrasebook.Translator.ChoiceIn |
Selects a plural form in an explicit locale. |
phrasebook.Translator.Has |
Reports whether any locale defines a key. |
phrasebook.Translator.Locale |
Returns the configured app locale. |
phrasebook.Translator.Forms |
Returns a key as CLDR plural forms for the admin SPA. |
phrasebook.Translator.Bundle |
Returns all keys under a prefix as CLDR forms, merged over the fallback chain. |
phrasebook.Translator.Resolved |
Returns the first locale in the fallback chain that has keys under a prefix. |
Configuration
phrasebook.Activate reads these keys from the app's compass config:
| Key | Default | Controls |
|---|---|---|
app.locale |
en |
The app locale, used when a request carries no locale. |
app.fallback_locale |
en |
The last locale tried before a key is reported missing. |
When the compass environment is production, missing-key warnings are not logged.
Dependencies
- SummerCMS modules: backpack, pact, towel.
- Third-party:
github.com/goccy/go-yaml,github.com/nicksnyder/go-i18n/v2(CLDR plural rules),golang.org/x/text/language. - Standard library:
context,embed,fmt,io/fs,log/slog,path,regexp,sort,strconv,strings,sync,unicode,unicode/utf8.
Testing
go test ./modules/phrasebook/...
The tests use in-memory filesystems and need no external services.