Files
summercms/docs/services/localization.md
Jakub Zych f9b7f2ea33 feat(12-01): add Laravel request validation to lagoon
- 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
2026-10-02 11:25:36 +02:00

4.7 KiB

title, description, section, order
title description section order
Localization Ship plugin translations in lang YAML catalogs, translate with :name placeholders and CLDR plurals through phrasebook, and read the request locale. services 80

Localization

WinterCMS plugins keep their strings in lang/<locale>/*.php and read them with Lang::get('acme.blog::lang.posts.title') and trans_choice. SummerCMS keeps the key form and Laravel's message syntax; phrasebook loads the catalogs and translates.

Catalogs

A plugin ships lang/<locale>/<group>.yaml files and implements pact.HasLang to return them. Nested maps flatten into dotted keys under the plugin ID, so title in lang/en/posts.yaml of acme.blog is the key acme.blog::posts.title. At boot, phrasebook.Activate loads the framework strings and every plugin's catalogs and publishes one phrasebook.Translator on the application. Duplicate keys, malformed paths and values that are not strings fail the start-up.

A plugin that implements pact.HasLangOverrides can replace keys of any loaded namespace, the framework's admin strings included, with files laid out as lang/<locale>/<namespace>/<group>.yaml. Overrides may also add a locale.

Translating

phrasebook.Translator.Get translates a key in the request locale, and phrasebook.Translator.GetIn in a locale you name. A lookup tries the locale, then its parent (pt-BR, then pt), then app.fallback_locale. A key that no locale has comes back unchanged, and outside production it is logged once. Placeholders follow Laravel: :name inserts the value, :Name capitalizes its first letter and :NAME upper-cases it:

cat := phrasebook.NewCatalog()
if err := cat.Load("acme.blog", langFS); err != nil {
	fmt.Println(err)
	return
}
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})

// surf stores the request locale on the context; here the example does.
ctx := towel.WithLocale(context.Background(), "pl")
fmt.Println(tr.Get(ctx, "acme.blog::posts.title", nil))
// pl has no greeting, so the fallback locale answers.
fmt.Println(tr.Get(ctx, "acme.blog::posts.greeting", map[string]string{"name": "Ada"}))
fmt.Println(tr.GetIn("en", "acme.blog::posts.shout", map[string]string{"name": "Ada"}))
// A missing key comes back as the key.
fmt.Println(tr.Get(ctx, "acme.blog::posts.missing", nil))
// Output:
// Posty
// Hello, Ada
// Welcome, ADA
// acme.blog::posts.missing

Application code gets the published translator with app.Lookup[*phrasebook.Translator]().

Plurals

phrasebook.Translator.Choice and phrasebook.Translator.ChoiceIn pick a plural form and fill in :count. A key can hold a map of CLDR plural categories (one, few, many, other, ...), checked against the categories the locale actually has, so a Polish string gets the forms Polish needs. Laravel's pipe syntax works too, with exact ({0}) and range ([2,*]) conditions:

cat := phrasebook.NewCatalog()
if err := cat.Load("acme.blog", langFS); err != nil {
	fmt.Println(err)
	return
}
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})

for _, n := range []int{1, 3, 5, 22} {
	fmt.Println(tr.ChoiceIn("pl", "acme.blog::posts.count", n, nil))
}
fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.count", 5, nil))
for _, n := range []int{0, 1, 7} {
	fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.drafts", n, nil))
}
// Output:
// 1 post
// 3 posty
// 5 postów
// 22 posty
// 5 posts
// No drafts
// One draft
// 7 drafts

The request locale

The locale lives on the request context, not in a global. For every route, surf sets it from the Accept-Language header; the locale.from-principal middleware switches it to the signed-in user's preferred locale. Code reads it with towel.Locale, and code outside a request sets it with towel.WithLocale, as the example above does. A context without a locale uses app.locale.

Strings for the admin

The admin SPA receives its strings from the server. phrasebook.Translator.Bundle returns every key under a prefix as CLDR plural forms, merged over the fallback chain, and phrasebook.Translator.Forms returns one key. Start-up fails if an admin (backend::) string cannot be expressed as CLDR forms, so a pipe string with a condition the SPA cannot evaluate is caught before any admin sees it.

The framework ships its validation messages (lagoon::validate for lagoon.Validate, and the full WinterCMS validator catalog lagoon::validation for lagoon.ValidateRequest) and admin strings (backend::lang) in English and Polish.