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

@eouia/intl-msg

v0.1.1

Published

Native Intl-based i18n message formatting with dictionary fallback for Node.js and browsers

Downloads

157

Readme

intl-msg

Native Intl-based i18n message formatting for modern Node.js, browsers, and Electron, with no runtime dependencies.

Why this exists

intl-msg started from practical localization problems that show up once a project grows beyond a simple translation table.

Typical pain points look like this:

  • translation files become expensive to maintain because every regional or user-specific variant turns into a full copy
  • locale-specific differences in spelling, grammar, date formatting, and number formatting leak into application code
  • fallback behavior is too simple for real users who may prefer chains such as fr-CA -> fr -> en-CA -> en
  • user overrides and minor-locale customizations are awkward to support cleanly

The goal of this project is to keep localization lightweight while still supporting:

  • partial dictionaries instead of full duplicated dictionary snapshots
  • locale-aware formatting driven by native Intl
  • explicit locale fallback chains
  • custom formatter behavior that can live near translation data instead of in app logic
  • user or language-pack overrides without forcing the main application to ship every variation

In short, intl-msg is not just a string lookup helper. It is a small dictionary resolution and message formatting layer built around native Intl.

For the longer project rationale, see VISION.md.

Status

The package now builds from a single source file and publishes both CommonJS and ESM outputs:

  • CommonJS: dist/cjs/main.cjs
  • ESM: dist/esm/main.js
  • Source of truth: src/main.js

Legacy files such as commonjs/main.js, esm/main.js, and the root main.js are now thin compatibility shims. Package consumers should rely on the published package entry points.

Runtime requirements

This library is designed for modern JavaScript runtimes with full Intl support. It is not a legacy-browser compatibility build.

Minimum practical requirements:

  • Node.js: 16+ recommended
  • Browsers: native ESM support and modern class features, including private fields
  • Electron: a modern Electron release whose bundled Chromium/Node versions satisfy the browser and Node requirements above

Required built-in Intl APIs:

  • Intl.getCanonicalLocales
  • Intl.PluralRules
  • Intl.DateTimeFormat
  • Intl.RelativeTimeFormat
  • Intl.ListFormat
  • Intl.NumberFormat

Required JavaScript features in the runtime:

  • ES modules or a bundler that can consume them
  • private class fields
  • optional chaining
  • nullish coalescing

If your target runtime does not provide the required Intl APIs, you must inject a compatible intlPolyfill when constructing IntlMsg.

Locale input remains compatibility-friendly:

  • common non-BCP47 separators such as en_US are normalized to en-US
  • fallback lookup checks the full canonical locale first, then falls back through the locale base name chain such as en-US and en

Environment support

Supported in practice means:

  • Node.js: works via the published CommonJS and ESM package entry points
  • Browsers: works in modern browsers through native ESM, a bundler, or the browser global build
  • Electron: works when the embedded Node/Chromium runtime provides the required Intl APIs and language features

Not currently provided:

  • a legacy ES5 build
  • a UMD or IIFE browser bundle
  • automatic polyfills for missing Intl features

Install

npm install @eouia/intl-msg

If you publish under a different package name, replace @eouia/intl-msg accordingly.

Usage

ESM

import IntlMsg from '@eouia/intl-msg'

const msg = IntlMsg.factory({
  locales: ['en-US', 'en'],
  dictionaries: {
    en: {
      translations: {
        HELLO: 'Hello, {{name}}.',
      },
    },
  },
})

console.log(msg.message('HELLO', { name: 'Taylor' }))

CommonJS

const IntlMsg = require('@eouia/intl-msg')

const msg = new IntlMsg()
msg.addLocale(['en-US', 'en'])
msg.addDictionary({
  en: {
    translations: {
      HELLO: 'Hello, {{name}}.',
    },
  },
})

console.log(msg.message('HELLO', { name: 'Taylor' }))

Browser <script>

For modern browsers, the package also ships a browser global build at dist/browser/intl-msg.js.

<script src="./dist/browser/intl-msg.js"></script>
<script>
  const msg = IntlMsg.factory({
    locales: ['en-US', 'en'],
    dictionaries: {
      en: {
        translations: {
          HELLO: 'Hello, {{name}}.',
        },
      },
    },
  })

  console.log(msg.message('HELLO', { name: 'Taylor' }))
</script>

This build is intended for modern browsers. It is not a transpiled legacy-browser build.

Dictionary format

{
  "en": {
    "translations": {
      "HELLO": "Hello, {{name}}."
    },
    "formatters": {
      "currency": {
        "format": "number",
        "options": {
          "style": "currency",
          "currency": "USD"
        }
      }
    }
  }
}
  • translations maps message keys to template strings
  • formatters maps formatter names to formatter config objects

Message syntax

Plain substitution

{{name}}
msg.message('HELLO', { name: 'Taylor' })
// => 'Hello, Taylor.'

Formatted substitution

{{amount:currency}}
msg.addDictionary({
  en: {
    translations: {
      TOTAL: 'Total: {{amount:currency}}',
    },
    formatters: {
      currency: {
        format: 'number',
        options: { style: 'currency', currency: 'USD' },
      },
    },
  },
})

msg.message('TOTAL', { amount: 1234.5 })
// => 'Total: $1,234.50'

Locale fallback

Locales are resolved using a fallback chain. For example:

  • en-US tries en-US, then en
  • zh-Hant-TW tries zh-Hant-TW, then zh-Hant, then zh

You can also provide multiple preferred locales:

msg.setLocale(['fr-CA', 'fr', 'en'])

Message lookup will try each locale in order, including each locale's fallback chain, until it finds a matching translation.

Built-in formatters

The library includes these built-in formatters:

  • pluralRules
  • pluralRange
  • list
  • number
  • numberRange
  • select
  • dateTime
  • dateTimeRange
  • relativeTime
  • duration
  • humanizedRelativeTime

Formatter overview

| Formatter | Input shape | Primary purpose | | --- | --- | --- | | select | scalar | Pick a string from options by value | | pluralRules | number | Pick a string from rules by plural category | | pluralRange | { start, end } | Pick a string from rules by plural range category | | list | array | Render a natural-language list | | number | number | Locale-aware number formatting | | numberRange | { start, end } | Locale-aware numeric range formatting | | dateTime | date-like value | Locale-aware date/time formatting | | dateTimeRange | { start, end } | Locale-aware date/time range formatting | | relativeTime | number | Relative time phrase with an explicit unit | | humanizedRelativeTime | date-like value | Relative time phrase with an inferred unit | | duration | duration record | Duration formatting via Intl.DurationFormat |

Example

msg.addDictionary({
  en: {
    translations: {
      SUMMARY: 'Today is {{today:dateLabel}}. Total: {{amount:currency}}.',
    },
    formatters: {
      dateLabel: {
        format: 'dateTime',
        options: { weekday: 'long', month: 'long', day: 'numeric' },
      },
      currency: {
        format: 'number',
        options: { style: 'currency', currency: 'USD' },
      },
    },
  },
})

msg.message('SUMMARY', {
  today: '2026-03-23',
  amount: 1234.5,
})
// => 'Today is Monday, March 23. Total: $1,234.50.'

Duration example

msg.addDictionary({
  en: {
    translations: {
      ELAPSED: 'Elapsed: {{time:elapsed}}',
    },
    formatters: {
      elapsed: {
        format: 'duration',
        options: { style: 'short' },
      },
    },
  },
})

msg.message('ELAPSED', {
  time: { hours: 1, minutes: 30, seconds: 5 },
})
// => 'Elapsed: 1 hr, 30 min, 5 sec'

The duration formatter follows Intl.DurationFormat and expects a duration record object such as { hours: 1, minutes: 30 }.

Range examples

msg.addDictionary({
  en: {
    translations: {
      BUDGET: 'Budget: {{amount:budget}}',
      EVENT: 'Event: {{period:schedule}}',
    },
    formatters: {
      budget: {
        format: 'numberRange',
        options: { style: 'currency', currency: 'USD' },
      },
      schedule: {
        format: 'dateTimeRange',
        options: { month: 'short', day: 'numeric' },
      },
    },
  },
})

msg.message('BUDGET', {
  amount: { start: 1200, end: 3400 },
})
// => 'Budget: $1,200.00 - $3,400.00'

msg.message('EVENT', {
  period: { start: '2026-03-23', end: '2026-03-25' },
})
// => 'Event: Mar 23-25'

The numberRange and dateTimeRange formatters expect an object with { start, end }.

Plural range example

msg.addDictionary({
  en: {
    translations: {
      LABEL: 'Recommended for {{countText}} {{count:ticketLabel}}',
    },
    formatters: {
      ticketLabel: {
        format: 'pluralRange',
        rules: {
          one: 'ticket',
          other: 'tickets',
        },
      },
    },
  },
})

msg.message('LABEL', {
  countText: '1-3',
  count: { start: 1, end: 3 },
})
// => 'Recommended for 1-3 tickets'

The pluralRange formatter expects { start, end } and uses Intl.PluralRules.prototype.selectRange().

Formatter reference

select

Use select when the input value should directly choose a phrase from options.

msg.addDictionary({
  en: {
    translations: {
      WELCOME: 'Welcome {{gender:title}} {{name}}.',
    },
    formatters: {
      title: {
        format: 'select',
        options: {
          female: 'Ms.',
          male: 'Mr.',
          other: '',
        },
      },
    },
  },
})

msg.message('WELCOME', { gender: 'female', name: 'Taylor' })
// => 'Welcome Ms. Taylor.'

Expected input:

  • any scalar value that can be matched against keys in options

Fallback behavior:

  • returns options.other when present
  • otherwise falls back to the original input value

pluralRules

Use pluralRules when a number should choose a localized term by plural category.

msg.addDictionary({
  en: {
    translations: {
      LABEL: 'There {{count:beVerb}} {{count}} {{count:unit}}.',
    },
    formatters: {
      beVerb: {
        format: 'pluralRules',
        rules: {
          one: 'is',
          other: 'are',
        },
      },
      unit: {
        format: 'pluralRules',
        rules: {
          one: 'item',
          other: 'items',
        },
      },
    },
  },
})

msg.message('LABEL', { count: 2 })
// => 'There are 2 items.'

Expected input:

  • a number

Fallback behavior:

  • returns rules.other when available
  • otherwise returns an empty string

pluralRange

Use pluralRange when a numeric range should choose a localized term by range category.

msg.addDictionary({
  en: {
    translations: {
      RANGE: '{{countText}} {{count:ticketLabel}}',
    },
    formatters: {
      ticketLabel: {
        format: 'pluralRange',
        rules: {
          one: 'ticket',
          other: 'tickets',
        },
      },
    },
  },
})

msg.message('RANGE', {
  countText: '1-3',
  count: { start: 1, end: 3 },
})
// => '1-3 tickets'

Expected input:

  • { start, end }

Fallback behavior:

  • warns and falls back when Intl.PluralRules.prototype.selectRange() is unavailable

list

Use list when you want locale-aware conjunctions such as "A, B, and C".

msg.addDictionary({
  en: {
    translations: {
      COLORS: 'Colors: {{value:palette}}',
    },
    formatters: {
      palette: {
        format: 'list',
        options: { style: 'long', type: 'conjunction' },
      },
    },
  },
})

msg.message('COLORS', { value: ['Red', 'Blue', 'White'] })
// => 'Colors: Red, Blue, and White'

Expected input:

  • an array

Fallback behavior:

  • returns the original value when the input is not an array

number

Use number for locale-aware numbers, currency, percent, or unit display.

msg.addDictionary({
  en: {
    translations: {
      TOTAL: 'Total: {{amount:price}}',
    },
    formatters: {
      price: {
        format: 'number',
        options: { style: 'currency', currency: 'USD' },
      },
    },
  },
})

msg.message('TOTAL', { amount: 1234.5 })
// => 'Total: $1,234.50'

Expected input:

  • a number

Fallback behavior:

  • validates commonly used Intl options when supported
  • returns the original value when the input is not numeric or options are invalid

numberRange

Use numberRange when two numeric endpoints should be formatted as one localized range.

msg.addDictionary({
  en: {
    translations: {
      BUDGET: 'Budget: {{amount:budget}}',
    },
    formatters: {
      budget: {
        format: 'numberRange',
        options: { style: 'currency', currency: 'USD' },
      },
    },
  },
})

msg.message('BUDGET', {
  amount: { start: 1200, end: 3400 },
})
// => 'Budget: $1,200.00 - $3,400.00'

Expected input:

  • { start, end }

Fallback behavior:

  • warns and falls back to two separately formatted values joined by - when formatRange() is unavailable

dateTime

Use dateTime for locale-aware date or time rendering from a date-like input.

msg.addDictionary({
  en: {
    translations: {
      TODAY: 'Today is {{value:dateLabel}}.',
    },
    formatters: {
      dateLabel: {
        format: 'dateTime',
        options: { weekday: 'long', month: 'long', day: 'numeric' },
      },
    },
  },
})

msg.message('TODAY', { value: '2026-03-23' })
// => 'Today is Monday, March 23.'

Expected input:

  • a Date, timestamp, or date-like string accepted by new Date(...)

Fallback behavior:

  • returns the original value when the input cannot be parsed as a valid date

dateTimeRange

Use dateTimeRange when two date-like values should be rendered as one localized range.

msg.addDictionary({
  en: {
    translations: {
      EVENT: 'Event: {{period:schedule}}',
    },
    formatters: {
      schedule: {
        format: 'dateTimeRange',
        options: { month: 'short', day: 'numeric' },
      },
    },
  },
})

msg.message('EVENT', {
  period: { start: '2026-03-23', end: '2026-03-25' },
})
// => 'Event: Mar 23-25'

Expected input:

  • { start, end } with date-like values

Fallback behavior:

  • warns and falls back to two separately formatted dates joined by - when formatRange() is unavailable

relativeTime

Use relativeTime when the unit is known in advance.

msg.addDictionary({
  en: {
    translations: {
      ETA: 'ETA: {{value:eta}}',
    },
    formatters: {
      eta: {
        format: 'relativeTime',
        unit: 'day',
      },
    },
  },
})

msg.message('ETA', { value: 3 })
// => 'ETA: in 3 days'

Expected input:

  • a number

Fallback behavior:

  • validates the unit when supported
  • returns the original value when the input is not numeric or the unit is invalid

humanizedRelativeTime

Use humanizedRelativeTime when you want the unit inferred from the distance to now.

msg.addDictionary({
  en: {
    translations: {
      WHEN: 'Updated {{value:ago}}',
    },
    formatters: {
      ago: {
        format: 'humanizedRelativeTime',
      },
    },
  },
})

msg.message('WHEN', { value: new Date(Date.now() - 2 * 60 * 60 * 1000) })
// => 'Updated 2 hours ago'

Expected input:

  • a date-like value

Fallback behavior:

  • returns the original value when the input cannot be parsed as a valid date

duration

Use duration for explicit duration records such as hours, minutes, and seconds.

msg.addDictionary({
  en: {
    translations: {
      ELAPSED: 'Elapsed: {{value:elapsed}}',
    },
    formatters: {
      elapsed: {
        format: 'duration',
        options: { style: 'short' },
      },
    },
  },
})

msg.message('ELAPSED', {
  value: { hours: 1, minutes: 30, seconds: 5 },
})
// => 'Elapsed: 1 hr, 30 min, 5 sec'

Expected input:

  • an Intl.DurationFormat-style duration record object

Fallback behavior:

  • warns and falls back gracefully when Intl.DurationFormat is unavailable

Option validation

When the runtime supports Intl.supportedValuesOf(), the library validates commonly used Intl options before constructing formatters.

Currently validated where applicable:

  • currency
  • unit
  • calendar
  • numberingSystem

If an option is invalid, the formatter warns through the configured logger and falls back gracefully instead of relying only on a constructor exception.

Parts-aware post-processing

Any formatter can optionally pass its result through one registered post-formatter as a second stage. There is no recursive formatter pipeline: format runs first, then postFormat may run once.

Supported built-in formatters currently provide parts when available:

  • list
  • number
  • numberRange
  • dateTime
  • dateTimeRange
  • relativeTime

The post-formatter receives a context object including:

  • value: the built-in formatter's default string result
  • parts: the result of formatToParts() or formatRangeToParts() when supported
  • rawValue: the original unformatted input value
  • format: the built-in formatter name that ran first

For custom primary formatters, postFormat still works, but parts is only populated when the first stage formatter collected them.

Example:

msg.registerFormatter('markCurrency', ({ value, parts }) => {
  const currency = parts.find((part) => part.type === 'currency')?.value ?? ''
  return `${value} [${currency}]`
})

msg.addDictionary({
  en: {
    translations: {
      TOTAL: 'Total: {{amount:price}}',
    },
    formatters: {
      price: {
        format: 'number',
        options: { style: 'currency', currency: 'USD' },
        postFormat: 'markCurrency',
      },
    },
  },
})

msg.message('TOTAL', { amount: 1234.5 })
// => 'Total: $1,234.50 [$]'

Custom formatters

Register a formatter by name, then reference it from dictionary formatter definitions:

msg.registerFormatter('capitalize', ({ value }) => {
  const text = value == null ? '' : String(value)
  return text ? text[0].toUpperCase() + text.slice(1).toLowerCase() : text
})

msg.addDictionary({
  en: {
    translations: {
      TITLE: 'Welcome, {{name:titleCase}}.',
    },
    formatters: {
      titleCase: {
        format: 'capitalize',
      },
    },
  },
})

msg.message('TITLE', { name: 'tAYLOR' })
// => 'Welcome, Taylor.'

Custom formatter callbacks receive a single config object. Common fields include:

  • locales
  • value
  • options
  • any additional formatter-specific properties from the dictionary config

API

new IntlMsg(options?)

Creates an instance.

Supported options:

  • log
  • verbose
  • intlPolyfill

Example:

const msg = new IntlMsg({ verbose: false })

IntlMsg.factory(options?)

Convenience constructor. In addition to the constructor options, it also accepts:

  • locales
  • dictionaries

Example:

const msg = IntlMsg.factory({
  locales: ['en-US', 'en'],
  dictionaries: {
    en: { translations: { HELLO: 'Hello' } },
  },
})

msg.message('HELLO')
// => 'Hello'

addLocale(locales)

Adds one locale or an array of locales.

msg.addLocale(['en-US', 'de'])
msg.getLocale()
// => ['en-US', 'de']

setLocale(locales)

Replaces the current locale list.

msg.setLocale(['fr-CA', 'fr', 'en'])
msg.getLocale()
// => ['fr-CA', 'fr', 'en']

getLocale()

Returns the current locale list.

addDictionary(dictionaryJson)

Merges dictionary data into the current instance.

msg.addDictionary({
  en: {
    translations: {
      HELLO: 'Hello, {{name}}.',
    },
  },
})

msg.message('HELLO', { name: 'Taylor' })
// => 'Hello, Taylor.'

getDictionary(locale)

Returns the Dictionary instance for a locale, or null.

msg.getDictionary('en')
// => Dictionary instance or null

getDictionaryNames()

Returns the registered locale names.

msg.getDictionaryNames()
// => ['en', 'en-US']

addTermToDictionary(locale, key, value)

Adds or replaces a translation term for a locale.

msg.addTermToDictionary('en', 'BYE', 'Goodbye')
msg.message('BYE')
// => 'Goodbye'

getTermFromDictionary(locale, key)

Returns a term value, or undefined.

msg.getTermFromDictionary('en', 'HELLO')
// => 'Hello, {{name}}.'

getRawMessage(key, locales?)

Returns the untranslated template string selected by locale lookup.

msg.getRawMessage('HELLO')
// => 'Hello, {{name}}.'

message(key, values?)

Formats and returns the final message string.

msg.message('HELLO', { name: 'Taylor' })
// => 'Hello, Taylor.'

registerFormatter(name, fn)

Registers a custom formatter callback.

msg.registerFormatter('capitalize', ({ value }) => {
  const text = value == null ? '' : String(value)
  return text ? text[0].toUpperCase() + text.slice(1).toLowerCase() : text
})

Dictionary formatter configs may also set postFormat to the name of a registered formatter. Only two stages are supported: format, then postFormat.

Development

Install dependencies and run tests:

npm test

Tests currently build the package first, then run Mocha with nyc coverage.

Publishing

Manual publish:

npm publish --access public

Trusted publishing via GitHub Actions is also configured in publish.yml.

Current workflow behavior:

  • pushes of tags matching v* trigger the publish workflow
  • the workflow runs npm ci, npm test, and then npm publish --access public
  • npm trusted publishing must be configured on npmjs.com for this repository and workflow file

To enable trusted publishing on npm:

  1. Open your package settings on npmjs.com
  2. Add a trusted publisher for GitHub Actions
  3. Use GitHub owner eouia
  4. Use repository intl-msg
  5. Use workflow file publish.yml

After that, publishing a new release is:

git tag v0.1.0
git push origin v0.1.0

Production use

intl-msg is usable in real applications today, especially when you want:

  • partial dictionaries
  • locale-aware formatting driven by translation data
  • explicit fallback behavior
  • application-controlled dictionary loading

It is a good fit when:

  • your app can decide where dictionaries live
  • you want to merge default dictionaries, language packs, and user overrides
  • you want to stay close to native Intl behavior

Things to keep in mind:

  • dictionary discovery is application-owned
  • the optional compose and loaders helpers are intentionally small
  • modern runtimes are assumed
  • this is not an ICU MessageFormat replacement

For runtime language switching, prefer composing a fresh dictionary set and creating a fresh IntlMsg instance instead of mutating one long-lived instance in place.

Optional composition helper

The package also provides an optional composition helper for building one merged dictionary from a priority plan:

import composeDictionaries from '@eouia/intl-msg/compose'

const dictionaries = await composeDictionaries(
  [
    { locale: 'en', source: 'default' },
    { locale: 'en-US', source: 'langpack' },
    { locale: 'en-CA', source: 'user' },
  ],
  async ({ locale, source }) => {
    // Application-specific loading logic goes here.
    // Return a partial dictionary object or null.
  }
)

This helper is intentionally small and optional. It does not replace the core IntlMsg API.

Optional loader helpers

The package also provides optional strict helpers via intl-msg/loaders:

import { createMemoryLoader, createFetchLoader, createPathLoader } from '@eouia/intl-msg/loaders'

These helpers are intentionally narrow:

  • they work well for simple, conventional layouts
  • they do not try to discover arbitrary custom dictionary locations
  • applications can override URL/path resolution through callbacks

Examples:

const memoryLoader = createMemoryLoader(registry)

const fetchLoader = createFetchLoader({
  resolveUrl: ({ locale, source }) => `/dictionaries/${source}/${locale}.json`,
})

const pathLoader = createPathLoader({
  resolvePath: ({ locale, source }) => `./dictionaries/${source}/${locale}.json`,
  readFile: fs.promises.readFile,
})

Node.js path example:

import IntlMsg from 'intl-msg'
import composeDictionaries from 'intl-msg/compose'
import { createPathLoader } from 'intl-msg/loaders'
import { readFile } from 'node:fs/promises'

const loader = createPathLoader({
  readFile,
  resolvePath: ({ locale, source }) =>
    `${process.cwd()}/dictionaries/${source}/${locale}.json`,
})

const dictionaries = await composeDictionaries(
  [{ locale: 'en-US', source: 'default' }],
  loader
)

const msg = IntlMsg.factory({
  locales: ['en-US', 'en'],
  dictionaries,
})

console.log(msg.message('HELLO'))