@echo-lingua/i18n-cli
v0.2.0
Published
Project-agnostic extractor and compiler for Echo Lingua translations: scan source (JS/TS + PHP/.phtml) for marked strings, hydrate gettext POs, and compile to JS, binary .mo, or Laravel JSON.
Maintainers
Readme
@echo-lingua/i18n-cli
The project-agnostic extractor and compiler for Echo Lingua translations. The echo-lingua-i18n
command (plus a programmatic API) scans your source for marked strings, hydrates gettext .po
catalogs, and, after the Echo Lingua API fills those in, compiles them into the output your project
consumes: a JS runtime catalog, a binary .mo for native gettext(), or flat JSON for Laravel.
It is dev-only and framework-neutral. It pairs with:
@echo-lingua/i18n: the optional JS/TS runtime (_()). Not needed for PHP/Laravel projects.@echo-lingua/i18n-core: the shared flag/plural/catalog primitives (installed transitively).
Translation is not part of this package: hydrated POs are filled by the Echo Lingua API, then compiled here.
npm i -D @echo-lingua/i18n-cliMarkers
JS/TS uses the _() convention from @echo-lingua/i18n:
_('Save changes');
_('Hello {name}', { name });
_('There is a file', 'There are {count} files', { count }); // plural
_('Order', {}, { context: 'verb' }); // msgctxt
_('Legal notice', {}, { flags: [EL.QUALITY_PRO], comment: '…' });PHP / .phtml uses the built-in gettext/Laravel functions, recognised by argument position per
kind:
<?php
_('Account settings'); // singular
gettext('Account settings'); // singular (alias)
__('Welcome back'); // singular (Laravel string-key)
ngettext('one invoice', '{count} invoices', $n); // plural → msgid + msgid_plural
pgettext('nav', 'Home'); // context → msgctxt + msgid
npgettext('mail', 'one message', '{count} messages', $n); // context + pluralDefaults: singular _, gettext, __, t, trans, translate; plural ngettext; context
pgettext; context + plural npgettext. .phtml (PHP-in-HTML) uses the same markers inside
<?php … ?> / <?= … ?>. Method calls ($obj->trans('…')) and non-literal/interpolated msgids are
skipped; a file that fails to parse warns rather than aborting the run. JS and PHP extract into the
same POs, so identical strings across .tsx and .php merge into one entry.
Only string literals extract. A non-literal argument (a variable, concatenation, or interpolated
template) is skipped with a warning; pass the dynamic part as a {placeholder}.
Config
This is the single config for the whole toolchain, and this CLI is the only thing that reads it. The
runtime (@echo-lingua/i18n) takes no config of its own, so installing both packages still means one
i18n.config.*. Everything here drives extraction and compilation; the only field with a runtime
counterpart is split (emit chunks here, load with initLazyI18n there).
i18n.config.{js,mjs,cjs,json} in your project root:
export default {
sourceLocale: 'en', // the source language (default 'en')
locales: ['fr', 'de', 'pl'], // targets; no need to list the source
include: ['src/**/*.{ts,tsx,php,phtml}'], // globs to scan for markers
exclude: ['**/node_modules/**'], // globs to skip (has sensible defaults)
poDir: 'translations/{locale}/messages.po',// PO path pattern; {locale} is substituted
catalogOut: 'src/locales/generated.ts', // where the compiled JS catalog is written
split: false, // true: per-locale chunks + lazy loading
functionNames: ['_'], // the JS names the scanner treats as markers
// Compile output. Default ['js']. Add 'mo' (PHP gettext) and/or 'laravel' (Laravel JSON).
compileTargets: ['js'],
moDir: 'translations/{locale}/LC_MESSAGES/messages.mo', // 'mo' target path; gettext layout
laravelJsonDir: 'lang/{locale}.json', // 'laravel' target path
};functionNames is the list of JS marker names (default ['_']); set it if you alias the marker.
PHP markers are a separate, built-in set (above). compileTargets selects which formats compile
writes; all targets render from the same translated POs, so you can emit several at once.
CLI
echo-lingua-i18n extract # scan source, hydrate each locale's PO (no translation)
echo-lingua-i18n compile # compile the translated POs into the configured target(s)
echo-lingua-i18n sync # extract, then compile
echo-lingua-i18n extract --cleanup # drop entries no longer in source (else kept as #~)A typical run is extract, then the Echo Lingua API translation over the POs, then compile.
extract never writes a msgstr. It preserves existing translations and Echo Lingua written flags
(el-processed, el-tier-*, fuzzy), and refreshes the #: references, the #. comment, and the
applied #, flags from source.
What a generated PO looks like
extract writes entries with a file:line reference, any #. comment and #, flags you set, and
(for a plural call) msgid_plural with one msgstr[n] per form. msgstr is left empty for the API:
#: src/Example.tsx:7
msgid "Hello {name}"
msgstr ""
#: src/Example.tsx:12
#. Footer link
#, el-quality-pro el-context-legal
msgid "Legal notice"
msgstr ""
#: src/Example.tsx:8
msgid "There is a file"
msgid_plural "There are {count} files"
msgstr[0] ""
msgstr[1] ""msgid plus msgctxt is the entry key (standard gettext), so identical strings share one entry and a
real msgctxt keeps two translations of the same text apart. The POs round-trip through the Echo
Lingua pipeline and any gettext tool (Poedit, Weblate, msgmerge).
Compile targets
All targets render from the same translated POs. Select them with compileTargets.
js (the default): the runtime catalog
Writes catalogOut for @echo-lingua/i18n to load, keyed by msgid with message / plurals
entries. In split: true mode it writes a loaders index plus one code-split chunk per locale. See the
@echo-lingua/i18n README for the shapes and initI18n / initLazyI18n usage.
mo: native PHP gettext
// i18n.config.mjs
export default {
sourceLocale: 'en',
locales: ['de', 'fr'],
include: ['src/**/*.{php,phtml}'],
compileTargets: ['mo'], // drop 'js' if you don't need the JS catalog
};sync hydrates the POs and, after translation, writes translations/{locale}/LC_MESSAGES/messages.mo
(the layout gettext expects). Load it natively; bindtextdomain's second argument is the directory
that contains the {locale}/LC_MESSAGES tree:
<?php
setlocale(LC_ALL, 'de_DE.UTF-8');
bindtextdomain('messages', __DIR__ . '/translations');
bind_textdomain_codeset('messages', 'UTF-8');
textdomain('messages');
echo _('Account settings'); // Kontoeinstellungen
echo ngettext('one invoice', '{count} invoices', 3);// {count} RechnungenThe .mo is a faithful re-encoding of the PO: msgctxt, plural forms, and the Plural-Forms header
all round-trip, so context and plural selection resolve correctly. The path is set by moDir.
laravel: flat string-key JSON
Laravel's string-key mode (__('Welcome back'), not the dotted __('messages.welcome') key mode)
is source-as-key, exactly like gettext, so it extracts the same way. Compile to Laravel's flat JSON:
// i18n.config.mjs
export default {
sourceLocale: 'en',
locales: ['de', 'fr'],
include: ['app/**/*.php', 'resources/views/**/*.php'],
compileTargets: ['laravel'], // writes lang/{locale}.json
};// lang/de.json
{
"Account settings": "Kontoeinstellungen",
"one invoice": "eine Rechnung|{count} Rechnungen"
}Then __('Account settings') and trans_choice('one invoice', $n) resolve natively. Caveats of the
flat-JSON format (not PO limitations):
- No
msgctxt. Flat JSON is keyed by the source string alone, so context-qualified entries can't be represented;compileskips them and prints a warning naming each. Use themotarget if you need context. - Plurals use Laravel's
singular|pluralpipe convention, fortrans_choice. - Placeholders pass through verbatim. The compiler does not rewrite
{name}into Laravel's:name, so write placeholders in the style your runtime expects.
Programmatic API
The same steps are available as functions (used, for example, by the inline editor's dev server):
import { loadConfig, runExtract, runCompile } from '@echo-lingua/i18n-cli';
const config = await loadConfig(projectRoot);
await runExtract(config, { cleanup: false });
await runCompile(config);Lower-level building blocks are exported too: extractMessages / extractFromSource, mergePo,
compileCatalog / renderCatalogModule, and the compile-target renderers renderMoFile,
renderLaravelJson, and flattenCatalog.
Adding a source language
Extraction is pluggable: each file extension maps to a parser (JS/TS via Babel, .php/.phtml via
php-parser), and every parser emits the same normalised message shape. Adding a new source type is a
registry entry, not a change to the extract/compile core.
