Files
summer-phrasebook/README.md
Jakub Zych 8684d2fb7b Implement Phase 1 core translation engine (95 tests passing)
Custom i18n module with HOCON translation files, CLDR-aware pluralization
for 40+ locales, namespace::group.item key resolution, :name/:Name/:NAME
parameter interpolation, and locale fallback chains. No effect system deps.
2026-02-22 23:30:16 +01:00

7.6 KiB

summer-phrasebook

Internationalization module for SummerCMS. Provides translation loading, CLDR-aware pluralization, and parameter interpolation — the Scala 3 equivalent of Laravel's Illuminate\Translation + WinterCMS Storm\Translation.

Quick start

import summer.phrasebook.*
import java.nio.file.Path

val translator = PhrasebookTranslator(
  initialLocale   = LocaleTag("en"),
  initialFallback = Some(LocaleTag("en")),
)

// Register a plugin's translation directory
translator.addNamespace("golem15.blog", Path.of("plugins/golem15/blog/lang"))

// Simple lookup
translator.get("golem15.blog::lang.labels.pluginName")
// => "Blog"

// With parameter replacement
translator.get("golem15.blog::lang.welcome", Map("name" -> "Alice"))
// => "Welcome, Alice!"

// Pluralization (auto-selects correct CLDR form)
translator.choice("golem15.blog::lang.posts_count", 5)
// => "5 posts"

// Switch locale
translator.setLocale(LocaleTag("pl"))
translator.choice("golem15.blog::lang.posts_count", 5)
// => "5 wpisów"

Translation file format

Translation files use HOCON (.conf), organized per-plugin, per-locale, per-group:

plugins/golem15/blog/lang/
  en/
    lang.conf
    validation.conf
  pl/
    lang.conf

Example lang/en/lang.conf:

labels {
  pluginName = "Blog"
  posts = "Posts"
}

post {
  create_title = "Create Post"
  edit_title = "Edit Post"
}

# Pluralization: pipe-separated forms (English: singular|plural)
posts_count = ":count post|:count posts"

# Explicit range syntax
items_range = "{0} No items|{1} One item|[2,*] Many items"

# Parameter interpolation
welcome = "Welcome, :name!"

Nested HOCON keys are flattened to dot-separated paths: labels.pluginName, post.create_title, etc.

Key format

Keys follow the WinterCMS/Laravel convention:

namespace::group.item
Part Description Example
namespace Plugin identifier (optional) golem15.blog
group Filename without .conf lang
item Dot-path into the flattened HOCON labels.pluginName

Examples:

golem15.blog::lang.labels.pluginName   -> plugins/golem15/blog/lang/{locale}/lang.conf -> labels.pluginName
golem15.blog::validation.required      -> plugins/golem15/blog/lang/{locale}/validation.conf -> required
system::validation.required            -> modules/system/lang/{locale}/validation.conf -> required

Keys without a namespace that start with validation. auto-resolve to system::validation.* for WinterCMS compatibility.

Parameter interpolation

Use :name placeholders with automatic case variants:

Placeholder Replacement Result for "alice"
:name as-is alice
:Name ucfirst Alice
:NAME uppercase ALICE
translator.get("golem15.blog::lang.greeting", Map("name" -> "alice", "count" -> "3"))
// Given: "Hello :Name, you have :count new messages"
// => "Hello Alice, you have 3 new messages"

Pluralization

Two mechanisms, evaluated in order:

1. Explicit conditions

Exact values with {N} and ranges with [from,to] (* = unbounded):

items = "{0} No items|{1} One item|[2,5] A few items|[6,*] Many items"

2. CLDR plural rules

When no explicit condition matches, the engine selects a form by CLDR index based on the locale. Forms are pipe-separated, ordered by CLDR category:

Locale group Forms Categories
Chinese, Japanese, Korean, Turkish, ... 1 other
English, German, Spanish, Italian, ... 2 one | other
French, Hindi, ... 2 one (0 and 1) | other
Polish, Russian, Ukrainian, Czech, ... 3 one | few | many
Slovenian, Maltese, Welsh 4 varies
Arabic 6 zero | one | two | few | many | other

Covers 40+ locale groups. Ported from Laravel's MessageSelector (Zend Framework, BSD license).

# English: singular|plural
posts_count = ":count post|:count posts"

# Polish: one|few|many
posts_count = ":count wpis|:count wpisy|:count wpisów"

# Arabic: zero|one|two|few|many|other
posts_count = "لا مقالات|مقالة واحدة|مقالتان|:count مقالات|:count مقالة|:count مقالة"
translator.choice("golem15.blog::lang.posts_count", 1, locale = Some(LocaleTag("pl")))
// => "1 wpis"

translator.choice("golem15.blog::lang.posts_count", 22, locale = Some(LocaleTag("pl")))
// => "22 wpisy"

Locale fallback

When a key is not found in the requested locale, the engine walks a fallback chain:

requested (de-at) -> parent (de) -> configured fallback (en) -> core locale (en)

First match wins. If no translation is found anywhere, the raw key string is returned.

Namespace aliases

Shorthand aliases for long plugin namespaces:

translator.addAlias("blog", "golem15.blog")

translator.get("blog::lang.labels.pluginName")
// => "Blog"

Runtime overrides

Override any translation at runtime (locale-specific):

translator.set("golem15.blog::lang.labels.pluginName", "My Custom Blog")

translator.get("golem15.blog::lang.labels.pluginName")
// => "My Custom Blog"

API reference

trait Translator:
  def get(key: String, replace: Map[String, String] = Map.empty,
          locale: Option[LocaleTag] = None): String

  def choice(key: String, number: Int, replace: Map[String, String] = Map.empty,
             locale: Option[LocaleTag] = None): String

  def has(key: String, locale: Option[LocaleTag] = None): Boolean

  def allForLocale(locale: LocaleTag,
                   namespace: Option[String] = None): Map[String, String]

  def setLocale(locale: LocaleTag): Unit
  def getLocale: LocaleTag
  def getFallback: Option[LocaleTag]
  def setFallback(locale: LocaleTag): Unit

  def addNamespace(namespace: String, path: java.nio.file.Path): Unit
  def addAlias(alias: String, namespace: String): Unit
  def set(key: String, value: String, locale: Option[LocaleTag] = None): Unit
  def clearCache(): Unit

Module structure

src/main/scala/summer/phrasebook/
  LocaleTag.scala              Opaque type for locale identifiers
  TranslationKey.scala         ParsedKey case class (namespace/group/item)
  KeyParser.scala              namespace::group.item parser with cache
  PluralRules.scala            CLDR plural index rules (40+ locales)
  Interpolator.scala           :name / :Name / :NAME replacement
  HoconLoader.scala            HOCON -> flat Map[String, String]
  FallbackChain.scala          Locale fallback chain builder
  Translator.scala             Public API trait
  PhrasebookTranslator.scala   Full implementation

Dependencies

Dependency Version Purpose
com.typesafe:config 1.4.3 HOCON parsing
org.scalameta:munit 1.0.3 Testing (test scope only)

No effect systems. No Cats, no ZIO. Pure direct-style Scala 3 on JDK 21.

Building

sbt compile    # compile
sbt test       # run all 95 tests

Requires JDK 21+ and sbt 1.10+.

Roadmap

  • Phase 2 — Integration: Pebble template extension (|_ filter, trans()/transChoice() functions), Tapir endpoint for JSON translation export to Vue admin SPA
  • Phase 3 — Tooling: summer phrasebook:scan (extract translatable strings), summer phrasebook:export/import (flat files for translators), dev-mode hot-reload
  • Phase 4 — Database-backed translation overrides, compile-time key validation macros

License

Part of SummerCMS. Plural rules derived from the Zend Framework (BSD license) via Laravel.