Files
summercms/modules/phrasebook/README.md
Jakub Zych 602e0412f3 docs: add a Role in the framework section to every module README
Each module README now says where the module sits in the dependency
graph, why it is its own module, and lists use cases for application
developers.
2026-10-11 00:58:17 +02:00

132 lines
7.2 KiB
Markdown

# 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 as `acme.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>.yaml` that 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 of `lagoon.Validate`), `lagoon::validation.*` (the WinterCMS/Laravel validator catalog used by `lagoon.ValidateRequest`, with the size messages split into `numeric`, `file`, `string` and `array`; a key the Polish file lacks, such as `after_or_equal`, falls back to English as in WinterCMS) and `backend::lang.*` (admin UI strings), shipped for `en` and `pl`.
- Lookups by request locale (`phrasebook.Translator.Get`, read from the context through `towel.Locale`) or by explicit locale (`phrasebook.Translator.GetIn`), walking the locale, its parent tags (`pt-BR` to `pt`) 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.Choice` and `phrasebook.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. `:count` is filled in automatically.
- Export for the admin SPA: `phrasebook.Translator.Forms` returns a key as CLDR plural forms, `phrasebook.Translator.Bundle` returns every key under a prefix, and `phrasebook.Translator.Resolved` reports which locale such a bundle mostly comes from. Activation fails if a `backend::` 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:
```yaml
# lang/en/posts.yaml
title: Posts
greeting: "Hello, :name"
count:
one: ":count post"
other: ":count posts"
```
```go
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](../compass/README.md) 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](../backpack/README.md), [pact](../pact/README.md), [towel](../towel/README.md).
- 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
```sh
go test ./modules/phrasebook/...
```
The tests use in-memory filesystems and need no external services.