Skip to content

Apollo-Deploy/Aurebesh

Repository files navigation

Aurebesh

"Aurebesh" — The ancient alphabet of the Star Wars galaxy, used by Jedi and Sith to preserve knowledge across worlds and generations.

TypeScript Next.js React License


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

Server/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/aurebesh

Quick 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.json

3. 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 countintervalCLDR formother

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: text format 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:* entries

Requires 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:watch

Translation JSON files live in each app's public/locales/ directory — the package itself ships no translations.


License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors