Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/dull-badgers-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions packages/i18n/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/*/
!/src/
169 changes: 169 additions & 0 deletions packages/i18n/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# @clerk/i18n

> **Internal package — not published.** Reactive i18n primitives built on [nanostores](https://github.com/nanostores/nanostores).

## Stores
Comment thread
alexcarpenter marked this conversation as resolved.

Every primitive returns a nanostores store — `.get()` / `.subscribe()` / `.listen()` (and `.set()` for writable stores):

```ts
import { atom, browser, localeFrom, formatter, direction } from '@clerk/i18n';

const $setting = atom<string | null>(null); // writable
const $browser = browser({ available: ['en', 'fr'] }); // from navigator.languages
const $locale = localeFrom($setting, $browser); // first non-null, else 'en'
const $fmt = formatter($locale); // reactive Intl wrapper
const $dir = direction($locale); // 'ltr' | 'rtl', follows the locale
```

`formatter($locale)` exposes locale-bound `time`, `number`, `relativeTime`, `list`, and `currency` (Intl instances are cached per options key):

```ts
$fmt.get().list(['Google', 'GitHub', 'Apple']); // "Google, GitHub, and Apple"
$fmt.get().list(['Google', 'GitHub'], { type: 'disjunction' }); // "Google or GitHub"
```

`direction($locale)` resolves text direction (via `Intl.Locale` text info, falling back to a known RTL-language list). Bind it to a container's `dir`:

```tsx
<div dir={useStore($dir)}>…</div>
```

## Messages

`createI18n($locale, options)` returns a namespace factory. `base` is the source-locale definition; message types are inferred from it. Non-base locales are fetched lazily via `get`; until the data lands, messages fall back to `base` per key.

```ts
import { createI18n, params, count, currency, messageFormat } from '@clerk/i18n';

const i18n = createI18n($locale, {
get: locale => fetch(`/locales/${locale}.json`).then(r => r.json()),
});

const $messages = i18n('cart', {
title: 'Your cart',
greeting: params('Hi {name}'), // (args: { name: string | number }) => string
items: count({ one: '{count} item', other: '{count} items' }), // (n: number) => string
price: currency(), // (amount: number, currencyCode: string, opts?) => string
notice: messageFormat('Read the {#a}terms{/a}'), // (handlers?) => string
});

const m = $messages.get();
m.greeting({ name: 'Sam' }); // "Hi Sam"
m.items(3); // "3 items"
m.price(1000, 'USD'); // "$10.00"
m.notice({ a: t => `<a>${t}</a>` }); // "Read the <a>terms</a>"
```

Each lazy load is registered as a nanostores [task](https://github.com/nanostores/nanostores#tasks), so during SSR you can `await allTasks()` (re-exported from `@clerk/i18n`) to flush all in-flight loads before rendering.

### `params(count(...))` — plural + params

```ts
const $msgs = i18n('pagination', {
page: params<{ category: string }>(
count({
one: 'One page in {category}',
other: '{count} pages in {category}',
}),
),
});
$msgs.get().page(5, { category: 'robots' }); // "5 pages in robots"
```

## Overrides

A consumer override layer sits above `base` and any fetched locale — **user wins**. Overrides are locale-agnostic (apply to every locale) and live-reactive via a store:

```ts
import { atom, createI18n, defineLocalization } from '@clerk/i18n';

const $overrides = atom(defineLocalization({ 'common.hi': 'Hey' }));
const i18n = createI18n($locale, { get, overrides: $overrides });

const $messages = i18n('common', { hi: 'Hello' });
$messages.get().hi; // "Hey"

$overrides.set(defineLocalization({ 'common.hi': 'Howdy' })); // live update
```

`defineLocalization` accepts **nested** or **flat dot-notation** forms (mixing is tolerated):

```ts
// nested — best for grouped overrides
defineLocalization({ signIn: { title: 'Log in to Acme' } });

// flat — best for sparse or programmatic overrides
defineLocalization({ 'signIn.title': 'Log in to Acme' });
```

Pass a registry type for autocomplete + validation. Overriding a `count` key with a bare string is a compile error:

```ts
type Registry = { signIn: typeof signInBase; cart: typeof cartBase };
defineLocalization<Registry>({ 'signIn.title': 'Log in' }); // ✓
defineLocalization<Registry>({ 'cart.items': 'nope' }); // ✗ count needs plural forms
```

## React

`@clerk/i18n/react` exports `useStore`, `Message`, `useMessage`, and `formatToParts`.

```tsx
import { useStore, Message } from '@clerk/i18n/react';

function Cart() {
const m = useStore($messages);
return (
<>
<h1>{m.title}</h1>
<Message
of={m.notice}
components={{ a: c => <a href='/terms'>{c}</a> }}
/>
</>
);
}
```

`useStore` uses `useSyncExternalStore` with stabilised callbacks so it's safe under React concurrent mode. The optional `{ ssr }` option provides the server snapshot.

Non-React consumers can use `formatToParts(message, values)` for a flat resolved-part list.

## SSR

For SSR, create stores per request (module-level stores share state across requests). Seed the `cache` option with server-fetched data so the first render is correct and single-pass. Call `await allTasks()` or `translationsLoading(i18n)` to flush in-flight loads before streaming.

If a per-request instance shares a long-lived `$locale` store, call `i18n.dispose()` when the request ends to release its subscription (an app-lived singleton never needs this).

`i18n.loading` is `true` while any locale load is in flight; `i18n.error` holds the last load failure (cleared on the next success) so you can surface fallback UI or record telemetry:

```ts
i18n.error.subscribe(err => {
if (err) clerk.telemetry?.record(/* … */);
});
```

For React Server Components, `loadTranslations` is a convenience that awaits the instance's load and returns the snapshot:

```ts
import { atom, createI18n, loadTranslations } from '@clerk/i18n';

async function Post({ locale }: { locale: string }) {
const i18n = createI18n(atom(locale), { get: loadLocale }); // per-request
const t = await loadTranslations(i18n('post', postBase));
return <span>{t.title}</span>;
}
```

## CI: extracting source strings

`messagesToJSON` serialises each namespace's `base` back to raw JSON (markers → template strings / plural forms) for upload to a translation service:

```ts
import { atom, createI18n, messagesToJSON } from '@clerk/i18n';

const i18n = createI18n(atom('en'), { get: async () => ({}) });
const json = messagesToJSON(i18n('organizationProfile', orgProfileBase), i18n('signIn', signInBase));
// { organizationProfile: { title: 'Organization Profile', … }, signIn: { … } }
```
76 changes: 76 additions & 0 deletions packages/i18n/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"name": "@clerk/i18n",
"version": "0.1.0",
"private": true,
Comment thread
alexcarpenter marked this conversation as resolved.
"description": "Reactive i18n primitives for Clerk SDKs, built on nanostores.",
"repository": {
"type": "git",
"url": "git+https://github.com/clerk/javascript.git",
"directory": "packages/i18n"
},
"license": "MIT",
"author": "Clerk",
"sideEffects": false,
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./react": {
"import": {
"types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js"
},
"require": {
"types": "./dist/react/index.d.cts",
"default": "./dist/react/index.cjs"
}
},
Comment thread
alexcarpenter marked this conversation as resolved.
"./package.json": "./package.json"
},
"files": [
"dist"
],
"scripts": {
"build": "tsdown",
"clean": "rimraf ./dist",
"dev": "tsdown --watch src",
"lint": "eslint src",
"test": "vitest run",
"test:ci": "vitest run --maxWorkers=70%",
"test:watch": "vitest"
},
"dependencies": {
"nanostores": "1.3.0"
},
"devDependencies": {
"@testing-library/react": "^16.0.0",
"@types/react": "catalog:react",
"react": "catalog:react",
"react-dom": "catalog:react",
"rimraf": "6.0.1",
"tsdown": "catalog:repo",
"typescript": "catalog:repo"
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"peerDependencies": {
"react": "catalog:peer-react"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"engines": {
"node": ">=20.9.0"
},
"publishConfig": {
"access": "public"
}
}
53 changes: 53 additions & 0 deletions packages/i18n/src/atom/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest';

import { atom } from './index';

describe('atom', () => {
it('reads and writes its value', () => {
const $a = atom(1);
expect($a.get()).toBe(1);
$a.set(2);
expect($a.get()).toBe(2);
});

it('subscribe fires immediately with the current value and on every change', () => {
const $a = atom(1);
const cb = vi.fn();

const unsubscribe = $a.subscribe(cb);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb.mock.calls[0][0]).toBe(1);

$a.set(2);
expect(cb).toHaveBeenCalledTimes(2);
expect(cb.mock.calls[1][0]).toBe(2);

unsubscribe();
$a.set(3);
expect(cb).toHaveBeenCalledTimes(2);
});

it('listen skips the current value and fires only on change', () => {
const $a = atom(1);
const cb = vi.fn();

const unsubscribe = $a.listen(cb);
expect(cb).not.toHaveBeenCalled();

$a.set(2);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb.mock.calls[0][0]).toBe(2);

unsubscribe();
$a.set(3);
expect(cb).toHaveBeenCalledTimes(1);
});

it('does not notify when set to an equal value', () => {
const $a = atom(1);
const cb = vi.fn();
$a.listen(cb);
$a.set(1);
expect(cb).not.toHaveBeenCalled();
});
});
12 changes: 12 additions & 0 deletions packages/i18n/src/atom/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { atom as nanoAtom } from 'nanostores';

import type { WritableStore } from '../types';

// Stores are nanostores under the hood. The nanostores store contract
// (`.get` / `.set` / `.subscribe` / `.listen`) is exactly our `ReadableStore` /
// `WritableStore` shape, so no adapter layer is needed.

/** A writable store, equivalent to a nanostores `atom()`. */
export function atom<T>(initial: T): WritableStore<T> {
return nanoAtom(initial) as WritableStore<T>;
}
27 changes: 27 additions & 0 deletions packages/i18n/src/browser/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { browser } from './index';

describe('browser', () => {
afterEach(() => vi.unstubAllGlobals());

it('resolves an exact match from navigator.languages', () => {
vi.stubGlobal('navigator', { languages: ['fr-FR', 'en'] });
expect(browser({ available: ['en', 'fr-FR'] }).get()).toBe('fr-FR');
});

it('falls back to the base language of a regional preference', () => {
vi.stubGlobal('navigator', { languages: ['fr-FR'] });
expect(browser({ available: ['en', 'fr'] }).get()).toBe('fr');
});

it('uses the fallback when nothing is available', () => {
vi.stubGlobal('navigator', { languages: ['de'] });
expect(browser({ available: ['en', 'fr'], fallback: 'en' }).get()).toBe('en');
});

it('defaults the fallback to en', () => {
vi.stubGlobal('navigator', { languages: ['de'] });
expect(browser({ available: ['fr'] }).get()).toBe('en');
});
});
23 changes: 23 additions & 0 deletions packages/i18n/src/browser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { atom } from 'nanostores';

import type { ReadableStore } from '../types';

export interface BrowserOptions {
/** Locales the app actually ships translations for. */
available: string[];
/** Locale to use when none of the browser languages match. Defaults to `'en'`. */
fallback?: string;
}

/**
* Resolve a locale from the browser's language preferences, narrowed to the
* set of `available` locales. Each preference is tried both as-is (`en-US`)
* and as its base language (`en`). Resolved once, at call time.
*/
export function browser({ available, fallback = 'en' }: BrowserOptions): ReadableStore<string> {
const langs = typeof navigator !== 'undefined' ? [...(navigator.languages || [navigator.language])] : [fallback];

const resolved = langs.flatMap(l => [l, l.split('-')[0]]).find(l => available.includes(l)) ?? fallback;

return atom(resolved);
}
10 changes: 10 additions & 0 deletions packages/i18n/src/count/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';

import { count } from './index';

describe('count', () => {
it('creates a count marker carrying the plural forms', () => {
const forms = { one: '{count} item', other: '{count} items' };
expect(count(forms)).toEqual({ _type: 'count', forms });
});
});
14 changes: 14 additions & 0 deletions packages/i18n/src/count/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { CountMarker, PluralForms } from '../types';

/**
* Define a pluralized message. The resolved message is a function of `n` that
* selects a plural form via `Intl.PluralRules` for the active locale and
* substitutes `{count}` with `n`.
*
* ```ts
* count({ one: '{count} item', other: '{count} items' })
* ```
*/
export function count(forms: PluralForms): CountMarker {
return { _type: 'count', forms };
}
Loading
Loading