canopy-i18n 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -3
- package/dist/adaptors/next/_client.d.ts +32 -0
- package/dist/adaptors/next/_client.js +61 -0
- package/dist/adaptors/next/createI18nNext.d.ts +53 -0
- package/dist/adaptors/next/createI18nNext.js +45 -0
- package/dist/adaptors/next/index.d.ts +2 -0
- package/dist/adaptors/next/index.js +1 -0
- package/dist/adaptors/react/createCookieI18nReact.d.ts +8 -0
- package/dist/adaptors/react/createCookieI18nReact.js +47 -0
- package/dist/adaptors/react/createHashI18nReact.d.ts +2 -0
- package/dist/adaptors/react/createHashI18nReact.js +24 -0
- package/dist/adaptors/react/createI18nReact.d.ts +50 -0
- package/dist/adaptors/react/createI18nReact.js +55 -0
- package/dist/adaptors/react/createPathnameI18nReact.d.ts +5 -0
- package/dist/adaptors/react/createPathnameI18nReact.js +45 -0
- package/dist/adaptors/react/createSearchI18nReact.d.ts +5 -0
- package/dist/adaptors/react/createSearchI18nReact.js +33 -0
- package/dist/adaptors/react/createStorageI18nReact.d.ts +5 -0
- package/dist/adaptors/react/createStorageI18nReact.js +35 -0
- package/dist/adaptors/react/index.d.ts +11 -0
- package/dist/adaptors/react/index.js +6 -0
- package/package.json +28 -4
- package/skills/SKILL.md +103 -334
package/README.md
CHANGED
|
@@ -2,9 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
A tiny, type-safe i18n library for building localized messages with builder pattern and applying locales across nested data structures.
|
|
4
4
|
|
|
5
|
-

|
|
6
|
-
|
|
7
|
-
|
|
8
5
|
## Features
|
|
9
6
|
- **AI-friendly**: Full type safety and single-file colocation give AI assistants complete context for accurate code generation.
|
|
10
7
|
- **Type-safe**: Compile-time safety for locale keys with full TypeScript IntelliSense support.
|
|
@@ -348,6 +345,117 @@ console.log(localized.content.sidebar.widget()); // "Widget"
|
|
|
348
345
|
```
|
|
349
346
|
|
|
350
347
|
|
|
348
|
+
## React Integration
|
|
349
|
+
|
|
350
|
+
`canopy-i18n/react` provides a tiny factory that returns a Provider, hooks, and a pre-bound `defineMessage` — all sharing the same `Locale` type.
|
|
351
|
+
|
|
352
|
+
```tsx
|
|
353
|
+
// i18n.ts
|
|
354
|
+
import { createI18nReact } from 'canopy-i18n/react';
|
|
355
|
+
|
|
356
|
+
export const LOCALES = ['en', 'ja'] as const;
|
|
357
|
+
|
|
358
|
+
export const { i18n, LocaleProvider, useLocale, useBindLocale } =
|
|
359
|
+
createI18nReact(LOCALES);
|
|
360
|
+
|
|
361
|
+
export const appI18n = i18n({
|
|
362
|
+
title: { en: 'My App', ja: 'マイアプリ' },
|
|
363
|
+
greeting: (ctx: { name: string }) => ({
|
|
364
|
+
en: `Hello, ${ctx.name}!`,
|
|
365
|
+
ja: `こんにちは、${ctx.name}さん!`,
|
|
366
|
+
}),
|
|
367
|
+
});
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
```tsx
|
|
371
|
+
// main.tsx
|
|
372
|
+
import { LocaleProvider } from './i18n';
|
|
373
|
+
|
|
374
|
+
<LocaleProvider defaultLocale="en">
|
|
375
|
+
<App />
|
|
376
|
+
</LocaleProvider>
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`LocaleProvider` also supports a controlled mode for integrating with URL routing, cookies, or external state:
|
|
380
|
+
|
|
381
|
+
```tsx
|
|
382
|
+
// Controlled mode: locale is owned by the parent
|
|
383
|
+
<LocaleProvider locale={currentLocale} onLocaleChange={setCurrentLocale}>
|
|
384
|
+
<App />
|
|
385
|
+
</LocaleProvider>
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
In controlled mode, `setLocale` from `useLocale()` calls your `onLocaleChange` handler instead of mutating internal state.
|
|
389
|
+
|
|
390
|
+
`createI18nReact` also accepts a factory-level `useLocaleSource` for source-driven locale (e.g. external store, URL hook, cookie). When set, the Provider reads the locale from this hook and `setLocale` calls `onLocaleChange` instead of mutating internal state:
|
|
391
|
+
|
|
392
|
+
```tsx
|
|
393
|
+
export const { LocaleProvider, useLocale } = createI18nReact(LOCALES, {
|
|
394
|
+
useLocaleSource: () => useMyStore((s) => s.locale),
|
|
395
|
+
onLocaleChange: (l) => useMyStore.getState().setLocale(l),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
<LocaleProvider>
|
|
399
|
+
<App />
|
|
400
|
+
</LocaleProvider>
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
For common sources, `canopy-i18n/react` ships ready-made factories. Each returns the same shape as `createI18nReact` and operates in source-driven mode:
|
|
404
|
+
|
|
405
|
+
```tsx
|
|
406
|
+
import {
|
|
407
|
+
createHashI18nReact, // URL hash (#ja)
|
|
408
|
+
createSearchI18nReact, // URL search param (?lang=ja, configurable via { param })
|
|
409
|
+
createPathnameI18nReact, // URL pathname prefix (/ja/..., configurable via { basePath })
|
|
410
|
+
createStorageI18nReact, // localStorage (configurable via { key })
|
|
411
|
+
createCookieI18nReact, // Cookie (configurable via { key, maxAge, path, sameSite })
|
|
412
|
+
} from 'canopy-i18n/react';
|
|
413
|
+
|
|
414
|
+
export const { LocaleProvider, useLocale, useBindLocale } =
|
|
415
|
+
createHashI18nReact(LOCALES);
|
|
416
|
+
|
|
417
|
+
// Render <LocaleProvider> with no props.
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
```tsx
|
|
421
|
+
// App.tsx
|
|
422
|
+
import { appI18n, useBindLocale, useLocale } from './i18n';
|
|
423
|
+
|
|
424
|
+
function App() {
|
|
425
|
+
const m = useBindLocale({ appI18n });
|
|
426
|
+
const { locale, setLocale } = useLocale();
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
<div>
|
|
430
|
+
<h1>{m.appI18n.title()}</h1>
|
|
431
|
+
<p>{m.appI18n.greeting({ name: 'Taro' })}</p>
|
|
432
|
+
<button onClick={() => setLocale(locale === 'en' ? 'ja' : 'en')}>
|
|
433
|
+
{locale}
|
|
434
|
+
</button>
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Factory return value
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
const {
|
|
444
|
+
locales, // the LOCALES tuple you passed in
|
|
445
|
+
i18n, // function: i18n(entries) → ChainBuilder bound to LOCALES
|
|
446
|
+
LocaleProvider, // uncontrolled or controlled (see above)
|
|
447
|
+
useLocale, // () => { locale, setLocale }
|
|
448
|
+
useBindLocale, // memoized bindLocale, locale type-checked
|
|
449
|
+
} = createI18nReact(['en', 'ja'] as const);
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
`i18n` is a shorthand for `ChainBuilder.add` bound to a base builder. Each `i18n({...})` call returns an independent `ChainBuilder`, so you can keep chaining `.add({...}).add({...})` for additional entries.
|
|
453
|
+
|
|
454
|
+
- `useBindLocale(msgsDef)` is memoized per `(msgsDef, locale)` pair.
|
|
455
|
+
- The `Locale` type is derived from the `LOCALES` tuple. Passing a `ChainBuilder` whose locales differ from the Provider's locales is rejected at compile time.
|
|
456
|
+
- `createI18nReact` itself has no built-in persistence. Use a built-in wrapper (`createHash/Search/Pathname/Storage/CookieI18nReact`) for common sources, or pass your own `useLocaleSource` / `onLocaleChange` for anything else.
|
|
457
|
+
- React is a `peerDependency` (`>=18`). Non-React users can ignore the `/react` subpath entirely.
|
|
458
|
+
|
|
351
459
|
## Repository
|
|
352
460
|
|
|
353
461
|
https://github.com/MOhhh-ok/canopy-i18n
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { type ComponentProps, type ReactNode } from "react";
|
|
3
|
+
import type { UseLocaleSource } from "../react/createI18nReact.js";
|
|
4
|
+
export type { UseLocaleSource };
|
|
5
|
+
export interface SetLocaleOptions {
|
|
6
|
+
mode?: "push" | "replace";
|
|
7
|
+
}
|
|
8
|
+
export interface LocaleContextValue {
|
|
9
|
+
locale: string;
|
|
10
|
+
setLocale: (locale: string, options?: SetLocaleOptions) => void;
|
|
11
|
+
locales: readonly string[];
|
|
12
|
+
pathPrefix: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function swapLocaleInPath(pathname: string, locale: string, pathPrefix?: string): string;
|
|
15
|
+
export declare function createParamsLocaleSource(paramKey: string): UseLocaleSource;
|
|
16
|
+
export interface ClientLocaleProviderProps {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
locales: readonly string[];
|
|
19
|
+
fallbackLocale?: string;
|
|
20
|
+
paramKey?: string;
|
|
21
|
+
useLocaleSource?: UseLocaleSource;
|
|
22
|
+
pathPrefix?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function ClientLocaleProvider({ children, locales, fallbackLocale, paramKey, useLocaleSource, pathPrefix, }: ClientLocaleProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
25
|
+
export declare function useLocaleClient(): LocaleContextValue;
|
|
26
|
+
export declare function useBindLocaleClient<T extends object>(messages: T): unknown;
|
|
27
|
+
export interface ClientLocaleLinkProps extends Omit<ComponentProps<typeof Link>, "href" | "locale"> {
|
|
28
|
+
locale: string;
|
|
29
|
+
href?: string;
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
}
|
|
32
|
+
export declare function ClientLocaleLink({ locale, href, children, ...rest }: ClientLocaleLinkProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useParams, usePathname, useRouter } from "next/navigation";
|
|
5
|
+
import { createContext, useCallback, useContext, useMemo, } from "react";
|
|
6
|
+
import { bindLocale } from "../../bindLocale.js";
|
|
7
|
+
const LocaleContext = createContext(undefined);
|
|
8
|
+
export function swapLocaleInPath(pathname, locale, pathPrefix = "/") {
|
|
9
|
+
const prefixSegments = pathPrefix.split("/").filter(Boolean);
|
|
10
|
+
const localeIndex = prefixSegments.length + 1;
|
|
11
|
+
const parts = pathname.split("/");
|
|
12
|
+
while (parts.length <= localeIndex) {
|
|
13
|
+
parts.push("");
|
|
14
|
+
}
|
|
15
|
+
parts[localeIndex] = locale;
|
|
16
|
+
return parts.join("/") || "/";
|
|
17
|
+
}
|
|
18
|
+
export function createParamsLocaleSource(paramKey) {
|
|
19
|
+
return () => {
|
|
20
|
+
const params = useParams();
|
|
21
|
+
return params?.[paramKey];
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function ClientLocaleProvider({ children, locales, fallbackLocale, paramKey = "locale", useLocaleSource, pathPrefix = "/", }) {
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
const pathname = usePathname();
|
|
27
|
+
const params = useParams();
|
|
28
|
+
const sourceLocale = useLocaleSource
|
|
29
|
+
? useLocaleSource()
|
|
30
|
+
: params?.[paramKey];
|
|
31
|
+
const locale = sourceLocale ?? fallbackLocale ?? locales[0];
|
|
32
|
+
const setLocale = useCallback((next, options) => {
|
|
33
|
+
const target = swapLocaleInPath(pathname, next, pathPrefix);
|
|
34
|
+
if (options?.mode === "replace") {
|
|
35
|
+
router.replace(target);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
router.push(target);
|
|
39
|
+
}
|
|
40
|
+
}, [pathname, router, pathPrefix]);
|
|
41
|
+
const value = useMemo(() => ({ locale, setLocale, locales, pathPrefix }), [locale, setLocale, locales, pathPrefix]);
|
|
42
|
+
return (_jsx(LocaleContext.Provider, { value: value, children: children }));
|
|
43
|
+
}
|
|
44
|
+
export function useLocaleClient() {
|
|
45
|
+
const ctx = useContext(LocaleContext);
|
|
46
|
+
if (!ctx) {
|
|
47
|
+
throw new Error("useLocale must be used within a LocaleProvider");
|
|
48
|
+
}
|
|
49
|
+
return ctx;
|
|
50
|
+
}
|
|
51
|
+
export function useBindLocaleClient(messages) {
|
|
52
|
+
const { locale } = useLocaleClient();
|
|
53
|
+
return useMemo(() => bindLocale(messages, locale), [messages, locale]);
|
|
54
|
+
}
|
|
55
|
+
export function ClientLocaleLink({ locale, href, children, ...rest }) {
|
|
56
|
+
const pathname = usePathname();
|
|
57
|
+
const ctx = useContext(LocaleContext);
|
|
58
|
+
const pathPrefix = ctx?.pathPrefix ?? "/";
|
|
59
|
+
const target = useMemo(() => href ?? swapLocaleInPath(pathname, locale, pathPrefix), [href, pathname, locale, pathPrefix]);
|
|
60
|
+
return (_jsx(Link, { href: target, ...rest, children: children }));
|
|
61
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ComponentProps, ReactElement, ReactNode } from "react";
|
|
2
|
+
import type Link from "next/link";
|
|
3
|
+
import { type ChainBuilder } from "../../chainBuilder.js";
|
|
4
|
+
import type { DeepLocaleConstraint, DeepUnwrap, ResolveServerLocale } from "../react/createI18nReact.js";
|
|
5
|
+
import { type SetLocaleOptions, swapLocaleInPath, type UseLocaleSource } from "./_client.js";
|
|
6
|
+
export type { ResolveServerLocale, SetLocaleOptions, UseLocaleSource };
|
|
7
|
+
export { swapLocaleInPath };
|
|
8
|
+
export declare function createParamsResolveServerLocale(paramKey: string): ResolveServerLocale;
|
|
9
|
+
export interface NextLocaleProviderProps<Locale extends string> {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
fallbackLocale?: Locale;
|
|
12
|
+
}
|
|
13
|
+
export interface LocaleLinkProps<Locale extends string> extends Omit<ComponentProps<typeof Link>, "href" | "locale"> {
|
|
14
|
+
locale: Locale;
|
|
15
|
+
href?: string;
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
export interface LocaleContextValue<Locale extends string> {
|
|
19
|
+
locale: Locale;
|
|
20
|
+
setLocale: (locale: Locale, options?: SetLocaleOptions) => void;
|
|
21
|
+
locales: readonly Locale[];
|
|
22
|
+
pathPrefix: string;
|
|
23
|
+
}
|
|
24
|
+
export type LocalePageParams<L extends readonly string[], K extends string = "locale"> = Promise<{
|
|
25
|
+
[P in K]: L[number];
|
|
26
|
+
}>;
|
|
27
|
+
export type LocalePageProps<L extends readonly string[], K extends string = "locale"> = {
|
|
28
|
+
params: LocalePageParams<L, K>;
|
|
29
|
+
};
|
|
30
|
+
export interface CreateI18nNextOptions<K extends string = string> {
|
|
31
|
+
paramKey?: K;
|
|
32
|
+
pathPrefix?: string;
|
|
33
|
+
resolveServerLocale?: ResolveServerLocale;
|
|
34
|
+
}
|
|
35
|
+
export interface I18nNextInstance<L extends readonly string[], K extends string = "locale"> {
|
|
36
|
+
locales: L;
|
|
37
|
+
paramKey: K;
|
|
38
|
+
pathPrefix: string;
|
|
39
|
+
i18n: ChainBuilder<L, {}>["add"];
|
|
40
|
+
bindLocale: {
|
|
41
|
+
<T extends object>(messages: T & DeepLocaleConstraint<T, L[number]>, locale: L[number]): DeepUnwrap<T>;
|
|
42
|
+
<T extends object>(messages: T & DeepLocaleConstraint<T, L[number]>, params: LocalePageParams<L, K>): Promise<DeepUnwrap<T>>;
|
|
43
|
+
<T extends object>(messages: T & DeepLocaleConstraint<T, L[number]>, input: unknown): Promise<DeepUnwrap<T>>;
|
|
44
|
+
};
|
|
45
|
+
generateStaticParams: () => Array<{
|
|
46
|
+
[P in K]: L[number];
|
|
47
|
+
}>;
|
|
48
|
+
LocaleProvider: (props: NextLocaleProviderProps<L[number]>) => ReactElement;
|
|
49
|
+
LocaleLink: (props: LocaleLinkProps<L[number]>) => ReactElement;
|
|
50
|
+
useLocale: () => LocaleContextValue<L[number]>;
|
|
51
|
+
useBindLocale: <T extends object>(messages: T & DeepLocaleConstraint<T, L[number]>) => DeepUnwrap<T>;
|
|
52
|
+
}
|
|
53
|
+
export declare function createI18nNext<const L extends readonly string[], const K extends string = "locale">(locales: L, options?: CreateI18nNextOptions<K>): I18nNextInstance<L, K>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { bindLocale as plainBindLocale } from "../../bindLocale.js";
|
|
3
|
+
import { createI18n } from "../../chainBuilder.js";
|
|
4
|
+
import { ClientLocaleLink, ClientLocaleProvider, swapLocaleInPath, useBindLocaleClient, useLocaleClient, } from "./_client.js";
|
|
5
|
+
export { swapLocaleInPath };
|
|
6
|
+
export function createParamsResolveServerLocale(paramKey) {
|
|
7
|
+
return async (input) => {
|
|
8
|
+
if (input == null)
|
|
9
|
+
return undefined;
|
|
10
|
+
const resolved = await input;
|
|
11
|
+
return resolved?.[paramKey];
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function createI18nNext(locales, options) {
|
|
15
|
+
const paramKey = (options?.paramKey ?? "locale");
|
|
16
|
+
const pathPrefix = options?.pathPrefix ?? "/";
|
|
17
|
+
const resolveServerLocale = options?.resolveServerLocale ?? createParamsResolveServerLocale(paramKey);
|
|
18
|
+
function LocaleProvider({ children, fallbackLocale }) {
|
|
19
|
+
return (_jsx(ClientLocaleProvider, { locales: locales, fallbackLocale: fallbackLocale, paramKey: paramKey, pathPrefix: pathPrefix, children: children }));
|
|
20
|
+
}
|
|
21
|
+
const builder = createI18n(locales);
|
|
22
|
+
const i18n = (entries) => builder.add(entries);
|
|
23
|
+
return {
|
|
24
|
+
locales,
|
|
25
|
+
paramKey,
|
|
26
|
+
pathPrefix,
|
|
27
|
+
i18n,
|
|
28
|
+
bindLocale: ((messages, input) => {
|
|
29
|
+
if (typeof input === "string") {
|
|
30
|
+
return plainBindLocale(messages, input);
|
|
31
|
+
}
|
|
32
|
+
const resolved = resolveServerLocale(input);
|
|
33
|
+
if (resolved &&
|
|
34
|
+
typeof resolved.then === "function") {
|
|
35
|
+
return resolved.then((locale) => plainBindLocale(messages, locale));
|
|
36
|
+
}
|
|
37
|
+
return plainBindLocale(messages, resolved);
|
|
38
|
+
}),
|
|
39
|
+
generateStaticParams: () => locales.map((locale) => ({ [paramKey]: locale })),
|
|
40
|
+
LocaleProvider,
|
|
41
|
+
LocaleLink: ClientLocaleLink,
|
|
42
|
+
useLocale: useLocaleClient,
|
|
43
|
+
useBindLocale: useBindLocaleClient,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { createI18nNext, createParamsResolveServerLocale, swapLocaleInPath, } from "./createI18nNext.js";
|
|
2
|
+
export type { CreateI18nNextOptions, I18nNextInstance, LocaleContextValue, LocaleLinkProps, LocalePageParams, LocalePageProps, NextLocaleProviderProps, ResolveServerLocale, SetLocaleOptions, UseLocaleSource, } from "./createI18nNext.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createI18nNext, createParamsResolveServerLocale, swapLocaleInPath, } from "./createI18nNext.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type I18nReactInstance } from "./createI18nReact.js";
|
|
2
|
+
export interface CreateCookieI18nReactOptions {
|
|
3
|
+
key?: string;
|
|
4
|
+
maxAge?: number;
|
|
5
|
+
path?: string;
|
|
6
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
7
|
+
}
|
|
8
|
+
export declare function createCookieI18nReact<const L extends readonly string[]>(locales: L, options?: CreateCookieI18nReactOptions): I18nReactInstance<L>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { createI18nReact } from "./createI18nReact.js";
|
|
3
|
+
const CHANGE_EVENT = "canopy-i18n-cookie-change";
|
|
4
|
+
const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
|
|
5
|
+
function readCookie(key) {
|
|
6
|
+
for (const c of document.cookie.split("; ")) {
|
|
7
|
+
const eq = c.indexOf("=");
|
|
8
|
+
if (eq < 0)
|
|
9
|
+
continue;
|
|
10
|
+
if (c.slice(0, eq) === key)
|
|
11
|
+
return decodeURIComponent(c.slice(eq + 1));
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
function writeCookie(key, value, options) {
|
|
16
|
+
const parts = [
|
|
17
|
+
`${key}=${encodeURIComponent(value)}`,
|
|
18
|
+
`path=${options.path ?? "/"}`,
|
|
19
|
+
`max-age=${options.maxAge ?? ONE_YEAR_SECONDS}`,
|
|
20
|
+
`SameSite=${options.sameSite ?? "Lax"}`,
|
|
21
|
+
];
|
|
22
|
+
document.cookie = parts.join("; ");
|
|
23
|
+
}
|
|
24
|
+
export function createCookieI18nReact(locales, options = {}) {
|
|
25
|
+
const key = options.key ?? "canopy-i18n-locale";
|
|
26
|
+
function read() {
|
|
27
|
+
const v = readCookie(key);
|
|
28
|
+
return v && locales.includes(v)
|
|
29
|
+
? v
|
|
30
|
+
: undefined;
|
|
31
|
+
}
|
|
32
|
+
function subscribe(callback) {
|
|
33
|
+
window.addEventListener(CHANGE_EVENT, callback);
|
|
34
|
+
return () => window.removeEventListener(CHANGE_EVENT, callback);
|
|
35
|
+
}
|
|
36
|
+
function useCookieLocale() {
|
|
37
|
+
return useSyncExternalStore(subscribe, read, () => undefined);
|
|
38
|
+
}
|
|
39
|
+
function setCookieLocale(locale) {
|
|
40
|
+
writeCookie(key, locale, options);
|
|
41
|
+
window.dispatchEvent(new Event(CHANGE_EVENT));
|
|
42
|
+
}
|
|
43
|
+
return createI18nReact(locales, {
|
|
44
|
+
useLocaleSource: useCookieLocale,
|
|
45
|
+
onLocaleChange: setCookieLocale,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { createI18nReact } from "./createI18nReact.js";
|
|
3
|
+
function subscribe(callback) {
|
|
4
|
+
window.addEventListener("hashchange", callback);
|
|
5
|
+
return () => window.removeEventListener("hashchange", callback);
|
|
6
|
+
}
|
|
7
|
+
export function createHashI18nReact(locales) {
|
|
8
|
+
function read() {
|
|
9
|
+
const hash = window.location.hash.slice(1);
|
|
10
|
+
return locales.includes(hash)
|
|
11
|
+
? hash
|
|
12
|
+
: undefined;
|
|
13
|
+
}
|
|
14
|
+
function useHashLocale() {
|
|
15
|
+
return useSyncExternalStore(subscribe, read, () => undefined);
|
|
16
|
+
}
|
|
17
|
+
function setHashLocale(locale) {
|
|
18
|
+
window.location.hash = locale;
|
|
19
|
+
}
|
|
20
|
+
return createI18nReact(locales, {
|
|
21
|
+
useLocaleSource: useHashLocale,
|
|
22
|
+
onLocaleChange: setHashLocale,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import { ChainBuilder } from "../../chainBuilder.js";
|
|
3
|
+
import { I18nMessage, type LocalizedMessage } from "../../message.js";
|
|
4
|
+
type Equal<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
5
|
+
export type DeepLocaleConstraint<T, Locale extends string> = T extends ChainBuilder<infer LL, any> ? Equal<LL[number], Locale> extends true ? T : never : T extends I18nMessage<infer LL, any> ? Equal<LL[number], Locale> extends true ? T : never : T extends (...args: any[]) => any ? T : T extends readonly any[] ? {
|
|
6
|
+
readonly [K in keyof T]: DeepLocaleConstraint<T[K], Locale>;
|
|
7
|
+
} : T extends object ? {
|
|
8
|
+
[K in keyof T]: DeepLocaleConstraint<T[K], Locale>;
|
|
9
|
+
} : T;
|
|
10
|
+
export type DeepUnwrap<T> = T extends I18nMessage<infer Ls, infer C> ? LocalizedMessage<Ls, C> : T extends ChainBuilder<infer Ls, infer Messages> ? {
|
|
11
|
+
[K in keyof Messages]: Messages[K] extends I18nMessage<Ls, infer C> ? LocalizedMessage<Ls, C> : never;
|
|
12
|
+
} : T extends readonly any[] ? {
|
|
13
|
+
[K in keyof T]: DeepUnwrap<T[K]>;
|
|
14
|
+
} : T extends object ? {
|
|
15
|
+
[K in keyof T]: DeepUnwrap<T[K]>;
|
|
16
|
+
} : T;
|
|
17
|
+
export interface LocaleContextValue<Locale extends string> {
|
|
18
|
+
locale: Locale;
|
|
19
|
+
setLocale: (locale: Locale) => void;
|
|
20
|
+
}
|
|
21
|
+
export type UseLocaleSource<Locale extends string = string> = () => Locale | undefined;
|
|
22
|
+
export type ResolveServerLocale<Locale extends string = string> = (input: unknown) => Locale | undefined | Promise<Locale | undefined>;
|
|
23
|
+
export type LocaleProviderProps<Locale extends string> = {
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
} & ({
|
|
26
|
+
defaultLocale: Locale;
|
|
27
|
+
locale?: undefined;
|
|
28
|
+
onLocaleChange?: undefined;
|
|
29
|
+
} | {
|
|
30
|
+
locale: Locale;
|
|
31
|
+
onLocaleChange: (locale: Locale) => void;
|
|
32
|
+
defaultLocale?: undefined;
|
|
33
|
+
} | {
|
|
34
|
+
defaultLocale?: undefined;
|
|
35
|
+
locale?: undefined;
|
|
36
|
+
onLocaleChange?: undefined;
|
|
37
|
+
});
|
|
38
|
+
export interface CreateI18nReactOptions<Locale extends string = string> {
|
|
39
|
+
useLocaleSource?: UseLocaleSource<Locale>;
|
|
40
|
+
onLocaleChange?: (locale: Locale) => void;
|
|
41
|
+
}
|
|
42
|
+
export interface I18nReactInstance<L extends readonly string[]> {
|
|
43
|
+
locales: L;
|
|
44
|
+
i18n: ChainBuilder<L, {}>["add"];
|
|
45
|
+
LocaleProvider: (props: LocaleProviderProps<L[number]>) => ReactElement;
|
|
46
|
+
useLocale: () => LocaleContextValue<L[number]>;
|
|
47
|
+
useBindLocale: <T extends object>(msgsDef: T & DeepLocaleConstraint<T, L[number]>) => DeepUnwrap<T>;
|
|
48
|
+
}
|
|
49
|
+
export declare function createI18nReact<const L extends readonly string[]>(locales: L, options?: CreateI18nReactOptions<L[number]>): I18nReactInstance<L>;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useMemo, useState, } from "react";
|
|
3
|
+
import { bindLocale } from "../../bindLocale.js";
|
|
4
|
+
import { createI18n } from "../../chainBuilder.js";
|
|
5
|
+
export function createI18nReact(locales, options) {
|
|
6
|
+
const Context = createContext(undefined);
|
|
7
|
+
const factorySource = options?.useLocaleSource;
|
|
8
|
+
const factoryOnChange = options?.onLocaleChange;
|
|
9
|
+
function LocaleProvider(props) {
|
|
10
|
+
const { children } = props;
|
|
11
|
+
const sourceLocale = factorySource?.();
|
|
12
|
+
const isControlled = props.locale !== undefined;
|
|
13
|
+
const [internalLocale, setInternalLocale] = useState((props.defaultLocale ?? props.locale ?? sourceLocale ?? locales[0]));
|
|
14
|
+
const locale = factorySource
|
|
15
|
+
? (sourceLocale ?? locales[0])
|
|
16
|
+
: isControlled
|
|
17
|
+
? props.locale
|
|
18
|
+
: internalLocale;
|
|
19
|
+
const setLocale = (next) => {
|
|
20
|
+
factoryOnChange?.(next);
|
|
21
|
+
if (factorySource)
|
|
22
|
+
return;
|
|
23
|
+
if (isControlled) {
|
|
24
|
+
props.onLocaleChange(next);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
setInternalLocale(next);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const value = useMemo(() => ({ locale, setLocale }),
|
|
31
|
+
// controlled では onLocaleChange の参照変化にも追従する
|
|
32
|
+
[locale, isControlled, isControlled ? props.onLocaleChange : null]);
|
|
33
|
+
return _jsx(Context.Provider, { value: value, children: children });
|
|
34
|
+
}
|
|
35
|
+
function useLocale() {
|
|
36
|
+
const ctx = useContext(Context);
|
|
37
|
+
if (!ctx) {
|
|
38
|
+
throw new Error("useLocale must be used within a LocaleProvider");
|
|
39
|
+
}
|
|
40
|
+
return ctx;
|
|
41
|
+
}
|
|
42
|
+
function useBindLocale(msgsDef) {
|
|
43
|
+
const { locale } = useLocale();
|
|
44
|
+
return useMemo(() => bindLocale(msgsDef, locale), [msgsDef, locale]);
|
|
45
|
+
}
|
|
46
|
+
const builder = createI18n(locales);
|
|
47
|
+
const i18n = (entries) => builder.add(entries);
|
|
48
|
+
return {
|
|
49
|
+
locales,
|
|
50
|
+
i18n,
|
|
51
|
+
LocaleProvider,
|
|
52
|
+
useLocale,
|
|
53
|
+
useBindLocale,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type I18nReactInstance } from "./createI18nReact.js";
|
|
2
|
+
export interface CreatePathnameI18nReactOptions {
|
|
3
|
+
basePath?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function createPathnameI18nReact<const L extends readonly string[]>(locales: L, options?: CreatePathnameI18nReactOptions): I18nReactInstance<L>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { createI18nReact } from "./createI18nReact.js";
|
|
3
|
+
const CHANGE_EVENT = "canopy-i18n-pathname-change";
|
|
4
|
+
function subscribe(callback) {
|
|
5
|
+
window.addEventListener("popstate", callback);
|
|
6
|
+
window.addEventListener(CHANGE_EVENT, callback);
|
|
7
|
+
return () => {
|
|
8
|
+
window.removeEventListener("popstate", callback);
|
|
9
|
+
window.removeEventListener(CHANGE_EVENT, callback);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function createPathnameI18nReact(locales, options = {}) {
|
|
13
|
+
const basePath = (options.basePath ?? "").replace(/\/$/, "");
|
|
14
|
+
function getSegments() {
|
|
15
|
+
const path = window.location.pathname;
|
|
16
|
+
const matchesBase = !basePath || path === basePath || path.startsWith(`${basePath}/`);
|
|
17
|
+
const stripped = matchesBase ? path.slice(basePath.length) : path;
|
|
18
|
+
return stripped.split("/").filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
function read() {
|
|
21
|
+
const seg = getSegments()[0];
|
|
22
|
+
return seg && locales.includes(seg)
|
|
23
|
+
? seg
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
26
|
+
function usePathnameLocale() {
|
|
27
|
+
return useSyncExternalStore(subscribe, read, () => undefined);
|
|
28
|
+
}
|
|
29
|
+
function setPathnameLocale(locale) {
|
|
30
|
+
const segments = getSegments();
|
|
31
|
+
const head = segments[0];
|
|
32
|
+
const replaced = head && locales.includes(head)
|
|
33
|
+
? [locale, ...segments.slice(1)]
|
|
34
|
+
: [locale, ...segments];
|
|
35
|
+
const newPath = `${basePath}/${replaced.join("/")}`;
|
|
36
|
+
const url = new URL(window.location.href);
|
|
37
|
+
url.pathname = newPath;
|
|
38
|
+
window.history.pushState({}, "", url);
|
|
39
|
+
window.dispatchEvent(new Event(CHANGE_EVENT));
|
|
40
|
+
}
|
|
41
|
+
return createI18nReact(locales, {
|
|
42
|
+
useLocaleSource: usePathnameLocale,
|
|
43
|
+
onLocaleChange: setPathnameLocale,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type I18nReactInstance } from "./createI18nReact.js";
|
|
2
|
+
export interface CreateSearchI18nReactOptions {
|
|
3
|
+
param?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function createSearchI18nReact<const L extends readonly string[]>(locales: L, options?: CreateSearchI18nReactOptions): I18nReactInstance<L>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { createI18nReact } from "./createI18nReact.js";
|
|
3
|
+
const CHANGE_EVENT = "canopy-i18n-search-change";
|
|
4
|
+
function subscribe(callback) {
|
|
5
|
+
window.addEventListener("popstate", callback);
|
|
6
|
+
window.addEventListener(CHANGE_EVENT, callback);
|
|
7
|
+
return () => {
|
|
8
|
+
window.removeEventListener("popstate", callback);
|
|
9
|
+
window.removeEventListener(CHANGE_EVENT, callback);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function createSearchI18nReact(locales, options = {}) {
|
|
13
|
+
const param = options.param ?? "lang";
|
|
14
|
+
function read() {
|
|
15
|
+
const v = new URLSearchParams(window.location.search).get(param);
|
|
16
|
+
return v && locales.includes(v)
|
|
17
|
+
? v
|
|
18
|
+
: undefined;
|
|
19
|
+
}
|
|
20
|
+
function useSearchLocale() {
|
|
21
|
+
return useSyncExternalStore(subscribe, read, () => undefined);
|
|
22
|
+
}
|
|
23
|
+
function setSearchLocale(locale) {
|
|
24
|
+
const url = new URL(window.location.href);
|
|
25
|
+
url.searchParams.set(param, locale);
|
|
26
|
+
window.history.pushState({}, "", url);
|
|
27
|
+
window.dispatchEvent(new Event(CHANGE_EVENT));
|
|
28
|
+
}
|
|
29
|
+
return createI18nReact(locales, {
|
|
30
|
+
useLocaleSource: useSearchLocale,
|
|
31
|
+
onLocaleChange: setSearchLocale,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type I18nReactInstance } from "./createI18nReact.js";
|
|
2
|
+
export interface CreateStorageI18nReactOptions {
|
|
3
|
+
key?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function createStorageI18nReact<const L extends readonly string[]>(locales: L, options?: CreateStorageI18nReactOptions): I18nReactInstance<L>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { createI18nReact } from "./createI18nReact.js";
|
|
3
|
+
export function createStorageI18nReact(locales, options = {}) {
|
|
4
|
+
const key = options.key ?? "canopy-i18n-locale";
|
|
5
|
+
const listeners = new Set();
|
|
6
|
+
function read() {
|
|
7
|
+
const v = localStorage.getItem(key);
|
|
8
|
+
return v && locales.includes(v)
|
|
9
|
+
? v
|
|
10
|
+
: undefined;
|
|
11
|
+
}
|
|
12
|
+
function subscribe(callback) {
|
|
13
|
+
listeners.add(callback);
|
|
14
|
+
const onStorage = (e) => {
|
|
15
|
+
if (e.key === key)
|
|
16
|
+
callback();
|
|
17
|
+
};
|
|
18
|
+
window.addEventListener("storage", onStorage);
|
|
19
|
+
return () => {
|
|
20
|
+
listeners.delete(callback);
|
|
21
|
+
window.removeEventListener("storage", onStorage);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function useStorageLocale() {
|
|
25
|
+
return useSyncExternalStore(subscribe, read, () => undefined);
|
|
26
|
+
}
|
|
27
|
+
function setStorageLocale(locale) {
|
|
28
|
+
localStorage.setItem(key, locale);
|
|
29
|
+
listeners.forEach((l) => l());
|
|
30
|
+
}
|
|
31
|
+
return createI18nReact(locales, {
|
|
32
|
+
useLocaleSource: useStorageLocale,
|
|
33
|
+
onLocaleChange: setStorageLocale,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createI18nReact } from "./createI18nReact.js";
|
|
2
|
+
export type { CreateI18nReactOptions, LocaleContextValue, LocaleProviderProps, ResolveServerLocale, UseLocaleSource, } from "./createI18nReact.js";
|
|
3
|
+
export { createCookieI18nReact } from "./createCookieI18nReact.js";
|
|
4
|
+
export type { CreateCookieI18nReactOptions } from "./createCookieI18nReact.js";
|
|
5
|
+
export { createHashI18nReact } from "./createHashI18nReact.js";
|
|
6
|
+
export { createPathnameI18nReact } from "./createPathnameI18nReact.js";
|
|
7
|
+
export type { CreatePathnameI18nReactOptions } from "./createPathnameI18nReact.js";
|
|
8
|
+
export { createSearchI18nReact } from "./createSearchI18nReact.js";
|
|
9
|
+
export type { CreateSearchI18nReactOptions } from "./createSearchI18nReact.js";
|
|
10
|
+
export { createStorageI18nReact } from "./createStorageI18nReact.js";
|
|
11
|
+
export type { CreateStorageI18nReactOptions } from "./createStorageI18nReact.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createI18nReact } from "./createI18nReact.js";
|
|
2
|
+
export { createCookieI18nReact } from "./createCookieI18nReact.js";
|
|
3
|
+
export { createHashI18nReact } from "./createHashI18nReact.js";
|
|
4
|
+
export { createPathnameI18nReact } from "./createPathnameI18nReact.js";
|
|
5
|
+
export { createSearchI18nReact } from "./createSearchI18nReact.js";
|
|
6
|
+
export { createStorageI18nReact } from "./createStorageI18nReact.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canopy-i18n",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The Type-Safe i18n library that your IDE will Love",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -10,6 +10,28 @@
|
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.js",
|
|
12
12
|
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./react": {
|
|
15
|
+
"types": "./dist/adaptors/react/index.d.ts",
|
|
16
|
+
"import": "./dist/adaptors/react/index.js",
|
|
17
|
+
"default": "./dist/adaptors/react/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./unstable_next": {
|
|
20
|
+
"types": "./dist/adaptors/next/index.d.ts",
|
|
21
|
+
"import": "./dist/adaptors/next/index.js",
|
|
22
|
+
"default": "./dist/adaptors/next/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"next": ">=14",
|
|
27
|
+
"react": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"next": {
|
|
31
|
+
"optional": true
|
|
32
|
+
},
|
|
33
|
+
"react": {
|
|
34
|
+
"optional": true
|
|
13
35
|
}
|
|
14
36
|
},
|
|
15
37
|
"sideEffects": false,
|
|
@@ -23,16 +45,18 @@
|
|
|
23
45
|
"scripts": {
|
|
24
46
|
"dev": "tsc --watch",
|
|
25
47
|
"build": "tsc",
|
|
26
|
-
"prepublishOnly": "
|
|
48
|
+
"prepublishOnly": "bun run build",
|
|
27
49
|
"type-check": "tsc -p . --noEmit",
|
|
28
50
|
"test": "vitest run",
|
|
29
51
|
"test:watch": "vitest",
|
|
30
|
-
"
|
|
52
|
+
"_publish": "npm publish && git push --follow-tags && gh release create \"$(node -p \"require('./package.json').version\")\" --generate-notes"
|
|
31
53
|
},
|
|
32
54
|
"devDependencies": {
|
|
33
55
|
"@tsconfig/node20": "^20.1.9",
|
|
34
56
|
"@types/node": "^25.3.0",
|
|
35
|
-
"
|
|
57
|
+
"@types/react": "^19.2.14",
|
|
58
|
+
"next": "16",
|
|
59
|
+
"react": "^19.2.6",
|
|
36
60
|
"typescript": "^5.9.3",
|
|
37
61
|
"vitest": "^4.0.18"
|
|
38
62
|
},
|
package/skills/SKILL.md
CHANGED
|
@@ -21,18 +21,9 @@ A type-safe i18n library using the builder pattern. This reference helps AI assi
|
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
npm install canopy-i18n
|
|
24
|
-
# or
|
|
25
|
-
pnpm add canopy-i18n
|
|
26
|
-
bun add canopy-i18n
|
|
27
24
|
```
|
|
28
25
|
|
|
29
|
-
`package.json` must include `"type": "module"
|
|
30
|
-
|
|
31
|
-
```json
|
|
32
|
-
{
|
|
33
|
-
"type": "module"
|
|
34
|
-
}
|
|
35
|
-
```
|
|
26
|
+
`package.json` must include `"type": "module"`.
|
|
36
27
|
|
|
37
28
|
---
|
|
38
29
|
|
|
@@ -40,213 +31,71 @@ bun add canopy-i18n
|
|
|
40
31
|
|
|
41
32
|
### `createI18n(locales)`
|
|
42
33
|
|
|
43
|
-
Creates a builder
|
|
34
|
+
Creates a builder. **`as const` is required** for type inference.
|
|
44
35
|
|
|
45
36
|
```ts
|
|
46
37
|
import { createI18n } from 'canopy-i18n';
|
|
47
38
|
|
|
48
|
-
// ✅ Correct: use as const
|
|
49
39
|
const builder = createI18n(['en', 'ja'] as const);
|
|
50
|
-
|
|
51
|
-
// ❌ Wrong: without as const, type becomes string[] and type inference is lost
|
|
52
|
-
const builder = createI18n(['en', 'ja']);
|
|
53
40
|
```
|
|
54
41
|
|
|
55
|
-
- **Argument**: `readonly string[]` — allowed locale keys
|
|
56
|
-
- **Returns**: `ChainBuilder<Locales, {}>` — a chain builder instance
|
|
57
|
-
|
|
58
|
-
---
|
|
59
|
-
|
|
60
42
|
### `.add(entries)`
|
|
61
43
|
|
|
62
|
-
Adds
|
|
44
|
+
Adds messages. Each entry can be a static locale record or a template function. Static and template can be mixed in a single `.add()`.
|
|
63
45
|
|
|
64
46
|
```ts
|
|
65
|
-
// Static messages
|
|
66
47
|
const builder = createI18n(['en', 'ja'] as const)
|
|
67
48
|
.add({
|
|
68
49
|
title: { en: 'Title', ja: 'タイトル' },
|
|
69
|
-
greeting: { en: 'Hello', ja: 'こんにちは' },
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// Template functions
|
|
73
|
-
const builder2 = createI18n(['en', 'ja'] as const)
|
|
74
|
-
.add({
|
|
75
50
|
greeting: (ctx: { name: string; age: number }) => ({
|
|
76
51
|
en: `Hello, ${ctx.name}. You are ${ctx.age}.`,
|
|
77
52
|
ja: `こんにちは、${ctx.name}さん。${ctx.age}歳です。`,
|
|
78
53
|
}),
|
|
79
54
|
});
|
|
80
|
-
|
|
81
|
-
// Mixing static and template messages in a single add()
|
|
82
|
-
const builder3 = createI18n(['en', 'ja'] as const)
|
|
83
|
-
.add({
|
|
84
|
-
title: { en: 'Title', ja: 'タイトル' },
|
|
85
|
-
greeting: (ctx: { name: string }) => ({
|
|
86
|
-
en: `Hello, ${ctx.name}`,
|
|
87
|
-
ja: `こんにちは、${ctx.name}さん`,
|
|
88
|
-
}),
|
|
89
|
-
});
|
|
90
55
|
```
|
|
91
56
|
|
|
92
|
-
|
|
93
|
-
- **Returns**: new `ChainBuilder` (immutable)
|
|
94
|
-
|
|
95
|
-
---
|
|
57
|
+
Returns a new `ChainBuilder` (immutable).
|
|
96
58
|
|
|
97
59
|
### `.build(locale)`
|
|
98
60
|
|
|
99
|
-
Builds the final messages object.
|
|
61
|
+
Builds the final messages object. Does not mutate the builder — you can build multiple locales from one builder. All messages are called as functions.
|
|
100
62
|
|
|
101
63
|
```ts
|
|
102
|
-
const builder = createI18n(['en', 'ja'] as const)
|
|
103
|
-
.add({ title: { en: 'Title', ja: 'タイトル' } });
|
|
104
|
-
|
|
105
64
|
const enMessages = builder.build('en');
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// All messages are called as functions
|
|
109
|
-
console.log(enMessages.title()); // "Title"
|
|
110
|
-
console.log(jaMessages.title()); // "タイトル"
|
|
65
|
+
console.log(enMessages.title()); // "Title"
|
|
66
|
+
console.log(enMessages.greeting({ name: 'Taro', age: 25 })); // "Hello, Taro. You are 25."
|
|
111
67
|
```
|
|
112
68
|
|
|
113
|
-
- **Argument `locale`**: required
|
|
114
|
-
- **Returns**: `{ [key]: () => R }` or `{ [key]: (ctx: C) => R }`
|
|
115
|
-
- **Immutable**: `.build()` does not mutate the builder — you can generate multiple locales from one builder
|
|
116
|
-
|
|
117
|
-
---
|
|
118
|
-
|
|
119
69
|
### `bindLocale(obj, locale)`
|
|
120
70
|
|
|
121
|
-
Recursively traverses an object/array and calls `.build(locale)` on
|
|
71
|
+
Recursively traverses an object/array and calls `.build(locale)` on every `ChainBuilder` it finds. Used for the namespace pattern.
|
|
122
72
|
|
|
123
73
|
```ts
|
|
124
74
|
import { bindLocale } from 'canopy-i18n';
|
|
125
75
|
|
|
126
|
-
const data = {
|
|
127
|
-
common: commonBuilder,
|
|
128
|
-
nested: {
|
|
129
|
-
user: userBuilder,
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
|
|
76
|
+
const data = { common: commonBuilder, user: userBuilder };
|
|
133
77
|
const messages = bindLocale(data, 'en');
|
|
134
|
-
console.log(messages.common.hello());
|
|
135
|
-
console.log(messages.nested.user.welcome({ name: 'John' })); // "Welcome, John"
|
|
78
|
+
console.log(messages.common.hello());
|
|
136
79
|
```
|
|
137
80
|
|
|
138
|
-
- **Argument `obj`**: any object/array containing `ChainBuilder` instances
|
|
139
|
-
- **Argument `locale`**: locale string to apply
|
|
140
|
-
- **Returns**: new structure with all builders resolved
|
|
141
|
-
|
|
142
81
|
---
|
|
143
82
|
|
|
144
83
|
## Critical Gotchas
|
|
145
84
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// ❌ Type error — locale keys become string, inference breaks
|
|
153
|
-
createI18n(['en', 'ja'])
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
### 2. `.build()` is immutable
|
|
157
|
-
|
|
158
|
-
```ts
|
|
159
|
-
const builder = createI18n(['en', 'ja'] as const).add({ ... });
|
|
160
|
-
|
|
161
|
-
// ✅ Multiple locales from one builder
|
|
162
|
-
const enMessages = builder.build('en');
|
|
163
|
-
const jaMessages = builder.build('ja');
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
### 3. ESM only
|
|
167
|
-
|
|
168
|
-
```json
|
|
169
|
-
// Required in package.json
|
|
170
|
-
{ "type": "module" }
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
### 4. All messages must be called as functions
|
|
174
|
-
|
|
175
|
-
```ts
|
|
176
|
-
const m = builder.build('en');
|
|
177
|
-
|
|
178
|
-
// ✅ Call as a function
|
|
179
|
-
m.title()
|
|
180
|
-
m.greeting({ name: 'Alice' })
|
|
181
|
-
|
|
182
|
-
// ❌ Do not access as property — it is a function object, not a string
|
|
183
|
-
m.title
|
|
184
|
-
```
|
|
85
|
+
| Mistake | Fix |
|
|
86
|
+
|---------|-----|
|
|
87
|
+
| `createI18n(['en', 'ja'])` | `createI18n(['en', 'ja'] as const)` — without `as const`, locale keys become `string` and inference breaks |
|
|
88
|
+
| `messages.title` | `messages.title()` — all messages are functions, not strings |
|
|
89
|
+
| Mutating builder via `build()` | `.build()` is immutable; build multiple locales from one builder |
|
|
90
|
+
| CommonJS `require()` | ESM only; use `import` |
|
|
185
91
|
|
|
186
92
|
---
|
|
187
93
|
|
|
188
|
-
##
|
|
189
|
-
|
|
190
|
-
### Basic String Messages
|
|
191
|
-
|
|
192
|
-
```ts
|
|
193
|
-
import { createI18n } from 'canopy-i18n';
|
|
194
|
-
|
|
195
|
-
const messages = createI18n(['en', 'ja'] as const)
|
|
196
|
-
.add({
|
|
197
|
-
title: { en: 'Title', ja: 'タイトル' },
|
|
198
|
-
greeting: { en: 'Hello', ja: 'こんにちは' },
|
|
199
|
-
farewell: { en: 'Goodbye', ja: 'さようなら' },
|
|
200
|
-
})
|
|
201
|
-
.build('en');
|
|
202
|
-
|
|
203
|
-
console.log(messages.title()); // "Title"
|
|
204
|
-
console.log(messages.greeting()); // "Hello"
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
### Template Functions (Variable Interpolation)
|
|
208
|
-
|
|
209
|
-
```ts
|
|
210
|
-
import { createI18n } from 'canopy-i18n';
|
|
211
|
-
|
|
212
|
-
const messages = createI18n(['en', 'ja'] as const)
|
|
213
|
-
.add({
|
|
214
|
-
profile: (ctx: { name: string; age: number }) => ({
|
|
215
|
-
en: `Name: ${ctx.name}, Age: ${ctx.age}`,
|
|
216
|
-
ja: `名前: ${ctx.name}、年齢: ${ctx.age}歳`,
|
|
217
|
-
}),
|
|
218
|
-
})
|
|
219
|
-
.build('en');
|
|
220
|
-
|
|
221
|
-
console.log(messages.profile({ name: 'Taro', age: 25 }));
|
|
222
|
-
// "Name: Taro, Age: 25"
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
### Mixing Static and Template Messages
|
|
226
|
-
|
|
227
|
-
```ts
|
|
228
|
-
import { createI18n } from 'canopy-i18n';
|
|
229
|
-
|
|
230
|
-
const messages = createI18n(['en', 'ja'] as const)
|
|
231
|
-
.add({
|
|
232
|
-
title: { en: 'Items', ja: 'アイテム' },
|
|
233
|
-
count: (ctx: { count: number }) => ({
|
|
234
|
-
en: `${ctx.count} items`,
|
|
235
|
-
ja: `${ctx.count}個のアイテム`,
|
|
236
|
-
}),
|
|
237
|
-
})
|
|
238
|
-
.build('en');
|
|
239
|
-
|
|
240
|
-
console.log(messages.title()); // "Items"
|
|
241
|
-
console.log(messages.count({ count: 5 })); // "5 items"
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
### Namespace Pattern (Split Files + bindLocale)
|
|
94
|
+
## Namespace Pattern (Split Files + bindLocale)
|
|
245
95
|
|
|
246
96
|
```ts
|
|
247
97
|
// i18n/locales.ts
|
|
248
98
|
export const LOCALES = ['en', 'ja'] as const;
|
|
249
|
-
export type Locale = (typeof LOCALES)[number];
|
|
250
99
|
|
|
251
100
|
// i18n/common.ts
|
|
252
101
|
import { createI18n } from 'canopy-i18n';
|
|
@@ -254,223 +103,143 @@ import { LOCALES } from './locales';
|
|
|
254
103
|
|
|
255
104
|
export const common = createI18n(LOCALES).add({
|
|
256
105
|
hello: { en: 'Hello', ja: 'こんにちは' },
|
|
257
|
-
goodbye: { en: 'Goodbye', ja: 'さようなら' },
|
|
258
106
|
});
|
|
259
107
|
|
|
260
108
|
// i18n/user.ts
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
en: `Welcome, ${ctx.name}`,
|
|
268
|
-
ja: `ようこそ、${ctx.name}さん`,
|
|
269
|
-
}),
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
// i18n/index.ts
|
|
273
|
-
export { common } from './common';
|
|
274
|
-
export { user } from './user';
|
|
109
|
+
export const user = createI18n(LOCALES).add({
|
|
110
|
+
welcome: (ctx: { name: string }) => ({
|
|
111
|
+
en: `Welcome, ${ctx.name}`,
|
|
112
|
+
ja: `ようこそ、${ctx.name}さん`,
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
275
115
|
|
|
276
116
|
// app.ts
|
|
277
117
|
import { bindLocale } from 'canopy-i18n';
|
|
278
118
|
import * as i18n from './i18n';
|
|
279
119
|
|
|
280
120
|
const messages = bindLocale(i18n, 'en');
|
|
281
|
-
console.log(messages.common.hello());
|
|
282
|
-
console.log(messages.user.welcome({ name: 'John' }));
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
### Deep Nested Structures
|
|
286
|
-
|
|
287
|
-
```ts
|
|
288
|
-
import { createI18n, bindLocale } from 'canopy-i18n';
|
|
289
|
-
|
|
290
|
-
const structure = {
|
|
291
|
-
header: createI18n(['en', 'ja'] as const)
|
|
292
|
-
.add({ title: { en: 'Header', ja: 'ヘッダー' } }),
|
|
293
|
-
content: {
|
|
294
|
-
main: createI18n(['en', 'ja'] as const)
|
|
295
|
-
.add({ body: { en: 'Body', ja: '本文' } }),
|
|
296
|
-
sidebar: createI18n(['en', 'ja'] as const)
|
|
297
|
-
.add({ widget: { en: 'Widget', ja: 'ウィジェット' } }),
|
|
298
|
-
},
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
const localized = bindLocale(structure, 'en');
|
|
302
|
-
console.log(localized.header.title()); // "Header"
|
|
303
|
-
console.log(localized.content.main.body()); // "Body"
|
|
304
|
-
console.log(localized.content.sidebar.widget()); // "Widget"
|
|
121
|
+
console.log(messages.common.hello());
|
|
122
|
+
console.log(messages.user.welcome({ name: 'John' }));
|
|
305
123
|
```
|
|
306
124
|
|
|
307
125
|
---
|
|
308
126
|
|
|
309
127
|
## React Integration
|
|
310
128
|
|
|
311
|
-
|
|
129
|
+
`canopy-i18n/react` exposes `createI18nReact(LOCALES)`, returning a Provider, hooks, and a pre-bound `i18n` shorthand.
|
|
130
|
+
|
|
131
|
+
### Setup
|
|
312
132
|
|
|
313
133
|
```tsx
|
|
314
|
-
//
|
|
315
|
-
import {
|
|
316
|
-
import { createContext, useContext, useState } from 'react';
|
|
134
|
+
// i18n.ts
|
|
135
|
+
import { createI18nReact } from 'canopy-i18n/react';
|
|
317
136
|
|
|
318
|
-
|
|
137
|
+
export const LOCALES = ['en', 'ja'] as const;
|
|
319
138
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
setLocale: (locale: Locale) => void;
|
|
323
|
-
};
|
|
139
|
+
export const { i18n, LocaleProvider, useLocale, useBindLocale } =
|
|
140
|
+
createI18nReact(LOCALES);
|
|
324
141
|
|
|
325
|
-
|
|
142
|
+
// `i18n(...)` is `ChainBuilder.add(...)` pre-bound to LOCALES.
|
|
143
|
+
export const appI18n = i18n({
|
|
144
|
+
title: { en: 'My App', ja: 'マイアプリ' },
|
|
145
|
+
greeting: (ctx: { name: string }) => ({
|
|
146
|
+
en: `Hello, ${ctx.name}!`,
|
|
147
|
+
ja: `こんにちは、${ctx.name}さん!`,
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
```
|
|
326
151
|
|
|
327
|
-
|
|
328
|
-
const [locale, setLocale] = useState<Locale>('en');
|
|
329
|
-
return (
|
|
330
|
-
<LocaleContext.Provider value={{ locale, setLocale }}>
|
|
331
|
-
{children}
|
|
332
|
-
</LocaleContext.Provider>
|
|
333
|
-
);
|
|
334
|
-
}
|
|
152
|
+
### Provider — three modes
|
|
335
153
|
|
|
336
|
-
|
|
337
|
-
const ctx = useContext(LocaleContext);
|
|
338
|
-
if (!ctx) throw new Error('useLocale must be used within a LocaleProvider');
|
|
339
|
-
return ctx;
|
|
340
|
-
}
|
|
154
|
+
**Uncontrolled** (in-memory, no persistence):
|
|
341
155
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
156
|
+
```tsx
|
|
157
|
+
<LocaleProvider defaultLocale="en">
|
|
158
|
+
<App />
|
|
159
|
+
</LocaleProvider>
|
|
347
160
|
```
|
|
348
161
|
|
|
349
|
-
|
|
162
|
+
**Controlled** (locale lives outside React):
|
|
350
163
|
|
|
351
164
|
```tsx
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
export const defineMessage = () => createI18n(LOCALES);
|
|
357
|
-
|
|
358
|
-
export const appI18n = defineMessage()
|
|
359
|
-
.add({
|
|
360
|
-
title: { en: 'My App', ja: 'マイアプリ' },
|
|
361
|
-
description: { en: 'Welcome!', ja: 'ようこそ!' },
|
|
362
|
-
greeting: (ctx: { name: string }) => ({
|
|
363
|
-
en: `Hello, ${ctx.name}!`,
|
|
364
|
-
ja: `こんにちは、${ctx.name}さん!`,
|
|
365
|
-
}),
|
|
366
|
-
});
|
|
165
|
+
<LocaleProvider locale={currentLocale} onLocaleChange={setCurrentLocale}>
|
|
166
|
+
<App />
|
|
167
|
+
</LocaleProvider>
|
|
168
|
+
```
|
|
367
169
|
|
|
368
|
-
|
|
369
|
-
import { useBindLocale } from './LocaleContext';
|
|
370
|
-
import { appI18n } from './i18n';
|
|
170
|
+
**Source-driven** (factory option `useLocaleSource`). The Provider reads locale from the hook on every render; `setLocale` calls `onLocaleChange`.
|
|
371
171
|
|
|
372
|
-
|
|
373
|
-
|
|
172
|
+
```tsx
|
|
173
|
+
export const { LocaleProvider, useLocale } = createI18nReact(LOCALES, {
|
|
174
|
+
useLocaleSource: () => useMyStore((s) => s.locale),
|
|
175
|
+
onLocaleChange: (l) => useMyStore.getState().setLocale(l),
|
|
176
|
+
});
|
|
374
177
|
|
|
375
|
-
|
|
376
|
-
<div>
|
|
377
|
-
<h1>{m.title()}</h1>
|
|
378
|
-
<p>{m.description()}</p>
|
|
379
|
-
<p>{m.greeting({ name: 'Taro' })}</p>
|
|
380
|
-
</div>
|
|
381
|
-
);
|
|
382
|
-
}
|
|
178
|
+
<LocaleProvider><App /></LocaleProvider>
|
|
383
179
|
```
|
|
384
180
|
|
|
385
|
-
###
|
|
181
|
+
### Built-in source wrappers
|
|
386
182
|
|
|
387
|
-
|
|
388
|
-
// ProfileCard.tsx — define and use i18n in the same file
|
|
389
|
-
import { createI18n } from 'canopy-i18n';
|
|
390
|
-
import type { JSX } from 'react';
|
|
391
|
-
import { useBindLocale } from './LocaleContext';
|
|
392
|
-
|
|
393
|
-
const profileI18n = createI18n(['en', 'ja'] as const)
|
|
394
|
-
.add({
|
|
395
|
-
title: { en: 'User Profile', ja: 'ユーザープロフィール' },
|
|
396
|
-
editButton: { en: 'Edit Profile', ja: 'プロフィール編集' },
|
|
397
|
-
greeting: (ctx: { name: string }) => ({
|
|
398
|
-
en: `Welcome, ${ctx.name}!`,
|
|
399
|
-
ja: `ようこそ、${ctx.name}さん!`,
|
|
400
|
-
}),
|
|
401
|
-
});
|
|
183
|
+
Ready-made factories for common sources. Each returns the same shape as `createI18nReact` and operates in source-driven mode (`<LocaleProvider>` with no props).
|
|
402
184
|
|
|
403
|
-
|
|
404
|
-
|
|
185
|
+
```tsx
|
|
186
|
+
import {
|
|
187
|
+
createHashI18nReact, // URL hash (#ja)
|
|
188
|
+
createSearchI18nReact, // URL search param (?lang=ja)
|
|
189
|
+
createPathnameI18nReact, // URL pathname prefix (/ja/...)
|
|
190
|
+
createStorageI18nReact, // localStorage
|
|
191
|
+
createCookieI18nReact, // Cookie
|
|
192
|
+
} from 'canopy-i18n/react';
|
|
405
193
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
<h2>{m.title()}</h2>
|
|
409
|
-
<p>{m.greeting({ name })}</p>
|
|
410
|
-
<button>{m.editButton()}</button>
|
|
411
|
-
</div>
|
|
412
|
-
);
|
|
413
|
-
}
|
|
194
|
+
export const { LocaleProvider, useLocale, useBindLocale } =
|
|
195
|
+
createHashI18nReact(LOCALES);
|
|
414
196
|
```
|
|
415
197
|
|
|
416
|
-
|
|
198
|
+
Options:
|
|
199
|
+
- `createSearchI18nReact(LOCALES, { param })` — defaults to `lang`
|
|
200
|
+
- `createPathnameI18nReact(LOCALES, { basePath })` — defaults to `""`
|
|
201
|
+
- `createStorageI18nReact(LOCALES, { key })` — defaults to `canopy-i18n-locale`
|
|
202
|
+
- `createCookieI18nReact(LOCALES, { key, maxAge, path, sameSite })` — defaults to `canopy-i18n-locale` / 1 year / `/` / `Lax`
|
|
203
|
+
|
|
204
|
+
### Components
|
|
417
205
|
|
|
418
206
|
```tsx
|
|
419
|
-
|
|
420
|
-
import { useLocale } from './LocaleContext';
|
|
207
|
+
import { appI18n, useBindLocale, useLocale } from './i18n';
|
|
421
208
|
|
|
422
|
-
export function
|
|
209
|
+
export default function App() {
|
|
210
|
+
const m = useBindLocale({ appI18n });
|
|
423
211
|
const { locale, setLocale } = useLocale();
|
|
424
|
-
|
|
425
212
|
return (
|
|
426
213
|
<div>
|
|
427
|
-
<
|
|
428
|
-
<
|
|
214
|
+
<h1>{m.appI18n.title()}</h1>
|
|
215
|
+
<p>{m.appI18n.greeting({ name: 'Taro' })}</p>
|
|
216
|
+
<button onClick={() => setLocale(locale === 'en' ? 'ja' : 'en')}>{locale}</button>
|
|
429
217
|
</div>
|
|
430
218
|
);
|
|
431
219
|
}
|
|
432
220
|
```
|
|
433
221
|
|
|
434
|
-
|
|
222
|
+
`useBindLocale(msgsDef)` is memoized per `(msgsDef, locale)`. The `Locale` of every nested `ChainBuilder` must match the Provider's `LOCALES`; mismatches fail at compile time.
|
|
435
223
|
|
|
436
|
-
|
|
224
|
+
React is a `peerDependency` (`>=18`).
|
|
437
225
|
|
|
438
|
-
|
|
439
|
-
// Functions & Classes
|
|
440
|
-
export { createI18n } from 'canopy-i18n'; // create a builder
|
|
441
|
-
export { ChainBuilder } from 'canopy-i18n'; // builder class
|
|
442
|
-
export { I18nMessage } from 'canopy-i18n'; // message class
|
|
443
|
-
export { isI18nMessage } from 'canopy-i18n'; // type guard
|
|
444
|
-
export { bindLocale } from 'canopy-i18n'; // apply locale to nested structure
|
|
445
|
-
export { isChainBuilder } from 'canopy-i18n'; // type guard
|
|
446
|
-
|
|
447
|
-
// Types
|
|
448
|
-
export type { Template } from 'canopy-i18n'; // R | ((ctx: C) => R)
|
|
449
|
-
export type { LocalizedMessage } from 'canopy-i18n'; // built message function type
|
|
450
|
-
```
|
|
226
|
+
---
|
|
451
227
|
|
|
452
|
-
|
|
228
|
+
## Exports
|
|
453
229
|
|
|
454
230
|
```ts
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
231
|
+
// Core
|
|
232
|
+
export { createI18n, ChainBuilder, bindLocale } from 'canopy-i18n';
|
|
233
|
+
export { I18nMessage, isI18nMessage, isChainBuilder } from 'canopy-i18n';
|
|
234
|
+
export type { Template, LocalizedMessage } from 'canopy-i18n';
|
|
235
|
+
|
|
236
|
+
// React subpath
|
|
237
|
+
export {
|
|
238
|
+
createI18nReact,
|
|
239
|
+
createHashI18nReact,
|
|
240
|
+
createSearchI18nReact,
|
|
241
|
+
createPathnameI18nReact,
|
|
242
|
+
createStorageI18nReact,
|
|
243
|
+
createCookieI18nReact,
|
|
244
|
+
} from 'canopy-i18n/react';
|
|
465
245
|
```
|
|
466
|
-
|
|
467
|
-
---
|
|
468
|
-
|
|
469
|
-
## Common Mistakes
|
|
470
|
-
|
|
471
|
-
| Mistake | Fix |
|
|
472
|
-
|---------|-----|
|
|
473
|
-
| `createI18n(['en', 'ja'])` | `createI18n(['en', 'ja'] as const)` |
|
|
474
|
-
| `messages.title` | `messages.title()` (call as function) |
|
|
475
|
-
| CommonJS `require()` | Use ESM `import` |
|
|
476
|
-
| Typo in locale key | TypeScript catches it at compile time |
|