Translator client + AST scanner for PHP / Twig / XSLT sources. Connects an application to the self-hosted translator.
Zero regexes on sources. PHP via the nikic/php-parser AST, Twig via a custom
Twig\NodeVisitorInterface plugged into Twig\NodeTraverser, XSLT via
DOMDocument + DOMXPath (element variant — XPath inside a select= expression is still TODO).
| Tier | Classes | When it runs |
|---|---|---|
| Runtime | Translator, BundleLoader, LocaleResolver, LocaleMiddleware, LocaleContext, TwigI18nExtension, MissingKeyPolicy |
In Lambda / per request. Prefers build/locales/<locale>.cache.php (OPcache hot path) with a fallback to <locale>.json. |
| Scan | PhpScanner, TwigScanner, XsltScanner, ScannerPipeline, AST visitors |
Buildtime — CI or composer i18n:sync. |
| Build | TranslatorClient, BundleFetcher, KeySync, EtagStore |
Buildtime — HTTP communication with the translator. |
| Console | SyncCommand, FetchCommand, ScanCommand, StatusCommand |
Buildtime — CLI entry points. |
// config/dependencies.php
use Stromcom\I18n\Config\I18nConfig;
use Stromcom\I18n\Config\I18nServiceProvider;
use Stromcom\I18n\Runtime\MissingKeyPolicy;
return array_merge(I18nServiceProvider::definitions(), [
I18nConfig::class => static fn () => new I18nConfig(
projectId: 'auth-stromcom-cz',
token: (string) ($_ENV['I18N_TOKEN'] ?? ''),
baseUrl: 'https://translator.stromcom.cz',
sourceLocale: 'en',
targetLocales: ['cs', 'en', 'de', 'sk'],
fallbackLocale: 'en',
bundlesDir: dirname(__DIR__) . '/build/locales',
scanPaths: [dirname(__DIR__) . '/src', dirname(__DIR__) . '/templates'],
missingKeyPolicy: MissingKeyPolicy::LogAndFallback,
isDevelop: false,
),
// … your own DI definitions …
]);$app->add(\Stromcom\I18n\Runtime\LocaleMiddleware::class);
// LocaleMiddleware must run after the session and before the route handler.$twig->addExtension($container->get(\Stromcom\I18n\Runtime\TwigI18nExtension::class));foreach (\Stromcom\I18n\Config\I18nServiceProvider::consoleCommands() as $cmd) {
$app->addCommand($container->get($cmd));
}{# templates/login.twig #}
<button type="submit">{{ t('login.form.submit', 'Sign in') }}</button>
{# ICU plurals (requires ext-intl) #}
<p>{{ t('cart.itemCount', '{count, plural, one {# item} other {# items}}', { count: itemCount }) }}</p>
{# language switcher #}
<select>{% for loc in available_locales %}<option {% if loc == current_locale %}selected{% endif %}>{{ loc }}</option>{% endfor %}</select>// In a handler / domain service
$msg = $this->translator->trans('email.password_reset.subject', 'Reset your password');
// With ICU values
$msg = $this->translator->trans('admin.users.deleted', '{count, plural, one {# user deleted} other {# users deleted}}', ['count' => $n]);
// With a note (3rd positional / named arg `note:`) — the scanner extracts it as metadata
// for translators, the runtime ignores it:
$msg = $this->translator->trans('signup.title', 'Sign up', note: 'Page heading');The consumer calls a single method; the package runs the XSLT transformation plus
post-processing of <i18n:t/> elements. AVTs in attributes (count="{$total}") are
evaluated in the first pass, and attributes with concrete values then feed into
MessageFormatter:
$renderer = $container->get(\Stromcom\I18n\Runtime\XsltRenderer::class);
$html = $renderer->render(
xslPath: __DIR__ . '/templates/product.xsl',
data: $xmlSource, // string or DOMDocument
locale: 'cs', // optional, default = LocaleContext::get()
xsltParams: ['user' => 'Petr'], // <xsl:param> values
outputFormat: null, // null = auto from <xsl:output method>, otherwise 'html'|'xml'|'text'
);In the XSL template:
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:i18n="https://stromcom.cz/i18n"
exclude-result-prefixes="i18n">
<xsl:output method="xml" encoding="UTF-8"/>
<xsl:template match="/">
<h1><i18n:t key="page.title" default="Welcome" note="Homepage heading"/></h1>
<!-- ICU plural with a dynamic value from the data -->
<p><i18n:t key="cart.count"
default="{{count, plural, one {{# item}} other {{# items}}}}"
count="{data/@items}"/></p>
</xsl:template>
</xsl:stylesheet>code-analysis.yml Important — escaping ICU placeholders in XSL attributes:
XSLT 1.0 evaluates {...} in attributes of literal result elements as Attribute
Value Templates (AVTs) — XPath expressions. ICU placeholders such as {name} or
{count, plural, ...} therefore must be doubled to {{name}} / {{count, ...}}.
This is the XSLT 1.0 standard.
XsltScanner undoes that escaping, so the source_text synced to the platform is the
real ICU pattern ({count, plural, one {# item} other {# items}}) rather than the
doubled-brace spelling. Write the doubled form in the template and expect the collapsed
form everywhere else.
Do not use
<xsl:attribute>forkey/default. Its text content is not an AVT, so it avoids the doubling — but it is a child element, invisible to an attribute read. Such a template renders correctly while its key never reaches the platform. The scanner emits a warning naming the attribute instead of skipping in silence.<!-- renders fine, but is never synced --> <i18n:t key="greet"> <xsl:attribute name="default">Hello {name}</xsl:attribute> </i18n:t> <!-- write this instead --> <i18n:t key="greet" default="Hello {{name}}" name="{$user_name}"/>
Namespace matching is exact. Both the scanner and the renderer resolve <i18n:t/> by
namespace URI (https://stromcom.cz/i18n), never by prefix — any prefix bound to that URI
works. A mistyped declaration (http:// instead of https://) makes the scanner warn and
the renderer throw XsltRendererException, rather than leaking a raw <i18n:t/> tag into
the page.
Rules for <i18n:t/> attributes:
| Attribute | Meaning |
|---|---|
key |
Key identifier (required, must be a literal — no AVT expression) |
default |
Source text — ICU template (required, must be a literal) |
note |
Metadata for translators — ignored by the runtime |
| others | ICU MessageFormatter params ({paramName} in default); AVTs welcome here |
An element without key or default → removed from the output (warning in the scanner if it passes the scan).
A key or default holding an AVT expression (key="{$dynamic}") cannot be synced — the
scanner warns and skips it.
Note that omit-xml-declaration="yes" has no effect for XML output: pass 2 re-serialises
the post-processed DOM, so the declaration is always emitted. Use method="html" or strip
it yourself if a bare fragment is required.
composer i18n:scan # Debug dump of discovered keys (local only)
composer i18n:sync # Scan + POST to /keys/sync (idempotent UPSERT)
composer i18n:fetch # GET published bundles → build/locales/
composer i18n:fetch --draft # GET draft bundles (for local dev)
composer i18n:fetch --locale=cs # A single locale only
composer i18n:status # Coverage report (how many keys translated per locale)composer install # inside packages/stromcom-i18n/
composer test # PHPUnit — every src class has tests
composer stan # PHPStan level max + strict-rules → 0 errors
composer coverage # PHPUnit + text coverage report (needs pcov or xdebug)
composer mutate # Infection mutation testing (needs pcov or xdebug)
composer ca # stan + testTest layout mirrors src/, plus:
| Directory | Contents |
|---|---|
tests/Integration/ |
Cross-class contracts — notably the XSLT scanner ⇄ renderer round-trip |
tests/Support/ |
Doubles: TmpDir, CollectingLogger, HttpRecorder, RecordingScanner, InMemoryBundleLoader |
HTTP is exercised through symfony/http-client's MockHttpClient (no network in the
suite), console commands through CommandTester.
composer mutate accepts the usual Infection arguments, so a single class can be probed
in isolation:
composer mutate -- src/Scan/XsltScanner.php --show-mutations=maxMutation testing needs a coverage driver and ext-intl for the ICU tests:
sudo apt install php8.4-pcov php8.4-intl # match your PHP minor versionThen in consumers' composer.json:
"repositories": [
- { "type": "path", "url": "packages/stromcom-i18n", "options": { "symlink": true } }
+ { "type": "vcs", "url": "https://github.com/stromcom/php-i18n.git" }
],
"require": {
- "stromcom/php-i18n": "@dev",
+ "stromcom/php-i18n": "^0.1",
}- JavaScript / React — will be a separate npm package
@stromcom/i18n(different repo). AST parsing JS from PHP is hell, and the frontend needs its own runtime helper anyway. - The XPath function
i18n:t('key', 'default')inside aselect=attribute — requires an XPath parser and, on top of that, does not handle ICU plurals as elegantly as the element-only variant with attributes + AVTs. The two-pass renderer (XSLT + DOM post-processor) fully replaces that need. - Runtime fetch from the translator — bundles must be retrieved during the CI build (
composer i18n:fetch) and packed into the deploy artifact. The runtime only reads from disk.
i18n:fetch writes two files per locale:
| File | Purpose | Who reads it |
|---|---|---|
build/locales/<locale>.json |
Source of truth, raw response from the translator (wrapped {version, locale, translations: {...}}) |
BundleLoader as a fallback, debugging |
build/locales/<locale>.cache.php |
<?php return [flat-map] via var_export |
OPcache hot path — require caches bytecode in shared memory, no json_decode |
BundleLoader tries .cache.php first only if its mtime is ≥ the JSON. After i18n:fetch
the mtimes are synced (via touch()). If someone manually edits the JSON (mtime > PHP cache),
the loader detects it and falls back to JSON — no stale cache.
For Lambda (singleton BundleLoader + in-memory cache) the difference is 0 — the bundle is
parsed once per cold start. For PHP-FPM hosting OPcache is a win: bytecode is shared between
workers, 0 parsing after the first request any worker made.