Skip to content

denostack/intlit

Repository files navigation

Intlit

Build Coverage License Language Typescript
JSR version NPM Version Downloads

Elevate your internationalization (i18n) workflow with Intlit: gettext-compatible formatting, simplified plural handling, and first-class TypeScript support.

Features

  • Simple and intuitive API
  • Full TypeScript support for type safety
  • Unified key for singular and plural forms (unlike gettext which often requires separate msgid and msgid_plural)
  • Includes pluralization support (plugins can be optionally added for more features)
  • Easy to integrate with existing projects
  • Inline /* ... */ comments let you differentiate identical source strings without affecting the fallback text
  • Bundled Korean tagged template adds automatic particle selection when locale is set to ko

Installation

npm install intlit
import { Formatter } from "intlit";

Usage

Basic Usage

const formatter = new Formatter({
  locale: "en",
});

const text = formatter.format("Hello World");

console.log(text); // Output: Hello World

You can pass values in the second argument and interpolate them inside the template. When no translation is registered, the original text is used as the fallback.

const formatter = new Formatter({ locale: "en" });

formatter.format("Hello, {name}!", { name: "Kelly" });
// → "Hello, Kelly!"

Supplying Localized Messages

Provide a message catalog to translate strings. The Formatter generics keep the message keys type-safe.

const messages = {
  "Hello, {name}!": "안녕하세요, {name}!",
};

const formatter = new Formatter<typeof messages>({
  locale: "ko",
  messages,
});

formatter.format("Hello, {name}!", { name: "Kelly" });
// → "안녕하세요, Kelly!"

Handling Plurals

Intlit provides support for handling plural forms in different languages, making it easy to create grammatically correct translations.

First, let's see how to set up Formatter instances for English, Korean, and Arabic, including specific message translations for Korean and Arabic. The pluralization capability is available by default.

const formatter = new Formatter({
  locale: "en",
});

// With count = 1 (singular)
console.log(
  formatter.format("You have {count} file{count.other:}s{/}.", { count: 1 }),
); // Output: You have 1 file.

// With count = 2 (plural)
console.log(
  formatter.format("You have {count} file{count.other:}s{/}.", { count: 2 }),
); // Output: You have 2 files.
const messages = {
  "You have {count} file{count.other:}s{/}.": "{count}개의 파일이 있습니다.",
};

const formatter = new Formatter({
  locale: "ko",
  messages,
});

// With count = 1
console.log(
  formatter.format("You have {count} file{count.other:}s{/}.", { count: 1 }),
); // Output: 1개의 파일이 있습니다.
const messages = {
  "You have {count} file{count.other:}s{/}.":
    "{count.zero:}لا يوجد لديك ملفات.{.one:}لديك ملف واحد.{.two:}لديك ملفان.{.few:}لديك {_} ملفات قليلة.{.many:}لديك {_} ملفات كثيرة.{.other:}لديك {_} ملفات.{/}",
};

const formatter = new Formatter({
  locale: "ar",
  messages: messages,
});

// Arabic has multiple plural forms (zero, one, two, few, many, other). 🫢

console.log(
  formatter.format("You have {count} file{count.other:}s{/}.", { count: 0 }),
); // Output: لا يوجد لديك ملفات.

console.log(
  formatter.format("You have {count} file{count.other:}s{/}.", { count: 1 }),
); // Output: لديك ملف واحد.

console.log(
  formatter.format("You have {count} file{count.other:}s{/}.", { count: 2 }),
); // Output: لديك ملفان.

console.log(
  formatter.format("You have {count} file{count.other:}s{/}.", { count: 3 }),
); // Output: لديك 3 ملفات قليلة.

console.log(
  formatter.format("You have {count} file{count.other:}s{/}.", { count: 11 }),
); // Output: لديك 11 ملفات كثيرة.

console.log(
  formatter.format("You have {count} file{count.other:}s{/}.", { count: 100 }),
); // Output: لديك 100 ملفات.

Gender and Conditional Segments

Hooks in Intlit behave like chained methods. The default hooks include helpers for gendered output and else fallbacks.

const formatter = new Formatter({ locale: "en" });

formatter.format(
  "{user} added {photoCount.one:}a new photo{.other:}{_} new photos{/} to {gender.male:}his{.female:}her{.other:}their{/} stream.",
  { user: "Alex", photoCount: 2, gender: "female" },
);
// → "Alex added 2 new photos to her stream."

Inside nested segments the current value is available through the special _ parameter, so you can reuse the original number (as seen above when printing the plural count).

Adding Context with Inline Comments

Inline block comments are stripped from the rendered output but kept in the key. They are perfect for differentiating identical phrases that need distinct translations.

const messages = {
  "/* 환영 */안녕하세요.": "Welcome!",
  "/* 일반 */안녕하세요.": "Hello!",
};

const formatter = new Formatter({
  locale: "en",
  messages,
});

formatter.format("/* 환영 */안녕하세요.");
// → "Welcome!"

formatter.format("/* 일반 */안녕하세요.");
// → "Hello!"

// Because the comments are ignored at runtime the fallback remains clean.
new Formatter({ locale: "ko" }).format("/* 환영 */안녕하세요.");
// → "안녕하세요."

Integrating a Tagged Template Handler

Need smarter whitespace management or language-specific post-processing? Intlit ships with a particle-aware template handler that is applied automatically when locale is set to ko.

const formatter = new Formatter({ locale: "ko" });

formatter.format(
  `새 소식이 있습니다:\n{count.one:}새 메시지가 하나 생겼어요{.other:}{_}개의 새 메시지가 있어요{/}
`,
  { count: 3 },
);
// → "새 소식이 있습니다:\n3개의 새 메시지가 있어요"

For other locales you can still provide your own taggedTemplate function to massage the final string (e.g. trim indentation, collapse whitespace, or run additional transforms).

Custom Hooks

Extend Intlit by adding application-specific hooks. A hook receives a HookContext object describing the current placeholder and returns the next value that should flow through the chain.

import { Formatter, type Hook } from "intlit";

const upper: Hook = (ctx) => String(ctx.current ?? "").toUpperCase();

const fallback: Hook = (ctx) =>
  ctx.current == null || ctx.current === "" ? "(none)" : ctx.current;

const formatter = new Formatter({
  locale: "en",
  hooks: { upper, fallback },
});

formatter.format("Hello, {name.upper.fallback}!", { name: "alex" });
// → "Hello, ALEX!"

Hooks receive:

  • ctx.original: The value that came directly from Formatter#format.
  • ctx.current: The value produced so far in the chain. Whatever the hook returns becomes the new ctx.current.
  • ctx.locale: Locale that was supplied when constructing the formatter.
  • args: Arguments passed from the template method such as the currency part in {price.number:currency}. Arguments can include nested templates.

To share state across hook invocations, keep data in a WeakMap<HookContext, …>. The built-in gender and plural hooks follow this pattern to remember which branch has already matched.

Combine custom hooks with the built-in plural helpers to cover complex cases without branching in your code.

Formatter Options

new Formatter(options) accepts the following configuration:

  • locale: Locale passed to plural rules. Defaults to en.
  • messages: A record of source text → translated text pairs.
  • hooks: Extra hook implementations that augment or replace the defaults.
  • taggedTemplate: Custom renderer for the template literal output.

Instantiate different Formatter objects per locale, or swap the messages object dynamically when building multi-locale applications.

About

Type-safe i18n formatter with hooks and gettext-style templates

Resources

License

Stars

Watchers

Forks