Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

stromcom/php-i18n

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).

Architecture

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.

Use in a consumer

DI registration

// 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 …
]);

Slim middleware pipeline

$app->add(\Stromcom\I18n\Runtime\LocaleMiddleware::class);
// LocaleMiddleware must run after the session and before the route handler.

Twig environment

$twig->addExtension($container->get(\Stromcom\I18n\Runtime\TwigI18nExtension::class));

Symfony Console

foreach (\Stromcom\I18n\Config\I18nServiceProvider::consoleCommands() as $cmd) {
    $app->addCommand($container->get($cmd));
}

Use in the application

{# 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');

XSLT — two-pass renderer

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> for key / 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.

CLI

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)

Quality

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 + test

Test 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=max

Mutation 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 version

Then 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",
}

What the package does not do

  • 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 a select= 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.

On-disk bundle format

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.

About

AST-based i18n toolkit for PHP, Twig and XSLT — key scanner, translator.stromcom.cz client, runtime t() helper. Zero regex.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages