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 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
- ![スクリーンショット](https://raw.githubusercontent.com/mohhh-ok/canopy-i18n/main/docs/images/hero.gif)
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,2 @@
1
+ import { type I18nReactInstance } from "./createI18nReact.js";
2
+ export declare function createHashI18nReact<const L extends readonly string[]>(locales: L): I18nReactInstance<L>;
@@ -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.7.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": "pnpm run build",
48
+ "prepublishOnly": "bun run build",
27
49
  "type-check": "tsc -p . --noEmit",
28
50
  "test": "vitest run",
29
51
  "test:watch": "vitest",
30
- "release": "release-it"
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
- "release-it": "^19.2.4",
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 instance. **`as const` is required** for type inference.
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 multiple messages at once. Each entry can be a static locale record or a template function.
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
- - **entries**: `Record<K, Record<Locale, string> | ((ctx: C) => Record<Locale, string>)>`
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
- const jaMessages = builder.build('ja');
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 all `ChainBuilder` instances found. Used for the namespace pattern (split files). Since `build()` requires a locale, `bindLocale` provides it at the point of use.
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()); // "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
- ### 1. `as const` is required
147
-
148
- ```ts
149
- // Correct
150
- createI18n(['en', 'ja'] as const)
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
- ## Common Patterns
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
- import { createI18n } from 'canopy-i18n';
262
- import { LOCALES } from './locales';
263
-
264
- export const user = createI18n(LOCALES)
265
- .add({
266
- welcome: (ctx: { name: string }) => ({
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()); // "Hello"
282
- console.log(messages.user.welcome({ name: 'John' })); // "Welcome, 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
- ### Locale Context
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
- // LocaleContext.tsx
315
- import { bindLocale } from 'canopy-i18n';
316
- import { createContext, useContext, useState } from 'react';
134
+ // i18n.ts
135
+ import { createI18nReact } from 'canopy-i18n/react';
317
136
 
318
- type Locale = 'en' | 'ja';
137
+ export const LOCALES = ['en', 'ja'] as const;
319
138
 
320
- type ContextType = {
321
- locale: Locale;
322
- setLocale: (locale: Locale) => void;
323
- };
139
+ export const { i18n, LocaleProvider, useLocale, useBindLocale } =
140
+ createI18nReact(LOCALES);
324
141
 
325
- const LocaleContext = createContext<ContextType | undefined>(undefined);
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
- export function LocaleProvider({ children }: { children: React.ReactNode }) {
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
- export function useLocale() {
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
- // Reactively applies bindLocale based on current locale
343
- export function useBindLocale<T extends object>(msgsDef: T) {
344
- const { locale } = useLocale();
345
- return bindLocale(msgsDef, locale);
346
- }
156
+ ```tsx
157
+ <LocaleProvider defaultLocale="en">
158
+ <App />
159
+ </LocaleProvider>
347
160
  ```
348
161
 
349
- ### Usage in Components
162
+ **Controlled** (locale lives outside React):
350
163
 
351
164
  ```tsx
352
- // i18n.ts — export ChainBuilders (not yet built)
353
- import { createI18n } from 'canopy-i18n';
354
-
355
- const LOCALES = ['en', 'ja'] as const;
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
- // App.tsx apply locale with useBindLocale
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
- export default function App() {
373
- const m = useBindLocale(appI18n);
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
- return (
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
- ### Component-Local i18n (Colocation)
181
+ ### Built-in source wrappers
386
182
 
387
- ```tsx
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
- export function ProfileCard({ name }: { name: string }) {
404
- const m = useBindLocale(profileI18n);
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
- return (
407
- <div>
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
- ### Language Switcher Component
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
- // LanguageSwitcher.tsx
420
- import { useLocale } from './LocaleContext';
207
+ import { appI18n, useBindLocale, useLocale } from './i18n';
421
208
 
422
- export function LanguageSwitcher() {
209
+ export default function App() {
210
+ const m = useBindLocale({ appI18n });
423
211
  const { locale, setLocale } = useLocale();
424
-
425
212
  return (
426
213
  <div>
427
- <button onClick={() => setLocale('en')} disabled={locale === 'en'}>EN</button>
428
- <button onClick={() => setLocale('ja')} disabled={locale === 'ja'}>JA</button>
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
- ## Exports Reference
224
+ React is a `peerDependency` (`>=18`).
437
225
 
438
- ```ts
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
- ### Type Details
228
+ ## Exports
453
229
 
454
230
  ```ts
455
- // Template<C, R>: a static value or a function that receives context
456
- type Template<C, R = string> = R | ((ctx: C) => R);
457
-
458
- // LocalizedMessage<Ls, C, R>: the function type after build()
459
- // - when C is void: () => R
460
- // - when C is present: (ctx: C) => R
461
- type LocalizedMessage<Ls, C, R = string> =
462
- C extends void
463
- ? (() => R) & { __brand: "I18nMessage" }
464
- : ((ctx: C) => R) & { __brand: "I18nTemplateMessage" };
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 |