npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

canopy-i18n

v0.9.1

Published

The Type-Safe i18n library that your IDE will Love

Readme

canopy-i18n

A tiny, type-safe i18n library for building localized messages with builder pattern and applying locales across nested data structures — with optional AI translation for missing locales and runtime user input.

Features

  • AI translation: Write only your source locale and let AI fill in the rest. Built-in OpenAI / Claude / Gemini adapters, runtime translation of user input with caching. See AI Translation.
  • AI-friendly: Full type safety and single-file colocation give AI assistants complete context for accurate code generation.
  • Type-safe: Compile-time safety for locale keys with full TypeScript IntelliSense support.
  • Flexible templating: Plain functions support any JavaScript logic, template literals, or formatting library.
  • Zero dependencies: Lightweight with native TypeScript syntax, no custom {{placeholder}} format.
  • React-ready: Provider, hooks, and built-in source wrappers for URL hash / search param / pathname / localStorage / Cookie. See React Integration.

Using React? Jump straight to React Integration — the Provider, hooks, and ready-made source wrappers cover most app setups out of the box.

Tired of writing every locale by hand? See AI Translation — write ja only and let AI complete the rest, or translate user input at runtime.

Why Canopy i18n?

Traditional i18n libraries require separate JSON files and string-based key lookups:

Traditional approach:

// locales/en.json
{ "greeting": "Hello", "farewell": "Goodbye" }

// locales/ja.json
{ "greeting": "こんにちは", "farewell": "さようなら" }

// app.ts
import i18next from 'i18next';

await i18next.init({ /* config... */ });
console.log(i18next.t('greeting'));  // No type safety, typos cause silent failures

Canopy i18n:

import { createI18n } from 'canopy-i18n';

const messages = createI18n(['en', 'ja'] as const).add({
  greeting: { en: 'Hello', ja: 'こんにちは' },
  farewell: { en: 'Goodbye', ja: 'さようなら' }
}).build('en');

console.log(messages.greeting());  // Fully type-safe, autocomplete works

With template functions:

const messages = createI18n(['en', 'ja'] as const)
  .add({
    welcome: (ctx: { name: string }) => ({
      en: `Welcome, ${ctx.name}!`,
      ja: `ようこそ、${ctx.name}さん!`
    })
  }).build('en');

console.log(messages.welcome({ name: 'Alice' }));  // "Welcome, Alice!"

With AI translation — write only your source locale:

import { createAITranslator, openAIAdapter, memoryCache } from 'canopy-i18n/ai';

const translator = createAITranslator({
  adapter: openAIAdapter({ model: 'gpt-4o-mini', apiKey: process.env.OPENAI_API_KEY! }),
  sourceLocale: 'ja',
  cache: memoryCache(),
});

const messages = createI18n(['ja', 'en'] as const)
  .add(await translator.completeEntries(['ja', 'en'] as const, {
    greeting: { ja: 'こんにちは' },   // en is filled in by AI
  }))
  .build('en');

console.log(messages.greeting());  // "Hello"

// It also translates dynamic text (e.g. user input) at runtime, with caching
await translator.translate(userComment, { to: 'en' });

Benefits:

  • 🔒 Type safety: Typos caught at compile time, full autocomplete support
  • 📁 Colocation: All translations in one place, no file jumping
  • 🤖 AI translation: Missing locales and user input translated by AI — OpenAI / Claude / Gemini or your own adapter
  • Zero config: No loaders, plugins, or initialization required
  • 🚀 Framework agnostic: Works anywhere JavaScript runs

Installation

npm install canopy-i18n
# or
pnpm add canopy-i18n
# or
yarn add canopy-i18n
# or
bun add canopy-i18n

Quick Start

import { createI18n, bindLocale } from 'canopy-i18n';

// 1) Create a builder with allowed locales
const baseBuilder = createI18n(['ja', 'en'] as const);

// 2) Define messages using method chaining
// Note: Each method returns a new immutable builder instance
const builder = baseBuilder
  .add({
    title: {
      ja: 'タイトルテスト',
      en: 'Title Test',
    },
    greeting: {
      ja: 'こんにちは',
      en: 'Hello',
    },
    welcome: (ctx: { name: string; age: number }) => ({
      ja: `こんにちは、${ctx.name}さん。あなたは${ctx.age}歳です。`,
      en: `Hello, ${ctx.name}. You are ${ctx.age} years old.`,
    }),
  });

// 3) Reuse the builder to create messages for different locales
const enMessages = builder.build('en');
const jaMessages = builder.build('ja');

// 4) Use messages (English)
console.log(enMessages.title());                        // "Title Test"
console.log(enMessages.greeting());                     // "Hello"
console.log(enMessages.welcome({ name: 'Tanaka', age: 20 })); // "Hello, Tanaka. You are 20 years old."

// 5) Use messages (Japanese)
console.log(jaMessages.title());                        // "タイトルテスト"
console.log(jaMessages.greeting());                     // "こんにちは"
console.log(jaMessages.welcome({ name: 'Tanaka', age: 20 })); // "こんにちは、Tanakaさん。あなたは20歳です。"

React Integration

canopy-i18n/react provides a tiny factory that returns a Provider, hooks, and a pre-bound i18n shorthand — all sharing the same Locale type.

// i18n.ts
import { createI18nReact } from 'canopy-i18n/react';

export const LOCALES = ['en', 'ja'] as const;

export const { i18n, LocaleProvider, useLocale, useBindLocale } =
  createI18nReact(LOCALES);

export const appI18n = i18n({
  title: { en: 'My App', ja: 'マイアプリ' },
  greeting: (ctx: { name: string }) => ({
    en: `Hello, ${ctx.name}!`,
    ja: `こんにちは、${ctx.name}さん!`,
  }),
});
// main.tsx
import { LocaleProvider } from './i18n';

<LocaleProvider defaultLocale="en">
  <App />
</LocaleProvider>

LocaleProvider also supports a controlled mode for integrating with URL routing, cookies, or external state:

// Controlled mode: locale is owned by the parent
<LocaleProvider locale={currentLocale} onLocaleChange={setCurrentLocale}>
  <App />
</LocaleProvider>

In controlled mode, setLocale from useLocale() calls your onLocaleChange handler instead of mutating internal state.

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:

export const { LocaleProvider, useLocale } = createI18nReact(LOCALES, {
  useLocaleSource: () => useMyStore((s) => s.locale),
  onLocaleChange: (l) => useMyStore.getState().setLocale(l),
});

<LocaleProvider>
  <App />
</LocaleProvider>

Built-in source wrappers

For common sources, canopy-i18n/react ships ready-made factories. Each returns the same shape as createI18nReact and operates in source-driven mode:

import {
  createHashI18nReact,     // URL hash (#ja)
  createSearchI18nReact,   // URL search param (?lang=ja, configurable via { param })
  createPathnameI18nReact, // URL pathname prefix (/ja/..., configurable via { basePath })
  createStorageI18nReact,  // localStorage (configurable via { key })
  createCookieI18nReact,   // Cookie (configurable via { key, maxAge, path, sameSite })
} from 'canopy-i18n/react';

export const { LocaleProvider, useLocale, useBindLocale } =
  createHashI18nReact(LOCALES);

// Render <LocaleProvider> with no props.
// App.tsx
import { appI18n, useBindLocale, useLocale } from './i18n';

function App() {
  const m = useBindLocale({ appI18n });
  const { locale, setLocale } = useLocale();

  return (
    <div>
      <h1>{m.appI18n.title()}</h1>
      <p>{m.appI18n.greeting({ name: 'Taro' })}</p>
      <button onClick={() => setLocale(locale === 'en' ? 'ja' : 'en')}>
        {locale}
      </button>
    </div>
  );
}

Factory return value

const {
  locales,         // the LOCALES tuple you passed in
  i18n,            // function: i18n(entries) → ChainBuilder bound to LOCALES
  LocaleProvider,  // uncontrolled or controlled (see above)
  useLocale,       // () => { locale, setLocale }
  useBindLocale,   // memoized bindLocale, locale type-checked
} = createI18nReact(['en', 'ja'] as const);

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.

  • useBindLocale(msgsDef) is memoized per (msgsDef, locale) pair.
  • 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.
  • 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.
  • React is a peerDependency (>=18). Non-React users can ignore the /react subpath entirely.

AI Translation

The canopy-i18n/ai subpath provides a runtime translator built around a pluggable adapter. Bring any AI backend by implementing a single translate function.

import { createAITranslator, memoryCache } from 'canopy-i18n/ai';

const translator = createAITranslator({
  // Implement AIAdapter with any provider (OpenAI, local model, ...)
  adapter: {
    async translate({ texts, from, to }) {
      const translated = await callYourAI(texts, from, to);
      return translated; // same order and length as texts
    },
  },
  sourceLocale: 'ja',
  cache: memoryCache(), // optional; implement TranslationCache for DB persistence
});

Built-in adapters

Ready-made adapters for OpenAI, Anthropic (Claude), and Google Gemini. All are fetch-based with no SDK dependency. model and apiKey are required; instructions and baseURL are optional.

import {
  createAITranslator,
  memoryCache,
  openAIAdapter,
  anthropicAdapter,
  geminiAdapter,
} from 'canopy-i18n/ai';

const adapter = openAIAdapter({
  model: 'gpt-4o-mini',
  apiKey: process.env.OPENAI_API_KEY!,
  // baseURL: 'http://localhost:11434/v1', // any OpenAI-compatible API (Ollama, etc.)
  // instructions: 'Do not translate the product name "Canopy".',
});

// or
anthropicAdapter({
  model: 'claude-haiku-4-5',
  apiKey: process.env.ANTHROPIC_API_KEY!,
  // maxTokens: 8192, // the Anthropic API requires max_tokens; this is the default
});

// or
geminiAdapter({
  model: 'gemini-2.0-flash',
  apiKey: process.env.GEMINI_API_KEY!,
});

const translator = createAITranslator({
  adapter,
  sourceLocale: 'ja',
  cache: memoryCache(),
});

Prompt helpers

The prompt logic used by the built-in adapter is exported, so custom adapters can reuse it instead of writing their own:

import { buildTranslatePrompt, parseTranslatedTexts } from 'canopy-i18n/ai';

const adapter = {
  async translate(request) {
    const prompt = buildTranslatePrompt(request, { instructions: 'Keep it casual.' });
    const raw = await callYourAI(prompt);
    // strips code fences / surrounding text, validates order & length
    return parseTranslatedTexts(raw, request.texts.length);
  },
};

Translating dynamic texts (e.g. user input)

const translated = await translator.translate(userComment, { to: 'en' });
const many = await translator.translateMany(['こんにちは', 'さようなら'], { to: 'en' });

// Source language unknown? Omit both sourceLocale and `from` —
// the adapter receives `from: undefined` and should auto-detect.
const detected = await translator.translate(anyLanguageInput, { to: 'ja' });
  • from falls back to sourceLocale; if neither is set, the adapter auto-detects.
  • Results are cached (cache option), so the same text is translated only once.
  • Concurrent requests for the same text are deduplicated into a single adapter call.
  • translateMany batches all texts into one adapter call.
  • On adapter failure the original text is returned by default (onError: 'fallback'). Set onError: 'throw' to propagate errors.

Completing message entries

Write only the source locale and let AI fill in the rest. The result is a complete entries object for ChainBuilder.add():

const entries = await translator.completeEntries(['ja', 'en', 'fr'] as const, {
  title: { ja: 'タイトル' },                      // en & fr are AI-translated
  greeting: { ja: 'こんにちは', en: 'Hello' },     // existing en is kept, fr is AI-translated
});

const messages = createI18n(['ja', 'en', 'fr'] as const)
  .add(entries)
  .build('en');

Only static string entries are supported; template functions are out of scope for AI translation.

Interfaces

interface AIAdapter {
  // `from` is undefined when the source language is unknown — auto-detect it
  translate(request: { texts: string[]; from?: string; to: string }): Promise<string[]>;
}

interface TranslationCache {
  get(key: string): Promise<string | undefined> | string | undefined;
  set(key: string, value: string): Promise<void> | void;
}
createAITranslator({
  adapter,             // AIAdapter (required)
  sourceLocale: 'ja',  // default `from` (optional; required for completeEntries)
  cache,               // TranslationCache (optional)
  onError: 'fallback', // 'fallback' (default) | 'throw'
  cacheKey,            // (text, from, to) => string (optional)
});

API

createI18n(locales)

Creates a ChainBuilder instance to build localized messages.

  • locales: readonly string[] — Allowed locale keys (e.g. ['ja', 'en'] as const).
  • Returns: ChainBuilder<Locales, {}> — A builder instance to chain message definitions.
const builder = createI18n(['ja', 'en', 'fr'] as const);

ChainBuilder

A builder class for creating multiple localized messages with method chaining.

.add(entries)

Adds multiple messages at once. Each entry can be a static locale record or a template function.

  • entries: Record<K, Record<Locale, string> | ((ctx: C) => Record<Locale, string>)>
  • Returns: ChainBuilder with added messages
// Static messages
const builder = createI18n(['ja', 'en'] as const)
  .add({
    title: { ja: 'タイトル', en: 'Title' },
    greeting: { ja: 'こんにちは', en: 'Hello' },
  });

// Template functions
const builder2 = createI18n(['ja', 'en'] as const)
  .add({
    greet: (ctx: { name: string; age: number }) => ({
      ja: `こんにちは、${ctx.name}さん。${ctx.age}歳ですね。`,
      en: `Hello, ${ctx.name}. You are ${ctx.age}.`,
    }),
    farewell: (ctx: { name: string }) => ({
      ja: `さようなら、${ctx.name}さん。`,
      en: `Goodbye, ${ctx.name}.`,
    }),
  });

// Mixing static and template messages
const builder3 = createI18n(['ja', 'en'] as const)
  .add({
    title: { ja: 'タイトル', en: 'Title' },
    greet: (ctx: { name: string }) => ({
      ja: `こんにちは、${ctx.name}さん`,
      en: `Hello, ${ctx.name}`,
    }),
  });

.build(locale)

Builds the final messages object.

  • locale: Locale — Sets this locale on all messages before returning.
  • Returns: Messages — An object containing all defined messages
const englishMessages = builder.build('en');
const japaneseMessages = builder.build('ja');

Note: build(locale) creates a deep clone and does not mutate the builder instance, allowing you to build multiple locale versions from the same builder.

bindLocale(obj, locale)

Recursively traverses objects/arrays and sets the given locale on all I18nMessage instances and builds all ChainBuilder instances encountered.

  • obj: Any object/array structure containing messages or builders
  • locale: The locale to apply
  • Returns: A new structure with locale applied (containers are cloned, message instances are updated in place)
const data = {
  common: builder1,
  nested: {
    special: builder2,
  },
};

const localized = bindLocale(data, 'en');
console.log(localized.common.title());      // English version
console.log(localized.nested.special.msg()); // English version

Note: bindLocale works with both ChainBuilder instances (automatically building them with the specified locale) and already-built message objects (updating their locale).

Types

export type Template<C, R = string> = R | ((ctx: C) => R);
export type LocalizedMessage<Locales, Context> = I18nMessage<Locales, Context>;

Exports

export { createI18n, ChainBuilder } from 'canopy-i18n';
export { I18nMessage, isI18nMessage } from 'canopy-i18n';
export { bindLocale, isChainBuilder } from 'canopy-i18n';
export type { Template, LocalizedMessage } from 'canopy-i18n';

Usage Patterns

Basic String Messages

const messages = createI18n(['ja', 'en'] as const)
  .add({
    title: { ja: 'タイトル', en: 'Title' },
    greeting: { ja: 'こんにちは', en: 'Hello' },
    farewell: { ja: 'さようなら', en: 'Goodbye' },
  })
  .build('en');

console.log(messages.title());    // "Title"
console.log(messages.greeting()); // "Hello"

Template Functions with Context

const messages = createI18n(['ja', 'en'] as const)
  .add({
    profile: (ctx: { name: string; age: number }) => ({
      ja: `名前: ${ctx.name}、年齢: ${ctx.age}歳`,
      en: `Name: ${ctx.name}, Age: ${ctx.age}`,
    }),
  })
  .build('en');

console.log(messages.profile({ name: 'Taro', age: 25 }));
// "Name: Taro, Age: 25"

Mixing Static and Template Messages

const messages = createI18n(['ja', 'en'] as const)
  .add({
    title: { ja: 'タイトル', en: 'Title' },
    items: (ctx: { count: number }) => ({
      ja: `${ctx.count}個のアイテム`,
      en: `${ctx.count} items`,
    }),
  })
  .build('ja');

console.log(messages.title());           // "タイトル"
console.log(messages.items({ count: 5 })); // "5個のアイテム"

Namespace Pattern (Split Files)

// i18n/locales.ts
export const LOCALES = ['ja', 'en'] as const;

// i18n/common.ts
import { createI18n } from 'canopy-i18n';
import { LOCALES } from './locales';

export const common = createI18n(LOCALES).add({
  hello: { ja: 'こんにちは', en: 'Hello' },
  goodbye: { ja: 'さようなら', en: 'Goodbye' },
});

// i18n/user.ts
import { createI18n } from 'canopy-i18n';
import { LOCALES } from './locales';

export const user = createI18n(LOCALES).add({
  welcome: (ctx: { name: string }) => ({
    ja: `ようこそ、${ctx.name}さん`,
    en: `Welcome, ${ctx.name}`,
  }),
});

// i18n/index.ts
export { common } from './common';
export { user } from './user';

// app.ts
import { bindLocale } from 'canopy-i18n';
import * as i18n from './i18n';

const messages = bindLocale(i18n, 'en');
console.log(messages.common.hello());              // "Hello"
console.log(messages.user.welcome({ name: 'John' })); // "Welcome, John"

Dynamic Locale Switching

const builder = createI18n(['ja', 'en'] as const)
  .add({
    title: { ja: 'タイトル', en: 'Title' },
  });

// Build different locale versions from the same builder
const jaMessages = builder.build('ja');
const enMessages = builder.build('en');

console.log(jaMessages.title()); // "タイトル"
console.log(enMessages.title()); // "Title"

Deep Nested Structures

const structure = {
  header: createI18n(['ja', 'en'] as const)
    .add({ title: { ja: 'ヘッダー', en: 'Header' } }),
  content: {
    main: createI18n(['ja', 'en'] as const)
      .add({ body: { ja: '本文', en: 'Body' } }),
    sidebar: createI18n(['ja', 'en'] as const)
      .add({ widget: { ja: 'ウィジェット', en: 'Widget' } }),
  },
};

const localized = bindLocale(structure, 'en');
console.log(localized.header.title());           // "Header"
console.log(localized.content.main.body());      // "Body"
console.log(localized.content.sidebar.widget()); // "Widget"

Repository

https://github.com/MOhhh-ok/canopy-i18n