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

261 lines
7.6 KiB
Markdown

# summer-phrasebook
Internationalization module for [SummerCMS](https://git.golem15.com/golem15/summercms). Provides translation loading, CLDR-aware pluralization, and parameter interpolation — the Scala 3 equivalent of Laravel's `Illuminate\Translation` + WinterCMS `Storm\Translation`.
## Quick start
```scala
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](https://github.com/lightbend/config/blob/main/HOCON.md) (`.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`:
```hocon
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` |
```scala
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):
```hocon
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).
```hocon
# 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 مقالة"
```
```scala
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:
```scala
translator.addAlias("blog", "golem15.blog")
translator.get("blog::lang.labels.pluginName")
// => "Blog"
```
## Runtime overrides
Override any translation at runtime (locale-specific):
```scala
translator.set("golem15.blog::lang.labels.pluginName", "My Custom Blog")
translator.get("golem15.blog::lang.labels.pluginName")
// => "My Custom Blog"
```
## API reference
```scala
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
```bash
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.