@veloxdevworks/i18n
v0.3.0
Published
Velox i18n — i18nliner-compatible static analyzer and JavaScript runtime
Maintainers
Readme
@veloxdevworks/i18n
Velox i18n — i18nliner-compatible static analyzer (velox-analyzer), platform sync CLI (velox-i18n sync), and JavaScript runtime (t(), React, extension mode).
Documentation: veloxdevworks.com/i18n
Install (npm)
npm install @veloxdevworks/i18n --save-dev
npx velox-analyzer --help
npx velox-i18n sync --helpThe package ships both the static analyzer CLI and the TypeScript runtime (dist/). Use import { t } from '@veloxdevworks/i18n' or the React bindings at @veloxdevworks/i18n/react.
Analyzer binaries: the main package is small (JS only). A single host-matched native binary is installed automatically via optionalDependencies (same pattern as esbuild/SWC/Biome) — about one ~15–25 MB platform package, not all seven. The velox-analyzer command is a small Node launcher that runs that binary (including Alpine/musl). velox-i18n is a Node CLI that posts an analyzer catalog to the Velox Platform key-sync API.
Platform packages: @veloxdevworks/i18n-darwin-arm64, …-darwin-x64, …-linux-x64-gnu, …-linux-arm64-gnu, …-linux-x64-musl, …-linux-arm64-musl, …-win32-x64
Do not install with --omit=optional if you need the analyzer; that skips the native binary.
i18nliner migration (keep your locale scripts):
{
"devDependencies": {
"@veloxdevworks/i18n": "^0.1.0"
},
"scripts": {
"check": "velox-analyzer --check -c velox.config.json app packages",
"export-text": "velox-analyzer -o ./default.json -c velox.config.json app packages && node ./scripts/validateTranslations/index.js ./default.json"
}
}Copy velox.config.json.example to your repo root. Locale gap files (needsTranslation.json), validateTranslations, and import-translations stay your scripts — see Scope: analyzer vs locale validation.
Publish (maintainers):
Local: cross-build every target, publish 7 platform packages then the main package. One-time setup:
brew install zig && cargo install cargo-zigbuild(every crate dep is pure Rust, so Zig is the only cross-linker needed). Then, from the repo root:npm login # @veloxdevworks scope, once pnpm --filter @veloxdevworks/i18n run release:local # = build:ts → build:platforms → publish-all (platform pkgs then main)publish-allpacks eachnative/<key>/binary into@veloxdevworks/i18n-<key>and publishes it at the same version as the main package; then publishes@veloxdevworks/i18nwith exact-versionoptionalDependencies. Re-runnable with--skip-existingif a platform package already exists on the registry. Windows ships thex86_64-pc-windows-gnubuild (runs on stock Windows; no MSVC runtime needed). Musl targets (linux-*-musl) are required for Alpine /node:*-alpineCI.To build binaries without publishing:
pnpm --filter @veloxdevworks/i18n run build:platforms(or a subset:bash scripts/build-analyzer-platforms.sh darwin-arm64 linux-x64-musl). Stage packages withpnpm --filter @veloxdevworks/i18n run pack:platforms.CI: push a tag
v*;.github/workflows/release-analyzer.ymlbuilds each target (musl via cargo-zigbuild) and publishes platform packages + main with provenance (needsNPM_TOKENsecret). Local publish useswindows-gnu; CI Windows job still buildsmsvcinto the samewin32-x64/key.
Minimum requirements: Node.js ≥ 18, React ≥ 16.8 (peer dependency, optional)
Table of Contents
Overview
@veloxdevworks/i18n is Velox's i18n package on npm:
- Static Analyzer —
velox-analyzerscans TS/JS, derives i18nliner-compatible keys, exports a catalog JSON. - Platform sync —
velox-i18n syncposts that catalog to Velox Platform (POST …/keys/sync) for CI drift checks and apply. - Runtime —
t(),translate,I18n.t, React hooks,applyExtension()for i18n-js apps.
See veloxdevworks.com/i18n for full documentation.
The design is heavily inspired by i18nliner: by default, the source string itself is the key (auto-generated from the string's content), so developers never have to manually maintain a key catalog. Custom resource keys are also fully supported when more control is needed.
Runtime — Shipped Features
Installation & Setup
npm install @veloxdevworks/i18nVanilla JS / Node.js
import { I18n, configure } from '@veloxdevworks/i18n';
// Optional: call before new I18n() / I18nProvider so new instances inherit it.
// Must stay in sync with velox.config.json (see Key config parity below).
configure({ inferredKeyFormat: 'underscored_crc32' }); // default
const translations = await fetch('/api/v1/translations/en').then(r => r.json());
const i18n = new I18n('en', translations);
console.log(i18n.t('Hello, world!'));React
import { configure } from '@veloxdevworks/i18n';
import { I18nProvider } from '@veloxdevworks/i18n/react';
configure({ inferredKeyFormat: 'underscored_crc32' }); // before first provider mount
const translations = await fetch('/api/v1/translations/en').then(r => r.json());
root.render(
<I18nProvider locale="en" translations={translations}>
<App />
</I18nProvider>
);
// Or pass config={{ ... }} on the provider / constructor 4th argument.How configure() applies
| Path | Receives module configure()? |
|------|--------------------------------|
| Module t() / default singleton | Yes (always) |
| new I18n(...) created after configure() | Yes (baseline; constructor arg wins) |
| <I18nProvider> without config prop, mounted after configure() | Yes |
| Instance i18n.configure(...) | That instance only |
| configure() after an instance was created | Does not retroactively update that instance |
Precedence for a new instance: defaults < module configure() < constructor / provider config prop.
Key Strategy: Auto-generated vs. Custom Keys
Auto-generated keys (default)
Inspired by i18nliner, you write the default English string inline. The key is automatically derived from the string by normalizing whitespace and computing a stable slug + CRC32 checksum:
t('Welcome to Velox!')
// key derived → "welcome_to_velox_<crc32>" (i18nliner-compatible)This means:
- No key management overhead for developers.
- The English source string is the fallback value when a translation is missing.
- Refactoring a string changes its key — the analyzer detects this and marks the old key as unused.
Key formats supported:
| inferredKeyFormat | Example output |
|---|---|
| underscored_crc32 (default) | "welcome_to_velox_d2a1f2c4" |
| underscored | "welcome_to_velox" |
| literal | "Welcome to Velox!" |
| hash | "d2a1f2c4" (CRC only — smaller catalogs; Velox extension) |
Custom resource keys (opt-in)
When you need stable keys that survive content changes (e.g., legal copy, marketing text), pass a key option:
t('Please read our terms of service.', { key: 'legal.terms_intro' })Or use the i18nliner positional-key signature:
t('legal.terms_intro', 'Please read our terms of service.')Basic Usage
import { useTranslation } from '@veloxdevworks/i18n/react';
function Greeting() {
const { t } = useTranslation();
return <p>{t('Hello, world!')}</p>;
}With a namespace scope:
const { t } = useTranslation('checkout');
t('Place your order') // looks up key in "checkout" namespace first, falls back to rootSupported call signatures
// 1. Inferred key from source string
t('Hello, world!')
// 2. Explicit dotted key + positional default (i18nliner style)
t('nav.home', 'Home')
// 3. Explicit key via options (Velox style)
t('Home', { key: 'nav.home' })
// 4. Plural forms object
t({ one: '{{count}} item', other: '{{count}} items' }, { count: n })
// 5. Auto-plural shorthand (English words only)
t('person', { count: n }) // → "1 person" / "N people"
// 6. Context suffix (friend_male → friend with { context: 'male' })
t('Friend', { key: 'friend', context: 'male' })
// 7. Key-array fallback — first catalog match wins
t(['nav.new_home', 'nav.home'])String Interpolation
Velox accepts three placeholder conventions. All work in the same string:
| Syntax | Convention |
|---|---|
| %{variable} | i18nliner |
| {{variable}} | Velox / mustache |
| {variable} | ICU MessageFormat |
t('Hello, {{name}}!', { values: { name: 'Alice' } })
// → "Hello, Alice!"
t('Hello, %{name}!', { name: 'Alice' })
// → "Hello, Alice!" (i18nliner-style inline values)
t('Welcome back, {name}!', { name: 'Alex' })
// → "Welcome back, Alex!" (ICU-style)
t('Dear %{name}, you have {{count}} messages.', { name: 'Bob', values: { count: 3 } })
// → "Dear Bob, you have 3 messages."Unmatched placeholders are left as literal text (e.g. {unknown} stays unchanged when unknown is not passed).
HTML-safe interpolation (i18nliner compatible)
Values are HTML-escaped when wrappers are used, when a %h{var} hint appears in the string, or when a HtmlSafeString value is present:
import { HtmlSafeString } from '@velox/i18n-js';
// Wrappers trigger HTML-escaping of all non-safe values
t('Click *here* to continue.', {
wrappers: ['<a href="/foo">$1</a>'],
})
// → "Click <a href="/foo">here</a> to continue."
// HtmlSafeString bypasses escaping; plain strings are escaped
t('%{unsafe} and %{safe}', {
unsafe: '<script>',
safe: new HtmlSafeString('<em>ok</em>'),
})
// → "<script> and <em>ok</em>"Pluralization (CLDR via Intl.PluralRules)
Shipped. Plural form selection uses the browser/Node built-in Intl.PluralRules for the active locale, covering all six CLDR categories: zero, one, two, few, many, other.
// English — one / other
t({ one: '{{count}} result', other: '{{count}} results' }, { count: 1 }) // "1 result"
t({ one: '{{count}} result', other: '{{count}} results' }, { count: 42 }) // "42 results"
// Russian — one / few / many
const i18n = new I18n('ru', {});
i18n.t({ one: '{{count}} рез.', few: '{{count}} рез. (few)', many: '{{count}} рез. (many)', other: '{{count}} рез.' }, { count: 5 })
// → "5 рез. (many)"
// Arabic — zero / one / two / few / many / other
const ar = new I18n('ar', {});
ar.t({ zero: 'صفر', one: 'واحد', two: 'اثنان', few: '{{count}} قلة', many: '{{count}} كثير', other: '{{count}} آخر' }, { count: 3 })
// → "3 قلة"A missing category falls back to other. The zero form, when provided, is always used for count === 0 regardless of locale rules (i18nliner compatibility).
Context suffixes
When context is set, Velox looks up key_context first (e.g. friend_male), then falls back to the base key:
t('Friend', { key: 'friend', context: 'male' })Plural suffix catalogs
Sibling keys with CLDR suffixes (item_one, item_other, …) can be grouped at import time:
import { groupPluralSuffixes } from '@veloxdevworks/i18n/catalog';
groupPluralSuffixes({ item_one: '1 item', item_other: '{{count}} items' })
// → { item: { one: '1 item', other: '{{count}} items' } }Auto-plural shorthand (English only)
t('person', { count: 1 }) // → "1 person"
t('person', { count: 3 }) // → "3 people"
t('child', { count: 5 }) // → "5 children"Only triggers for single-word strings matching /^[\w-]+$/.
ICU plural strings (react-intl compatible subset)
When count is provided, Velox can parse a single top-level ICU plural message in catalog values or inline defaults:
t('{count, plural, one {# item} other {# items}}', { count: 1 }) // → "1 item"
t('{count, plural, one {# item} other {# items}}', { count: 5 }) // → "5 items"Supports =N exact matches, offset:N, and all CLDR plural categories via Intl.PluralRules. Prefer an explicit key when storing ICU strings in catalogs (derived keys from full ICU text are opaque).
ICU select / gender strings
When the select variable is provided in options, Velox resolves {var, select, …} messages:
t('{gender, select, female {she} male {he} other {they}}', { gender: 'female' }) // → "she"Unknown values fall back to the other branch.
ICU selectordinal strings
When the ordinal variable is provided in options, Velox resolves {var, selectordinal, …} with Intl.PluralRules({ type: 'ordinal' }):
t(
'{place, selectordinal, one {#st place} two {#nd place} few {#rd place} other {#th place}}',
{ place: 21 },
) // → "21st place"Same branch grammar as ICU plural (=N, offset:N, CLDR categories). The named variable (e.g. place) is read from options; missing or non-numeric values leave the message unchanged. # is replaced with the locale-formatted number (minus any offset).
Date & Number Formatting
Shipped. Locale-aware wrappers around Intl.DateTimeFormat, Intl.NumberFormat, and Intl.RelativeTimeFormat, cached per locale + options:
const i18n = new I18n('fr');
i18n.formatDate(new Date('2024-06-15'), { dateStyle: 'long' });
i18n.formatNumber(1234.5, { style: 'currency', currency: 'EUR' });
i18n.formatRelativeTime(-1, 'day'); // → "yesterday" (en)
import { selectRelativeTimeUnit } from '@veloxdevworks/i18n';
const [value, unit] = selectRelativeTimeUnit(targetDate.getTime() - Date.now());
i18n.formatRelativeTime(value, unit);
const { formatDate, formatNumber, formatRelativeTime } = useTranslation();
formatDate(order.createdAt, { timeZone: 'UTC' });Namespaces & Dot-notation
Namespaces map to Velox project namespaces. They allow large applications to load translation bundles incrementally.
// Load only the "dashboard" namespace
const { t } = useTranslation('dashboard');
t('Overview') // looks up key in dashboard namespace first, falls back to root ''Dot-notation namespaces are supported with parent-chain fallback:
const { t } = useTranslation('settings.account');
t('Change password')Lookup order for namespace settings.account:
translations['settings.account'][key]— exact namespace matchtranslations['settings'][key]— parent namespacetranslations[''][key]— root namespace (always last)
Three-level namespaces work the same way: a.b.c → a.b → a → root.
Catalog interoperability
Shipped. Shape-based helpers in @veloxdevworks/i18n/catalog normalize foreign JSON catalogs into Velox TranslationsMap form. They are tool-agnostic — no per-library adapters.
import {
flattenNested,
groupPluralSuffixes,
toTranslationsMap,
unwrapDescriptors,
} from '@veloxdevworks/i18n/catalog';
// Descriptor-shaped catalogs: { id: { defaultMessage, ... } }
const flat = unwrapDescriptors(extractedCatalog);
// Nested JSON: { app: { welcome: "Hi" } }
const namespaces = flattenNested(nestedCatalog, { namespaceDepth: 1 });
// Plural suffix siblings: { item_one: "...", item_other: "..." }
const grouped = groupPluralSuffixes(suffixedCatalog);
// Load into the runtime
const translations = toTranslationsMap([
{ namespace: '', catalog: flat },
{ namespace: '', catalog: grouped },
...namespaces,
]);flattenNested warns (does not throw) when two nested paths collapse to the same key.
React Integration
The React integration is in @velox/i18n-js/react and exposes:
| Export | Description |
|---|---|
| <I18nProvider> | Context provider; accepts locale, translations, and optional onMissingKey |
| useTranslation(ns?) | Returns { t, formatDate, formatNumber, locale, ready, i18n } |
| <Trans> | Component translation with string or rich JSX children |
Props
<I18nProvider
locale="en" // required — BCP 47 locale tag
translations={{}} // optional — starts ready:false when omitted or empty
onMissingKey={callback} // optional — called for every missing key
>
<App />
</I18nProvider>useTranslation
const { t, formatDate, formatNumber, locale, ready, i18n } = useTranslation('checkout');
// t — translation function bound to the namespace
// formatDate — Intl.DateTimeFormat for the active locale
// formatNumber — Intl.NumberFormat for the active locale
// locale — current active locale
// ready — false until translations have been loaded
// i18n — stable I18n instance (same reference across re-renders)<Trans> component
Shipped: string children and rich JSX children (links, emphasis, nested elements).
<Trans i18nKey="legal.terms_intro" values={{ company: 'Velox' }}>
Welcome to Velox.
</Trans>
<Trans>
Please read our <a href="/terms">terms of service</a>.
</Trans>Rich children serialize to <0>…</0> templates (matching the static analyzer). Translators may reorder slot tags in translated strings; the original React elements are preserved by slot name.
Locale Switch & Hot-reload
Shipped. I18nProvider maintains a stable I18n instance. Changing the locale or translations prop updates the instance without remounting the tree. The ready flag transitions false → true when the first non-empty translations catalog is applied.
function App() {
const [locale, setLocale] = useState('en');
const translations = locale === 'es' ? esTranslations : enTranslations;
return (
<I18nProvider locale={locale} translations={translations}>
<button onClick={() => setLocale('es')}>Switch to Spanish</button>
<MyComponent />
</I18nProvider>
);
}Async / external hot-reload
Code holding a reference to the i18n instance can call setLocale / setTranslations from outside React. All I18nProvider subtrees will re-render automatically. ready becomes true when the active locale catalog is first non-empty — including after imperative setTranslations / replaceTranslations (not only when the translations prop changes).
// Async translation loading — triggers re-render when done; ready flips true
const { i18n, ready } = useTranslation();
async function loadLocale(locale: string) {
const data = await fetch(`/api/translations/${locale}`).then(r => r.json());
i18n.setLocale(locale);
i18n.setTranslations(locale, data);
}requireExplicitKeys Mode
When requireExplicitKeys: true, every t() call must provide an explicit key. Calling t('Some string') without a dotted key or { key: '...' } option throws a MissingExplicitKeyError with an actionable message.
import { configure, t, MissingExplicitKeyError } from '@veloxdevworks/i18n';
// Call before using module t() or creating new I18n / I18nProvider instances
configure({ requireExplicitKeys: true });
t('my.key', 'Default value') // ✓ explicit dotted key
t('Text', { key: 'my.key' }) // ✓ explicit key via option
t('Some text') // ✗ throws MissingExplicitKeyErrorPer-instance (overrides or instead of module configure):
const i18n = new I18n('en', {}, undefined, { requireExplicitKeys: true });Key config parity (analyzer ↔ runtime)
The analyzer reads velox.config.json; the runtime reads I18nConfig. These must match or derived keys diverge and lookups miss (app silently shows English).
| velox.config.json | Runtime configure() / constructor |
|---------------------|--------------------------------------|
| i18n.inferred_key_format | inferredKeyFormat (underscored_crc32 | underscored | literal | hash) |
| i18n.key_max_length | keyLength (default 50; unused when format is hash) |
| i18n.require_explicit_keys | requireExplicitKeys |
CRC digests (formats underscored_crc32 and hash) are full unpadded hex, matching i18nliner’s CRC algorithm. There is no separate hash-length knob (legacy key_hash_length is ignored).
hash format: key is CRC hex only ({"f3624ac4":"Hello, world!"}) — opt-in for smaller multi-MFE catalogs. Same content → same key; the analyzer errors if two different source strings collide on one derived key. Default remains underscored_crc32 for i18nliner drop-in. Keep analyzer and runtime format in sync.
Migrating key formats (seamless)
App source stays t('Hello, world!'). Build a reverse map from an analyzer catalog (default_value + dual derive), rewrite locale JSON keys only, and flip config:
# 1. Export catalog under the *current* format
npx velox-analyzer -o ./catalog.json -c velox.config.json app packages
# 2. Dry-run remap (no writes)
npx velox-i18n migrate-keys \
--from underscored_crc32 \
--to hash \
--catalog ./catalog.json \
--locales ./locales \
--report ./key-migration-report.json
# 3. Apply: rewrites catalog + locale keys; optional config flip
npx velox-i18n migrate-keys \
--from underscored_crc32 \
--to hash \
--catalog ./catalog.json \
--locales ./locales \
--write \
--update-config
# 4. Runtime must match (same release as new locale bundles)
# configure({ inferredKeyFormat: 'hash' })Fails closed on collisions and orphan locale keys (use --drop-orphans only if intentional). Deploy locale bundles and runtime inferredKeyFormat together.
Missing Key Behavior
When a key is not found in the loaded catalog:
- Auto-key mode — the original source string (the default English text) is returned as-is. The user always sees readable text.
- Explicit key mode — the key string itself is returned (e.g.,
"legal.terms_intro"). - An optional
onMissingKey(key, locale, namespace)callback on<I18nProvider>or theI18nconstructor is called so you can report missing keys to an error tracker.
const i18n = new I18n('en', {}, (key, locale, ns) => {
myErrorTracker.capture({ key, locale, namespace: ns });
});Runtime — Planned / Not Yet Shipped
The following features appear in marketing material or the ROADMAP but are not yet implemented in the current runtime:
| Feature | Status | Notes |
|---|---|---|
| JSX component interpolation in t() | Planned | t('Click <link>here</link>') with render-prop values — use <Trans> for JSX children today |
| Rich <Trans> children | Shipped | JSX → <0>…</0> templates; runtime reconstructs slot elements |
| ICU plural subset | Shipped | {count, plural, …} with =N, offset:N, CLDR categories |
| ICU select / gender | Shipped | {gender, select, …} |
| ICU selectordinal | Shipped | {place, selectordinal, …} via ordinal PluralRules |
| Date / number formatting | Shipped | i18n.formatDate / formatNumber and useTranslation() helpers |
| Relative-time formatting | Shipped | i18n.formatRelativeTime + selectRelativeTimeUnit helper |
| Locale-bundle splitting / lazy load | Planned | Load namespace bundles on demand — see ROADMAP |
| velox.config.json runtime loading | Node-only stretch | Config from disk is not loaded automatically in browser |
See ROADMAP.md for details on planned work.
Static Analyzer
Scope: analyzer vs locale validation
Many i18nliner adopters run a two-layer pipeline. Velox is aimed at replacing only the first layer (what i18nliner does today). Keep your existing locale scripts unless you explicitly migrate them.
| Layer | Typical tools | Velox responsibility |
|-------|---------------|----------------------|
| Source → English catalog | i18nliner export, i18nliner check | Yes — velox-analyzer scan, key derivation, default.json export, call-site validation |
| Catalog ↔ locale files | Custom validateTranslations, needsTranslation.json, Google/CSV helpers | No (for now) — missing translations per locale, pruning translations/*.json, import merge |
| Runtime | i18n-js + i18nliner extension, react-native-i18n, app loaders | Runtime package — unrelated to the Rust CLI |
Example monorepo flow (i18nliner-style):
I18n.t('Hello') in app/packages
│
▼
i18nliner export → default.json (en keys + English defaults)
│
▼
validateTranslations.js → sync/prune translations/{locale}.json
│ missing? → needsTranslation.json + exit 1
▼
[translators / import-translations / CSV]
│
▼
Runtime loads translations/{locale}.jsonDrop-in trial: swap only the export/check step, e.g. velox-analyzer export instead of i18nliner export, then leave node scripts/validateTranslations/... unchanged on the path to default.json.
Planned CLI (not all shipped yet): check (read-only, source + optional catalog diff), export (write default.json, optional --prune for stale keys in the English catalog). Locale completeness checks may become a separate validate-locales command or Velox API feature later; they are not part of i18nliner's published CLI.
Goals
The Rust-based static analyzer (packages/i18n-js/src/) serves as the bridge between application source code and translation catalogs (and eventually the Velox API). Its primary responsibilities are:
- Extract — scan TypeScript/JavaScript source files and find every resolved
t(...)/I18n.t(...)call-site (import-aware). - Derive keys — compute the canonical translation key from the source string (auto-key mode) or validate explicit keys.
- Detect drift — compare extracted keys against
default.json(unused/changed keys in the English catalog). - Sync — export a catalog, then use
velox-i18n syncto push keys to Velox Platform (see Integration with Velox API). - Lint — emit actionable errors/warnings when call sites are invalid (dynamic keys, plural issues, explicit-key collisions).
Architecture Overview
src/analyzer/
├── main.rs # CLI entry point (clap)
├── config.rs # Reads velox.config.json / CLI flags
├── scanner.rs # File-system walk, file filtering
├── parser/
│ ├── mod.rs # Parser trait + dispatch
│ ├── swc_parser.rs # Uses SWC (via swc_ecma_parser crate) to parse JS/TS → AST
│ └── visitor.rs # AST visitor that identifies i18n call-sites
├── extractor.rs # Call-site extraction (CallSite + namespace from hooks/Trans)
├── keygen.rs # Auto-key generation from source strings
├── catalog.rs # Loads/writes -o catalog JSON (KeyEntry + optional namespace)
├── api_client.rs # HTTP client for Velox REST/GraphQL API
├── differ.rs # Compares extracted keys vs. known catalog keys
├── reporter.rs # Formats output (human, JSON, GitHub annotations)
└── lib.rs # Public library surface for WASM / Node.js FFI future useModule Breakdown (src/analyzer)
scanner.rs
Recursively walks the project directory, respecting .gitignore and a configurable include/exclude list. Emits a stream of (path, source_text) pairs to the parser stage.
Supported file extensions: .ts, .tsx, .js, .jsx, .mts, .cts. Declaration files (*.d.ts, *.d.mts, *.d.cts) are always skipped.
scan.exclude uses gitignore-style globs (via globset), not Jest regexes. Matching directories are pruned during the walk. If exclude is omitted from config, defaults apply (**/__tests__/**, **/__generated__/**, **/*.test.*, **/*.spec.*). Set "exclude": [] to scan those paths.
| Jest testPathIgnorePatterns (regex) | Velox scan.exclude (glob) |
|---------------------------------------|-------------------------------|
| /__tests__/ | **/__tests__/** |
| /__generated__/ | **/__generated__/** |
| \.test\.tsx?$ | **/*.test.ts, **/*.test.tsx |
Use --profile on large monorepos to print phase timings and pass statistics (parsed vs prefilter_skip vs pass-2 reparsed) to stderr before tuning scan.include / exclude.
parser/swc_parser.rs + parser/visitor.rs
Uses the swc_ecma_parser crate to produce a full AST without spawning a Node.js process. The visitor walks the AST looking for:
| Pattern | Example |
|---|---|
| t("string") call expression | Import-bound t('Hello, world!') |
| t("string", opts) with options | t('{{count}} item', { count }) |
| t({ one: "...", other: "..." }, opts) | Plural shorthand |
| t(['a', 'b']) key-array fallback | One catalog entry per static string key |
| t(..., { context: 'male' }) | Static context → -o emits base key and key_context |
| const { t } = useTranslation() | Hook-derived bare t(...) (also { t: translate }) |
| const hook = useTranslation(); hook.t(...) | Hook object member form |
| useTranslation('ns') namespace | Applied to hook/import t() and <Trans> in the same function scope |
| <Trans i18nKey="..."> JSX element | <Trans i18nKey="legal.intro"> |
| <Trans> with JSX children (rich) | Serialized to <0>…</0> templates; component_slots populated |
| <Trans>default string</Trans> | Inline default without explicit key |
Each match produces a CallSite record:
pub struct CallSite {
pub file: PathBuf,
pub line: u32,
pub col: u32,
pub namespace: Option<String>,
pub raw_string: Option<String>, // source string / plural map
pub explicit_key: Option<String>, // from key: "..." option
pub interpolations: Vec<String>, // %{var} / {{var}} / {var} names (deduped)
pub component_slots: Vec<String>, // <Trans> slots, or t() wrappers indices/keys
pub count_param: bool, // whether `count` is passed
pub context: Option<String>, // static context: "..." option
}extractor.rs
Collects CallSite records from the AST. Namespace is taken from useTranslation('…') in the enclosing function (including when t is bound from the hook return value) and from <Trans namespace="…"> when set. When writing -o catalogs, each entry’s namespace is CallSite.namespace, or the analyzer --namespace default if the call site has none. A static context option also emits the suffixed key {base}_{context} (e.g. friend + friend_male) so translators see the variant the runtime looks up first.
keygen.rs
Derives a stable, human-readable key from a source string:
- Lowercase and trim.
- Replace non-alphanumeric runs with
_(ASCII slugify, same asvelox-analyzer; non-ASCII is not transliterated). - Truncate to
keyLengthcharacters (default: 50). - Append unpadded CRC32 hex suffix (in
underscored_crc32mode, the default). Inhashmode, the key is only that CRC (no slug).
"Welcome to Velox!" → "welcome_to_velox_<crc32>"This exactly matches i18nliner's keyifyUnderscoredCrc32 algorithm (including the charCodeAt(0) & 0xFF byte conversion for CRC32 compatibility).
catalog.rs
-o catalog JSON shape:
{
"keys": {
"nav.home": {
"default_value": "Home",
"plural_forms": null,
"interpolations": [],
"component_slots": [],
"namespace": "labels"
}
}
}namespace is optional for backward compatibility. When present, velox-i18n sync uses it per key; otherwise sync falls back to --namespace / config / common.
api_client.rs
Authenticates against the Velox API using a project API token (read from VELOX_API_TOKEN env var or velox.config.json). Provides:
GET /api/v1/projects/:id/keys— fetch registered keysPOST /api/v1/projects/:id/keys/batch— push new keysDELETE /api/v1/projects/:id/keys/:key— remove unused keys (with--pruneflag)
differ.rs
Compares extracted keys against the keys known to the catalog/API:
| Status | Description |
|---|---|
| New | Found in source, not in catalog → push to API |
| Unchanged | Matches catalog exactly |
| Changed | Key exists but default string differs → flag for re-translation |
| Unused | In catalog but not found in source → warn / prune |
| Duplicate | Same key derived from two different source strings → error |
reporter.rs
Emits results in one of three formats controlled by --format:
human(default) — colored terminal output with file:line referencesjson— machine-readable JSON array of findingsgithub— GitHub Actions annotation format (::error file=...::)
Key Extraction Rules
| Rule | Behavior |
|---|---|
| Dynamic key/string | t(variable) — emits a warning, cannot be statically analyzed |
| Concatenated string | t("Hello" + name) — emits an error; interpolation must use {{}} |
| Template literal | t(`Hello ${name}`) — emits an error; use {{name}} interpolation instead |
| Nested t() call | t(t('inner')) — emits an error |
| count without plural forms | t('item', { count }) without plural map — emits a warning |
| Explicit key collision | Two call-sites with the same explicit key but different defaults — emits an error |
CLI Interface
velox-analyzer [OPTIONS] [PATH]
Arguments:
[PATH] Root directory to scan (default: current directory)
Options:
-c, --config <FILE> Path to velox.config.json [default: ./velox.config.json]
--locale <LOCALE> Source locale to operate on [default: en]
--namespace <NS> Default namespace for -o catalog entries
(overridden by useTranslation / Trans namespace)
--format <FORMAT> Output format: human | json | github [default: human]
-o, --output <FILE> Write translation catalog JSON
-v, --verbose Print scan roots and file count
--profile Phase timings and pass stats (stderr)
--check Exit nonzero on validation errors
--strict Treat warnings as errors with --check
-h, --help Print help
Platform key sync is a separate command: `velox-i18n sync` (see Integration with Velox API).Example — extract one MFE with a namespace default:
velox-analyzer packages/labels -o labels.json --namespace labels
velox-i18n sync --catalog labels.json --project "$PROJECT_ID" --org "$ORG_ID"(Sync --namespace is optional when every catalog entry already carries namespace.)
Output Formats
human
✔ 312 keys extracted from 47 files
NEW (3)
├─ checkout.place_order src/pages/CheckoutPage.tsx:42
├─ checkout.order_summary src/pages/CheckoutPage.tsx:67
└─ settings.account.change_pw src/pages/AccountSettings.tsx:18
CHANGED (1)
└─ welcome_to_velox src/pages/HomePage.tsx:12
was: "Welcome to Velox"
now: "Welcome to Velox!"
UNUSED (2)
├─ old_hero_headline
└─ beta_banner_text
ERRORS (0)
WARNINGS (0)json
{
"summary": { "extracted": 312, "new": 3, "changed": 1, "unused": 2, "errors": 0, "warnings": 0 },
"findings": [
{
"status": "new",
"key": "checkout.place_order",
"namespace": "checkout",
"defaultValue": "Place your order",
"locations": [{ "file": "src/pages/CheckoutPage.tsx", "line": 42, "col": 10 }]
}
]
}Integration with Velox API
Key sync to Velox Platform is a two-step CI flow (analyzer extract, then Node sync CLI):
# 1. Extract keys from source (assignment default for entries without call-site ns)
velox-analyzer packages/labels -o ./catalog.json --namespace labels -c velox.config.json
# 2a. Drift check (exit 2 when the preview change list is non-empty)
VELOX_I18N_API_KEY=$API_KEY \
velox-i18n sync --catalog ./catalog.json --project "$PROJECT_ID" --org "$ORG_ID" --dry-run
# 2b. Apply
VELOX_I18N_API_KEY=$API_KEY \
velox-i18n sync --catalog ./catalog.json --project "$PROJECT_ID" --org "$ORG_ID"The sync CLI maps each analyzer catalog key’s default_value to platform sourceText. Namespace is taken from the catalog entry’s namespace when set; otherwise from sync --namespace, else velox.config.json namespaces[0], else common.
Optional platform defaults in velox.config.json:
{
"source_locale": "en",
"namespaces": ["common"],
"platform": {
"apiUrl": "https://i18n.velox.test",
"orgId": "<org-uuid>",
"projectId": "<project-uuid>",
"namespace": "common"
}
}Auth is VELOX_I18N_API_KEY (Better Auth API key or OAuth bearer with i18n:write). Never commit the key. Flag → config → env precedence applies for --api-url, --org, --project, and --namespace.
velox-analyzer --sync in older docs is not shipped; use velox-i18n sync instead.
Package Structure
packages/i18n-js/
├── src/
│ ├── runtime/
│ │ ├── index.ts # I18n class, configure(), t(), MissingExplicitKeyError
│ │ ├── react.tsx # I18nProvider, useTranslation, Trans
│ │ ├── keygen.ts # deriveKey(), EXPLICIT_KEY_PATTERN
│ │ └── pluralize.ts # English auto-plural helper (single-word shorthand)
│ └── (Rust source) # Rust static analyzer (see Static Analyzer section)
├── Cargo.toml # Rust analyzer binary
├── package.json # JS runtime package
├── tsup.config.ts # Dual CJS/ESM build
├── ROADMAP.md # Planned future features
└── tsconfig.json