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, XsltRenderer, I18nPostProcessor, NotificationBundleLoader, HtmlFragmentSanitizer |
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, NotificationSync, NotificationBundleFetcher, 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,
// Optional — only for projects with notification templates (`<i18n:email>`).
notificationBundlesDir: dirname(__DIR__) . '/translations/notifications',
),
// … 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.
Texts of notifications live in the translator's notification module: one template per
e-mail, one field per text. Code is the source of truth — i18n:sync creates the templates
and fields, the translator never has to be set up by hand.
<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:template name="subject">
<i18n:email template="customerAccount.passwordRecovery" key="subject">Reset your password - stromcom.cz</i18n:email>
</xsl:template>
<xsl:template name="contentBody">
<i18n:email template="customerAccount.passwordRecovery" key="intro" format="html"
login="{login}" note="Text above the button">
<h2>Hello,</h2>
<p>we received a request to reset the password for <strong>{{login}}</strong>.</p>
</i18n:email>
</xsl:template>
<xsl:template name="buttonFallback">
<xsl:param name="url"/>
<i18n:email template="layout" key="buttonFallback" format="html" url="{$url}">
<p>If the button does not work, copy this link:<br/><a href="{{url}}">{{url}}</a></p>
</i18n:email>
</xsl:template>
</xsl:stylesheet>| Part | Required | Meaning |
|---|---|---|
| element name | — | Notification type = translator channel code: <i18n:email> → email, <i18n:sms> → sms. Every i18n element except <i18n:t> is a notification field; which channels exist is up to the translator (an unknown one fails the sync with 400). |
template |
yes | Template code ([a-z0-9][a-z0-9._-]*, case-insensitive). One e-mail = one template; shared layout texts = template layout. Literal. |
key |
yes | Field of the template (^[a-z][a-zA-Z0-9_.]{0,63}$). Literal. |
format |
no | text (default) or html. Literal. |
note |
no | Note for translators. Literal. |
| content | yes | Default text in the source locale. text = plain text, html = markup from the whitelist. Static — no xsl:* inside. |
| other attributes | — | Placeholder values (login="{login}"). AVTs welcome — they are evaluated in the first XSLT pass. |
Rules:
- Placeholders are
{{name}}— in text and in attributes of the content (<a href="{{url}}">). An AVT turns{{url}}into{url}in the result tree; the scanner and the runtime both turn it back, so you always write{{name}}. A real AVT expression inside the content (href="{$url}") is a scanner error. - Placeholder values are text. They are filled in after the HTML is parsed, so they are escaped
and can never inject markup. Every
{{name}}in the default needs an attribute and every attribute needs a{{name}}— both directions are scanner errors. - Branch in XSL, not in the text.
xsl:choosearound two elements with differentkeys. - Same (
template,key) in several places only with the same default, format and note. - HTML whitelist (shared with the translator,
HtmlFragmentSanitizer):p, br, strong, em, u, a[href], ul, ol, li, h2, h3;b→strong,i→em,div→p. No other attributes, nostyle.href= a whole{{name}}, or a literal starting withhttps:///mailto:. - Whitespace: runs collapse to one space, and — as everywhere in an XSLT stylesheet —
whitespace-only text nodes disappear.
<strong>a</strong> <em>b</em>renders as<strong>a</strong><em>b</em>; put the space inside an element or use . - A template that receives the translated text as a parameter must print it with
xsl:copy-of, notxsl:value-of— otherwise the element dissolves into text before it is translated.
Every broken rule is logged by the scanner as skipping <i18n:email> — <reason> (with path,
line, template, key in the context) and the field is not synced.
XsltRenderer runs it for you. An application with its own XSLTProcessor (e.g. with
registerPHPFunctions()) calls it on its own result tree:
$document = $processor->transformToDoc($xml);
$container->get(\Stromcom\I18n\Runtime\I18nPostProcessor::class)->process($document, 'cs');
$html = $document->saveHTML();It resolves <i18n:t> and <i18n:email> in one pass. A field's text comes from
notification bundle[locale] → [sourceLocale] → the element's content; a missing translation goes
through MissingKeyPolicy ([i18n] Missing notification translation). A lookalike in a foreign
namespace (mistyped xmlns:i18n) throws XsltRendererException.
The same commands handle keys and notifications. The notification part is skipped when
notificationBundlesDir is not configured.
composer i18n:scan # Debug dump of discovered keys + notification fields (local only)
composer i18n:sync # Scan + POST to /keys/sync and /notifications/sync (idempotent UPSERT)
composer i18n:fetch # GET published key + notification bundles
composer i18n:fetch --draft # GET draft bundles (for local dev)
composer i18n:fetch --locale=cs # A single locale only
composer i18n:status # Coverage report (keys and notification fields 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, StaticScanner, InMemoryBundleLoader, InMemoryNotificationBundleLoader |
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.2",
}XsltRenderer::__construct(I18nPostProcessor $postProcessor, LocaleContext $context)— the second pass moved toI18nPostProcessor. DI users need no change.SyncCommand,FetchCommandandStatusCommandtake the notification services as extra constructor arguments. DI users need no change.TranslatorInterface::trans()gained?string $note = null—trans(…, note: '…')now works at runtime too. Custom implementations must add the parameter.ScannerPipeline::scan()still returns keys only;scanAll()returns keys and notification fields.
- 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, flat {key: text} map with sorted keys |
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 |
Notification bundles go to notificationBundlesDir/<locale>.json (+ .cache.php) as the bare,
recursively sorted {template: {field: text}} map. Their ETags share .i18n-etags.json under
notifications:<locale>.
The response envelope ({version, locale, generated_at, translations}) is not stored: bundles
are committed build inputs, and generated_at would change the file on every fetch, so no publish
gate could compare a committed bundle against the published one. BundleLoader still accepts the
wrapped shape, so hand-written or legacy bundles keep working.
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.