@apollo-deploy/aurebesh
v0.1.4
Published
Universal i18n, pluralization, smart inline syntax, and real-time FX currency engine for Next.js 16 apps
Maintainers
Readme
Aurebesh
"Aurebesh" — The ancient alphabet of the Star Wars galaxy, used by Jedi and Sith to preserve knowledge across worlds and generations.
What it covers
| Domain | Capability |
|--------|-----------|
| Translation | Namespace-scoped JSON files, dot-notation key lookup, SSR hydration, lazy HTTP loading |
| Pluralization | CLDR plural rules (cardinal + ordinal), exact count, numeric intervals (2-5, 6+), gender/case inflection |
| Inline shorthands | {{count : singular \| plural}} for plurals, {{selector : key: text \| fallback}} for selects |
| Interpolation | {{token}} with HTML escaping, nested dot-path tokens, configurable delimiters |
| Formatting | Locale-aware dates (4 presets + custom), times, relative time, numbers, percentages, lists |
| Currency | ISO 4217 money formatting, zero-decimal detection, compact notation |
| FX Engine | Live exchange rates (exchangerate.host), static fallback, failover chain, Next.js "use cache" integration, tag-based invalidation |
| Locale detection | Accept-Language header parsing with quality weights, cookie-first with RSC/client parity |
| Observability | Missing-key reporter (in-memory + pluggable), translation coverage reports for CI/CD |
| React | useTranslation, useMoney, FormatService, locale persistence (localStorage + cookie), SSR hydration provider |
Architecture
@apollo-deploy/aurebesh
├── @apollo-deploy/aurebesh/config → Locale constants, I18nConfig, createI18nConfig()
├── @apollo-deploy/aurebesh/format → Pure Intl formatters (date, time, number, currency, relative, list)
├── @apollo-deploy/aurebesh/currency → Price types, resolution policies, FX snapshots, provider chain
├── @apollo-deploy/aurebesh/react → Client hooks & I18nProvider ← "use client"
├── @apollo-deploy/aurebesh/server → SSR loaders, locale detection, money(), fx-refresh handler ← server-only
└── @apollo-deploy/aurebesh/observability → Missing-key reporter interface + InMemoryMissingTranslationReporterServer/client boundary is strictly enforced. @apollo-deploy/aurebesh/server imports server-only — any accidental client import fails at build time. @apollo-deploy/aurebesh/react carries "use client" directives.
Installation
bun install @apollo-deploy/aurebeshQuick start
1. Create your app config
// lib/i18n.config.ts
import '@apollo-deploy/aurebesh/config';
export const i18nConfig = createI18nConfig(['settings', 'billing']);
// ^— additional namespaces beyond 'common' + 'auth'2. Lay out translation files
public/
locales/
en/
common.json
auth.json
settings.json
es/
common.json
auth.json
settings.json
fr/
common.json
auth.json
settings.json3. Wrap your root layout (RSC)
// app/layout.tsx
import { join } from 'node:path';
import '@apollo-deploy/aurebesh/react';
import '@apollo-deploy/aurebesh/server';
import '@apollo-deploy/aurebesh/currency';
import '@apollo-deploy/aurebesh/currency'; // re-exported from server
import { i18nConfig } from '@/lib/i18n.config';
ensureDefaultFxProviders(); // idempotent — safe to call on every render
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const i18nState = await getI18nServerState('en', i18nConfig.namespaces, {
publicDir: join(process.cwd(), 'public'),
});
return (
<html lang="en">
<body>
<I18nProvider config={i18nConfig} {...i18nState}>
{children}
</I18nProvider>
</body>
</html>
);
}4. Translate in client components
// components/Greeting.tsx
'use client';
import '@apollo-deploy/aurebesh/react';
export function Greeting({ name }: { name: string }) {
const { t, locale, changeLocale, format } = useTranslation('common');
return (
<div>
<h1>{t('greeting', { name })}</h1>
<p>{t('itemCount', { count: 5 })}</p>
<time>{format.date(new Date(), 'long')}</time>
<button onClick={() => changeLocale('es')}>Español</button>
</div>
);
}Translation file format
Plain keys & nested keys
{
"greeting": "Hello, {{name}}!",
"navigation": {
"home": "Home",
"settings": "Settings"
}
}t('greeting', { name: 'Zara' }) // → "Hello, Zara!"
t('navigation.home') // → "Home"Plural forms — flat sibling style (i18next-compatible)
{
"itemCount": "{{count}} item",
"itemCount_other": "{{count}} items",
"itemCount_zero": "No items"
}t('itemCount', { count: 0 }) // → "No items"
t('itemCount', { count: 1 }) // → "1 item"
t('itemCount', { count: 5 }) // → "5 items"Plural forms — nested object style
{
"messages": {
"one": "You have {{count}} message",
"other": "You have {{count}} messages",
"0": "No messages",
"2-5": "A few messages ({{count}})",
"6+": "Many messages"
}
}Resolution priority: exact count → interval → CLDR form → other
Ordinal plurals
{
"rank": {
"one": "{{count}}st place",
"two": "{{count}}nd place",
"few": "{{count}}rd place",
"other": "{{count}}th place"
}
}t('rank', { count: 1, ordinal: true }) // → "1st place"
t('rank', { count: 3, ordinal: true }) // → "3rd place"Gender/case inflection
{
"role": {
"male": { "nominative": "actor", "other": "actor" },
"female": { "nominative": "actress", "other": "actress" },
"other": { "nominative": "performer" }
}
}t('role', { gender: 'female', case: 'nominative' }) // → "actress"Inline plural and select shorthands
For plurals or gender variants embedded inside a longer sentence, use the
inline shorthand syntax. Everything stays inside {{}} — the same delimiters
used for plain variables.
Plural — 2 branches (singular | plural)
{
"items": "You have {{count}} {{count : item | items}}.",
"results": "Found {{count : # result | # results}} matching your search."
}t('items', { count: 1 }) // → "You have 1 item."
t('items', { count: 5 }) // → "You have 5 items."
t('results', { count: 0 }) // → "Found 0 results matching your search."
t('results', { count: 1 }) // → "Found 1 result matching your search."Plural — 3 branches (zero | singular | plural)
When you need a distinct zero form, add a third branch:
{
"messages": "{{count : No unread messages | # unread message | # unread messages}}"
}t('messages', { count: 0 }) // → "No unread messages"
t('messages', { count: 1 }) // → "1 unread message"
t('messages', { count: 4 }) // → "4 unread messages"# inside a branch is replaced with the actual count value.
Select (gender, status, any discrete value)
{
"confirmed": "{{gender : male: He | female: She | They}} confirmed the order.",
"status": "Status: {{state : active: Active | paused: Paused | Unknown}}"
}t('confirmed', { gender: 'male' }) // → "He confirmed the order."
t('confirmed', { gender: 'female' }) // → "She confirmed the order."
t('confirmed', { gender: 'other' }) // → "They confirmed the order."Rules:
- Branches with
key: textformat are named cases. - The last branch without
:is the fallback (other).
React API
useTranslation(namespace?)
import '@apollo-deploy/aurebesh/react';
const { t, locale, changeLocale, isLoading, format } = useTranslation('settings');| Return | Type | Description |
|--------|------|-------------|
| t | (key, opts?) => string | Translate a key, falls back to key on miss |
| locale | SupportedLocale | Current active locale code |
| changeLocale | (next: SupportedLocale) => Promise<void> | Switch locale + persist to cookie/localStorage |
| isLoading | boolean | true while namespaces are loading over HTTP |
| format | FormatService | Locale-bound formatting utilities |
FormatService methods
format.date(new Date(), 'long') // "January 15, 2024"
format.date(new Date(), 'short') // "1/15/24"
format.date(new Date(), { weekday: 'long', month: 'short' })
format.time(new Date()) // "2:30 PM"
format.dateTime(new Date()) // "Jan 15, 2024, 2:30 PM"
format.relative(new Date(Date.now() - 3600000)) // "1 hour ago"
format.number(1234567.89) // "1,234,567.89" (locale-aware)
format.currency(99.99, 'EUR') // "€99.99"
format.list(['apples', 'oranges', 'pears']) // "apples, oranges, and pears"
format.list(['cats', 'dogs'], 'disjunction') // "cats or dogs"useMoney()
'use client';
import '@apollo-deploy/aurebesh/react';
function PricingCard({ price }: { price: Price }) {
const money = useMoney();
return (
<div>
{/* AUTO: converts USD → EUR via FX snapshot */}
<span>{money(price, { target: 'EUR' })}</span>
{/* FIXED: no conversion, renders base currency as-is */}
<span>{money({ ...price, policy: 'FIXED' })}</span>
{/* Regional override: uses price.regional['EU'] if present */}
<span>{money(price, { region: 'EU' })}</span>
</div>
);
}Requires rates to be passed to <I18nProvider> from the server. Throws a dev-mode error when rates are missing for AUTO policy.
I18nProvider
<I18nProvider
config={i18nConfig}
initialLocale="en"
initialMessages={{ common: { ... }, auth: { ... } }} // SSR hydration
rates={rateSnapshot} // FX snapshot for useMoney
missingKeyReporter={reporter} // optional observability
>
{children}
</I18nProvider>Server API
getI18nServerState(locale, namespaces, opts)
Load translation files from disk for SSR hydration. Skips missing files gracefully.
import '@apollo-deploy/aurebesh/server';
const state = await getI18nServerState('en', ['common', 'auth', 'settings'], {
publicDir: join(process.cwd(), 'public'),
pathTemplate: 'locales/{{locale}}/{{namespace}}.json', // default
});
// → { initialLocale: 'en', initialMessages: { common: {...}, auth: {...}, settings: {...} } }resolveRequestLocale(input, config)
Detect the correct locale from an incoming request (proxy/middleware use case).
import '@apollo-deploy/aurebesh/server';
// In proxy.ts (Next.js 16)
const locale = resolveRequestLocale(
{
cookieLocale: request.cookies.get('apollo.locale')?.value,
acceptLanguage: request.headers.get('accept-language'),
},
i18nConfig, // { supportedLocales, defaultLocale }
);Follows cookie-first strategy — cookie wins over Accept-Language header. Falls back to defaultLocale when no supported match is found.
detectLocaleFromAcceptLanguage(header, opts)
Parse an Accept-Language header with full quality-weight (q=) support.
detectLocaleFromAcceptLanguage('fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7', {
supportedLocales: ['en', 'es', 'fr'],
defaultLocale: 'en',
}); // → 'fr'money(price, opts) — server-side
Resolve and format a price against cached FX rates in a single call.
import '@apollo-deploy/aurebesh/server';
const price: Price = {
base: { amount: 99.99, currency: 'USD' },
regional: { EU: { amount: 84.99, currency: 'EUR' } },
};
await money(price, { target: 'EUR', locale: 'de-DE' }); // "84,99 €"
await money(price, { region: 'EU', locale: 'de-DE' }); // "84,99 €" (regional override)
await money(price, { locale: 'ja-JP', target: 'JPY' }); // "¥15,000"Currency & FX Engine
Price types
interface Price {
base: { amount: number; currency: string }; // ISO 4217, decimal units
regional?: Record<string, PriceAmount>; // keyed by region code, e.g. 'EU'
policy?: 'AUTO' | 'FIXED' | 'DISPLAY_ONLY';
}| Policy | Behaviour |
|--------|-----------|
| AUTO (default) | Convert base amount to target currency via live FX rates |
| FIXED | Never convert — always render the base amount as-is (compliance use case) |
| DISPLAY_ONLY | Show base currency without conversion (informational display) |
FX providers
import {
ExchangeRateHostProvider,
StaticFallbackProvider,
MultiProvider,
registerProvider,
ensureDefaultFxProviders,
} from '@apollo-deploy/@apollo-deploy/aurebesh/currency';
// Option A: use the built-in defaults (recommended)
ensureDefaultFxProviders();
// Registers: 'exchange-rate-host', 'static-fallback', 'multi:host+fallback'
// Option B: bring your own provider chain
const liveProvider = new ExchangeRateHostProvider({ apiKey: process.env.FX_KEY });
const fallback = new StaticFallbackProvider({
table: { USD: { USD: 1, EUR: 0.91, GBP: 0.78 } },
});
const chain = new MultiProvider([liveProvider, fallback], 'my-chain');
registerProvider(liveProvider);
registerProvider(fallback);
registerProvider(chain);Custom provider
Implement the CurrencyProvider interface to add any data source:
import '@apollo-deploy/aurebesh/currency';
class MyProvider implements CurrencyProvider {
readonly name = 'my-provider';
async fetchRates(base: string): Promise<RateSnapshot> {
const data = await myApi.getRates(base);
return {
base,
rates: data.rates,
fetchedAt: Date.now(),
source: this.name,
};
}
}FX caching (Next.js "use cache")
getCachedRates uses the Next.js 16 Cache Components system:
import '@apollo-deploy/aurebesh/currency'; // server-only
// Read (cached 30 min, stale 15 min, expires 24h):
const snapshot = await getCachedRates('USD', 'multi:host+fallback');
// Invalidate when you know rates have changed:
invalidateFxRatesForBase('USD');
invalidateFxRates(); // invalidate ALL fx-rates:* entriesRequires cacheComponents: true in your app's next.config.ts.
FX refresh route handler
// app/api/admin/fx-refresh/route.ts
import '@apollo-deploy/aurebesh/server';
import { verifyAdminToken } from '@/lib/auth';
export const POST = createFxRefreshHandler({
authorize: async (request) => {
const token = request.headers.get('x-admin-token');
return verifyAdminToken(token);
},
defaultBase: 'USD',
defaultWarm: true, // pre-warm cache after invalidation
});POST to /api/admin/fx-refresh with optional JSON body:
{ "scope": "base", "base": "USD", "warm": true }Observability
Missing-key reporter
import '@apollo-deploy/aurebesh/observability';
const reporter = new InMemoryMissingTranslationReporter();
<I18nProvider config={i18nConfig} missingKeyReporter={reporter}>
{children}
</I18nProvider>
// Later — e.g. in an admin panel or CI check:
const metrics = reporter.snapshot(); // sorted by hit count descending
// [
// { key: 'billing.invoiceTitle', locale: 'es', namespace: 'billing', hits: 42, ... },
// ...
// ]
reporter.clear();Custom reporter (send to your monitoring backend)
import '@apollo-deploy/aurebesh/observability';
class DatadogMissingKeyReporter implements MissingTranslationReporter {
report(event: MissingTranslationEvent): void {
datadogRum.addError(new Error(`Missing i18n key: ${event.key}`), {
locale: event.locale,
namespace: event.namespace,
});
}
}Translation coverage reports
Use getTranslationCoverageFromDisk in CI to gate deployments on translation completeness.
// scripts/check-translations.ts
import { join } from 'node:path';
import '@apollo-deploy/aurebesh/server';
const report = await getTranslationCoverageFromDisk({
publicDir: join(process.cwd(), 'public'),
locales: ['en', 'es', 'fr'],
namespaces: ['common', 'auth', 'settings'],
referenceLocale: 'en', // 'en' is the source of truth
});
console.log(`Overall coverage: ${(report.summary.coverage * 100).toFixed(1)}%`);
for (const locale of report.locales) {
if (locale.coverage < 0.9) {
console.error(`${locale.locale}: ${(locale.coverage * 100).toFixed(1)}% — below threshold`);
process.exit(1);
}
}Report shape:
{
generatedAt: 1713456789000,
referenceLocale: 'en',
namespaces: ['common', 'auth', 'settings'],
summary: {
localeCount: 3,
referenceKeyCount: 280,
translatedKeyCount: 252,
missingKeyCount: 28,
extraKeyCount: 4,
coverage: 0.9, // 0–1 ratio
},
locales: [
{
locale: 'es',
coverage: 0.94,
namespaces: [
{
locale: 'es',
namespace: 'settings',
coverage: 0.88,
missingKeys: ['billing.planName', 'billing.nextRenewal'],
extraKeys: ['legacy.oldKey'],
missingFile: false,
...
}
]
}
]
}Locale detection in middleware / proxy
// proxy.ts (Next.js 16 — replaces middleware.ts)
import { NextRequest, NextResponse } from 'next/server';
import '@apollo-deploy/aurebesh/server';
import { i18nConfig } from '@/lib/i18n.config';
export function proxy(request: NextRequest) {
const locale = resolveRequestLocale(
{
cookieLocale: request.cookies.get('apollo.locale')?.value ?? null,
acceptLanguage: request.headers.get('accept-language'),
},
i18nConfig,
);
const response = NextResponse.next();
response.headers.set('x-locale', locale);
return response;
}Config reference
import '@apollo-deploy/aurebesh/config';
const config = createI18nConfig(['settings', 'billing', 'audit']);
// config.namespaces → ['common', 'auth', 'settings', 'billing', 'audit']
// config.defaultLocale → 'en'
// config.supportedLocales → ['en', 'es', 'fr']
// config.interpolation.prefix → '{{'
// config.interpolation.suffix → '}}'
// config.interpolation.escapeValue → true (HTML-escape by default)
// config.pluralization.simplifyPluralSuffix → true (_plural maps to 'other')
// config.loadPath → '/locales/{{locale}}/{{namespace}}.json'Adding a new supported locale
Edit src/config/locales.ts:
export const SUPPORTED_LOCALES = [
{ code: 'en', name: 'English', nativeName: 'English', direction: 'ltr' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', direction: 'ltr' },
{ code: 'fr', name: 'French', nativeName: 'Français', direction: 'ltr' },
// add here:
{ code: 'de', name: 'German', nativeName: 'Deutsch', direction: 'ltr' },
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', direction: 'rtl' },
] as const;Module exports
| Import path | Environment | Contents |
|-------------|------------|---------|
| @apollo-deploy/aurebesh | Both | Root types: TranslateFn, FormatService, UseTranslationResult + all config re-exports |
| @apollo-deploy/aurebesh/config | Both | I18nConfig, LocaleConfig, SupportedLocale, SUPPORTED_LOCALES, DEFAULT_LOCALE, BASE_NAMESPACES, baseI18nConfig, createI18nConfig |
| @apollo-deploy/aurebesh/react | Client only | I18nProvider, useTranslation, useMoney, buildFormatService, getPersistedLocale, persistLocale |
| @apollo-deploy/aurebesh/server | Server only | getI18nServerState, loadMessagesFromDisk, resolveRequestLocale, detectLocaleFromAcceptLanguage, money, resolveMoney, refreshFxRates, createFxRefreshHandler, getTranslationCoverageFromDisk |
| @apollo-deploy/aurebesh/format | Both | formatDate, formatTime, formatDateTime, formatRelative, formatNumber, formatPercent, formatList, formatMoney |
| @apollo-deploy/aurebesh/currency | Both (server for rates) | Price, PriceAmount, PricePolicy, RateSnapshot, RateInput, resolvePrice, pickRateSnapshot, isRateSnapshot, ExchangeRateHostProvider, StaticFallbackProvider, MultiProvider, registerProvider, ensureDefaultFxProviders, getCachedRates, invalidateFxRates |
| @apollo-deploy/aurebesh/observability | Both | MissingTranslationReporter, MissingTranslationEvent, MissingTranslationMetric, InMemoryMissingTranslationReporter |
Requirements
| Dependency | Version | |------------|---------| | Node.js | 20+ | | TypeScript | 5.x | | Next.js | 16+ (peer dep) | | React | 18+ or 19+ (peer dep) |
cacheComponents: true must be set in next.config.ts for FX rate caching to function.
Contributing
# Install
pnpm install
# Typecheck
pnpm -F aurebesh build
# Tests
pnpm -F aurebesh test
# Watch mode
pnpm -F aurebesh test:watchTranslation JSON files live in each app's public/locales/ directory — the package itself ships no translations.
License
MIT
