Files
summercms/modules/phrasebook
Jakub Zych 1e841b4281 feat(18-01): write-only settings secrets, richeditor and tabs on settings forms (task 3)
- type: password on a settings form binds a lagoon.Encrypted column as a
  write-only secret (any other column fails boot naming the field): the
  GET omits it from data and reports stored, env or empty per field in
  meta.secrets; on PUT an absent key or "" keeps the value, null clears
  it, a non-string is a 422; the answer never carries a value
- cabana.SecretFallbacks (field to config key) gives the env state;
  cabana.SettingsMeta, SecretStored/SecretEnv/SecretEmpty exported
- settings forms accept richeditor: the Put sanitizes through seaglass
  like a controller save and returns meta.notices; repeater and
  dependsOn stay refused (settingsFormOnly next to controllerFormOnly)
- backend::lang secret.* phrases in en and pl
- admin SPA: PasswordField secret status line (stored with Remove stored
  value, env, empty, pending clear with Undo), SettingsFormView tabs with
  error-tab activation and the secrets/notices meta, settings payload
  that never sends an untouched secret; regenerated OpenAPI and TS
  types; rebuilt dist
- vault fixture, TestSettingsSecretCompile, Docker TestSettingsSecretSmoke
  and TestSettingsRichEditorSmoke, conform settings fixture with a
  password field; settings.md sections, forms.md notes, cabana README
2026-10-13 11:36:12 +02:00
..

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:

# 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.