- lagoon.ValidateRequest ports Laravel 9 request validation: wildcard expansion, implicit-rule stop, bail, sometimes/nullable/blank skipping, size messages split by type and character-counted string lengths - ParseRules, In, CustomRule, UploadedFile and ErrorKeys for rule tables - pl/en lagoon::validation catalogs ported verbatim from WinterCMS - lagoon.Validate answers a numeric range failure with the bound that failed (min, max or numeric between) instead of always max
120 lines
6.6 KiB
Markdown
120 lines
6.6 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"`
|
|
|
|
## 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.
|
|
|
|
## 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.
|