test-messages_zh_CN.properties
41 Bytes
test.simple.greeting = 你好,世界
The eighth cross-cutting platform service is live: plug-ins and PBCs
now have a real Translator and LocaleProvider instead of the
UnsupportedOperationException stubs that have shipped since v0.5.
What landed
-----------
* New Gradle subproject `platform/platform-i18n` (13 modules total).
* `IcuTranslator` — backed by ICU4J's MessageFormat (named placeholders,
plurals, gender, locale-aware number/date/currency formatting), the
format every modern translation tool speaks natively. JDK ResourceBundle
handles per-locale fallback (zh_CN → zh → root).
* The translator takes a list of `BundleLocation(classLoader, baseName)`
pairs and tries them in order. For the **core** translator the chain
is just `[(host, "messages")]`; for a **per-plug-in** translator
constructed by VibeErpPluginManager it's
`[(pluginClassLoader, "META-INF/vibe-erp/i18n/messages"), (host, "messages")]`
so plug-in keys override host keys, but plug-ins still inherit
shared keys like `errors.not_found`.
* Critical detail: the plug-in baseName uses a path the host does NOT
publish, because PF4J's `PluginClassLoader` is parent-first — a
`getResource("messages.properties")` against the plug-in classloader
would find the HOST bundle through the parent chain, defeating the
per-plug-in override entirely. Naming the plug-in resource somewhere
the host doesn't claim sidesteps the trap.
* The translator disables `ResourceBundle.Control`'s automatic JVM-default
locale fallback. The default control walks `requested → root → JVM
default → root` which would silently serve German strings to a
Japanese-locale request just because the German bundle exists. The
fallback chain stops at root within a bundle, then moves to the next
bundle location, then returns the key string itself.
* `RequestLocaleProvider` reads the active HTTP request's
Accept-Language via `RequestContextHolder` + the servlet container's
`getLocale()`. Outside an HTTP request (background jobs, workflow
tasks, MCP agents) it falls back to the configured default locale
(`vibeerp.i18n.defaultLocale`, default `en`). Importantly, when an
HTTP request HAS no Accept-Language header it ALSO falls back to the
configured default — never to the JVM's locale.
* `I18nConfiguration` exposes `coreTranslator` and `coreLocaleProvider`
beans. Per-plug-in translators are NOT beans — they're constructed
imperatively per plug-in start in VibeErpPluginManager because each
needs its own classloader at the front of the resolution chain.
* `DefaultPluginContext` now wires `translator` and `localeProvider`
for real instead of throwing `UnsupportedOperationException`.
Bundles
-------
* Core: `platform-i18n/src/main/resources/messages.properties` (English),
`messages_zh_CN.properties` (Simplified Chinese), `messages_de.properties`
(German). Six common keys (errors, ok/cancel/save/delete) and an ICU
plural example for `counts.items`. Java 9+ JEP 226 reads .properties
files as UTF-8 by default, so Chinese characters are written directly
rather than as `\\uXXXX` escapes.
* Reference plug-in: moved from the broken `i18n/messages_en-US.properties`
/ `messages_zh-CN.properties` (wrong path, hyphen-locale filenames
ResourceBundle ignores) to the canonical
`META-INF/vibe-erp/i18n/messages.properties` /
`messages_zh_CN.properties` paths with underscore locale tags.
Added a new `printingshop.plate.created` key with an ICU plural for
`ink_count` to demonstrate non-trivial argument substitution.
End-to-end smoke test
---------------------
Reset Postgres, booted the app, hit POST /api/v1/plugins/printing-shop/plates
with three different Accept-Language headers:
* (no header) → "Plate 'PLATE-001' created with no inks." (en-US, plug-in base bundle)
* `Accept-Language: zh-CN` → "已创建印版 'PLATE-002' (无油墨)。" (zh-CN, plug-in zh_CN bundle)
* `Accept-Language: de` → "Plate 'PLATE-003' created with no inks." (de, but the plug-in
ships no German bundle so it falls back to the plug-in base
bundle — correct, the key is plug-in-specific)
Regression: identity, catalog, partners, and `GET /plates` all still
HTTP 200 after the i18n wiring change.
Build
-----
* `./gradlew build`: 13 subprojects, 118 unit tests (was 107 / 12),
all green. The 11 new tests cover ICU plural rendering, named-arg
substitution, locale fallback (zh_CN → root, ja → root via NO_FALLBACK),
cross-classloader override (a real JAR built in /tmp at test time),
and RequestLocaleProvider's three resolution paths
(no request → default; Accept-Language present → request locale;
request without Accept-Language → default, NOT JVM locale).
* The architectural rule still enforced: platform-plugins now imports
platform-i18n, which is a platform-* dependency (allowed), not a
pbc-* dependency (forbidden).
What was deferred
-----------------
* User-preferred locale from the authenticated user's profile row is
NOT in the resolution chain yet — the `LocaleProvider` interface
leaves room for it but the implementation only consults
Accept-Language and the configured default. Adding it slots in
between request and default without changing the api.v1 surface.
* The metadata translation overrides table (`metadata__translation`)
is also deferred — the `Translator` JavaDoc mentions it as the
first lookup source, but right now keys come from .properties files
only. Once Tier 1 customisation lands (P3.x), key users will be able
to override any string from the SPA without touching code.