Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion packages/i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const $fmt = formatter($locale); // reactive Intl wrapper
`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, messageFormat } from '@clerk/i18n';
import { createI18n, params, count, currency, messageFormat } from '@clerk/i18n';

const i18n = createI18n($locale, {
get: locale => fetch(`/locales/${locale}.json`).then(r => r.json()),
Expand All @@ -30,12 +30,14 @@ 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>"
```

Expand Down
13 changes: 13 additions & 0 deletions packages/i18n/src/create-i18n/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';

import { atom } from '../atom';
import { count } from '../count';
import { currency } from '../currency';
import { defineLocalization } from '../define-localization';
import { params } from '../params';
import { createI18n } from './index';
Expand Down Expand Up @@ -64,6 +65,18 @@ describe('createI18n', () => {
expect($msg.get().items(5)).toBe('5 items');
});

it('formats currency with the active locale', () => {
const $locale = atom('en-US');
const i18n = createI18n($locale, { get: vi.fn() });
const $msg = i18n('billing', { $: currency() });

expect($msg.get().$(1000, 'USD')).toBe('$10.00');
expect($msg.get().$(1000, 'USD', { style: 'short' })).toBe('$10');

$locale.set('fr-FR');
expect($msg.get().$(100000, 'USD')).toBe('1 000,00 $US');
});

// Snapshot identity must be stable across reads when nothing changed, otherwise
// `useSyncExternalStore` (which reads `get()` before subscribing) loops forever.
it('returns a referentially stable snapshot until an input changes', () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/i18n/src/create-i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { atom, computed, task } from 'nanostores';

import { createCurrencyFormatter } from '../currency';
import type { AnyMarker, Messages, PluralForms, ReadableStore, ResolvedOverrides } from '../types';

/** The override values for one namespace, keyed by message key. */
Expand Down Expand Up @@ -133,6 +134,10 @@ export function createI18n($locale: ReadableStore<string>, options: CreateI18nOp
};
}

if (marker?._type === 'currency') {
return createCurrencyFormatter(locale);
}

if (marker?._type === 'count-params') {
const rules = new Intl.PluralRules(locale);
const forms: PluralForms =
Expand Down Expand Up @@ -160,7 +165,13 @@ export function createI18n($locale: ReadableStore<string>, options: CreateI18nOp
): Messages<B> {
const out: Record<string, unknown> = {};
for (const key in base) {
out[key] = buildEntry(locale, base[key], overridesForNamespace?.[key]);
const baseVal = base[key];
const override = overridesForNamespace?.[key];
if (typeof baseVal === 'object' && baseVal !== null && !asMarker(baseVal)) {
out[key] = buildMessages(locale, baseVal as Record<string, unknown>, override as Overrides | undefined);
} else {
out[key] = buildEntry(locale, baseVal, override);
}
}
return out as Messages<B>;
}
Expand Down
36 changes: 36 additions & 0 deletions packages/i18n/src/currency/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { CurrencyFormatOptions } from '../formatter';
import type { CurrencyMarker } from '../types';

export type CurrencyFormatFn = (amount: number, currencyCode: string, opts?: CurrencyFormatOptions) => string;

export function createCurrencyFormatter(locale: string): CurrencyFormatFn {
const nf: Record<string, Intl.NumberFormat> = {};

return (amount, currencyCode, opts = {}) => {
try {
const currency = currencyCode !== '' ? currencyCode : 'USD';
const baseKey = JSON.stringify({ style: 'currency', currency });
nf[baseKey] ??= new Intl.NumberFormat(locale, { style: 'currency', currency });
const { maximumFractionDigits } = nf[baseKey].resolvedOptions();
const major = amount / 10 ** (maximumFractionDigits ?? 2);

if (opts.style === 'short') {
const shortKey = JSON.stringify({ style: 'currency', currency, trailingZeroDisplay: 'stripIfInteger' });
nf[shortKey] ??= new Intl.NumberFormat(locale, {
style: 'currency',
currency,
trailingZeroDisplay: 'stripIfInteger',
});
return nf[shortKey].format(major);
}

return nf[baseKey].format(major);
} catch {
return `${currencyCode} ${(amount / 100).toFixed(2)}`;
}
};
}

export function currency(): CurrencyMarker {
return { _type: 'currency' };
}
26 changes: 3 additions & 23 deletions packages/i18n/src/formatter/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { computed } from 'nanostores';

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

export interface CurrencyFormatOptions {
Expand Down Expand Up @@ -27,6 +28,7 @@ export function formatter($locale: ReadableStore<string>): ReadableStore<Formatt
const dtf: Record<string, Intl.DateTimeFormat> = {};
const nf: Record<string, Intl.NumberFormat> = {};
const rtf: Record<string, Intl.RelativeTimeFormat> = {};
const currency = createCurrencyFormatter(locale);

return {
time(date, opts = {}) {
Expand All @@ -44,29 +46,7 @@ export function formatter($locale: ReadableStore<string>): ReadableStore<Formatt
rtf[key] ??= new Intl.RelativeTimeFormat(locale, { numeric: 'auto', ...opts });
return rtf[key].format(value, unit);
},
currency(amount, currencyCode, opts = {}) {
try {
const currency = currencyCode !== '' ? currencyCode : 'USD';
const baseKey = JSON.stringify({ style: 'currency', currency });
nf[baseKey] ??= new Intl.NumberFormat(locale, { style: 'currency', currency });
const { maximumFractionDigits } = nf[baseKey].resolvedOptions();
const major = amount / 10 ** (maximumFractionDigits ?? 2);

if (opts.style === 'short') {
const shortKey = JSON.stringify({ style: 'currency', currency, trailingZeroDisplay: 'stripIfInteger' });
nf[shortKey] ??= new Intl.NumberFormat(locale, {
style: 'currency',
currency,
trailingZeroDisplay: 'stripIfInteger',
});
return nf[shortKey].format(major);
}

return nf[baseKey].format(major);
} catch {
return `${currencyCode} ${(amount / 100).toFixed(2)}`;
}
},
currency,
};
});
}
5 changes: 4 additions & 1 deletion packages/i18n/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { localeFrom } from './locale-from';
export { formatter } from './formatter';
export { params } from './params';
export { count } from './count';
export { currency } from './currency';
export { messageFormat, getMessageFormatParts, formatToParts } from './message-format';
export { createI18n } from './create-i18n';
export { defineLocalization } from './define-localization';
Expand All @@ -14,12 +15,14 @@ export { translationsLoading } from './translations-loading';
export { messagesToJSON } from './messages-to-json';

export type { BrowserOptions } from './browser';
export type { Formatter } from './formatter';
export type { CurrencyFormatFn } from './currency';
export type { CurrencyFormatOptions, Formatter } from './formatter';
export type { MessageFormatPart, RichText, ResolvedPart } from './message-format';
export type { CreateI18nOptions, I18n, MessageStore } from './create-i18n';
export type {
AnyMarker,
CountMarker,
CurrencyMarker,
ExtractParams,
FlatKey,
FlatOverrides,
Expand Down
10 changes: 10 additions & 0 deletions packages/i18n/src/index.type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { describe, expectTypeOf, it } from 'vitest';
import { atom } from './atom';
import { count } from './count';
import { createI18n } from './create-i18n';
import { currency } from './currency';
import { defineLocalization } from './define-localization';
import type { CurrencyFormatFn, CurrencyFormatOptions } from './index';
import type { RichText } from './message-format';
import { messageFormat } from './message-format';
import { params } from './params';
Expand All @@ -27,12 +29,14 @@ describe('createI18n message inference', () => {
plain: 'Hello',
greet: params('Hi {name}'),
items: count({ one: '{count} item', other: '{count} items' }),
price: currency(),
rich: messageFormat('{#b}x{/b}'),
}).get();

expectTypeOf(m.plain).toEqualTypeOf<string>();
expectTypeOf(m.greet).parameter(0).toEqualTypeOf<{ name: string | number }>();
expectTypeOf(m.items).toEqualTypeOf<(n: number) => string>();
expectTypeOf(m.price).toEqualTypeOf<CurrencyFormatFn>();
expectTypeOf(m.rich).toEqualTypeOf<RichText>();
});

Expand All @@ -50,6 +54,12 @@ describe('OverrideValue', () => {
});
});

describe('currency formatting typing', () => {
it('exposes options for higher-level currency helpers', () => {
expectTypeOf<CurrencyFormatOptions>().toMatchTypeOf<{ style?: 'short' }>();
});
});

describe('defineLocalization typing', () => {
type Reg = { signIn: { title: string; greet: ParamsMarker<'Hi {name}'>; items: CountMarker } };

Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/messages-to-json/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { atom } from '../atom';
import { count } from '../count';
import { createI18n } from '../create-i18n';
import { currency } from '../currency';
import { messageFormat } from '../message-format';
import { params } from '../params';
import { messagesToJSON } from './index';
Expand All @@ -15,6 +16,7 @@ describe('messagesToJSON', () => {
greet: params('Hi {name}'),
items: count({ one: '{count} item', other: '{count} items' }),
page: params<{ category: string }>(count({ one: 'One in {category}', other: '{count} in {category}' })),
$: currency(),
notice: messageFormat('Read the {#a}terms{/a}'),
nested: { title: 'T' },
});
Expand Down
17 changes: 14 additions & 3 deletions packages/i18n/src/messages-to-json/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,24 @@ function isMarker(value: unknown): value is AnyMarker {
return typeof value === 'object' && value !== null && '_type' in value;
}

const SKIP = Symbol('skip');

/** Serialize a single `base` value back to its raw JSON form. */
function serialize(value: unknown): unknown {
function serialize(value: unknown) {
if (isMarker(value)) {
if (value._type === 'currency') {
return SKIP;
}
// params / messageFormat carry a template string; count / count-params carry forms.
return value._type === 'params' || value._type === 'transform' ? value.template : value.forms;
}
if (value && typeof value === 'object') {
const out = Object.create(null) as Record<string, unknown>;
for (const key of Object.keys(value as Record<string, unknown>)) {
out[key] = serialize((value as Record<string, unknown>)[key]);
const serialized = serialize((value as Record<string, unknown>)[key]);
if (serialized !== SKIP) {
out[key] = serialized;
}
}
return out;
}
Expand All @@ -42,7 +50,10 @@ export function messagesToJSON(...messages: SourceStore[]): Record<string, unkno
for (const { namespace, base } of messages) {
const ns = (out[namespace] ??= Object.create(null)) as Record<string, unknown>;
for (const key of Object.keys(base)) {
ns[key] = serialize(base[key]);
const serialized = serialize(base[key]);
if (serialized !== SKIP) {
ns[key] = serialized;
}
}
}
return out;
Expand Down
34 changes: 22 additions & 12 deletions packages/i18n/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

import type { ReadableAtom, WritableAtom } from 'nanostores';

import type { CurrencyFormatFn } from './currency';

// ---------------------------------------------------------------------------
// Stores
//
Expand Down Expand Up @@ -52,6 +54,10 @@ export interface CountMarker {
readonly forms: PluralForms;
}

export interface CurrencyMarker {
readonly _type: 'currency';
}

/**
* A pluralized message that also takes named params: `params(count(...))`. The
* resolved message is `(n, args) => string` — `n` selects the plural form, then
Expand All @@ -71,7 +77,7 @@ export interface TransformMarker<R = unknown> {
}

/** Any message marker. Used for runtime narrowing in `create-i18n`. */
export type AnyMarker = ParamsMarker | CountMarker | CountParamsMarker | TransformMarker;
export type AnyMarker = ParamsMarker | CountMarker | CurrencyMarker | CountParamsMarker | TransformMarker;

// ---------------------------------------------------------------------------
// `messageFormat` transform types
Expand Down Expand Up @@ -120,11 +126,13 @@ export type MessageType<V> =
? (n: number, args: P) => string
: V extends CountMarker
? (n: number) => string
: V extends TransformMarker<infer R>
? R
: V extends string
? string
: V;
: V extends CurrencyMarker
? CurrencyFormatFn
: V extends TransformMarker<infer R>
? R
: V extends string
? string
: V;

/** Map a whole `base` definition object to its resolved messages object. */
export type Messages<B> = { [K in keyof B]: MessageType<B[K]> };
Expand All @@ -146,13 +154,15 @@ export type OverrideValue<V> = V extends ParamsMarker
? string
: V extends CountMarker | CountParamsMarker
? Partial<PluralForms>
: V extends TransformMarker
? string
: V extends string
: V extends CurrencyMarker
? never
: V extends TransformMarker
? string
: V extends Record<string, unknown>
? { [K in keyof V]?: OverrideValue<V[K]> }
: OverrideInput;
: V extends string
? string
: V extends Record<string, unknown>
? { [K in keyof V]?: OverrideValue<V[K]> }
: OverrideInput;

/** A map of namespace -> `base` definition, used to type overrides precisely. */
export type Registry = Record<string, Record<string, unknown>>;
Expand Down
2 changes: 1 addition & 1 deletion packages/swingset/src/components/StoryEmbed.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider';
import { MosaicProvider } from '@clerk/ui/mosaic/providers/mosaic-provider';
import { Layers2Icon } from 'lucide-react';
import type React from 'react';
import { useState } from 'react';
Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/StoryPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider';
import { MosaicProvider } from '@clerk/ui/mosaic/providers/mosaic-provider';
import { RotateCcwIcon, SlidersHorizontalIcon } from 'lucide-react';
import type React from 'react';
import { useEffect, useState } from 'react';
Expand Down Expand Up @@ -61,8 +61,8 @@ export function StoryPreview({ name, storyModule }: StoryPreviewProps) {
<div className='flex min-h-40 items-center justify-center p-10'>
{mounted && (
<MosaicProvider
cssLayerName='components'
appearance={{
cssLayerName: 'components',
variables,
}}
>
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@clerk/i18n": "workspace:^",
"@clerk/localizations": "workspace:^",
"@clerk/shared": "workspace:^",
"@emotion/cache": "11.11.0",
Expand Down
Loading