yet another localization library
README.md

@oomfware/polyglot #

yet another localization library.

polyglot takes message resources authored in MessageFormat 2.0 and turns them into tree-shakeable TypeScript modules.

npm install --save-dev @oomfware/polyglot

quick start #

resources are written in the draft message-resource format.

@locale en
---

greeting = Hello!

welcome = Welcome, {$name}!

unread =
  .input {$count :integer}
  .match $count
  0   {{No new messages.}}
  one {{You have {$count} new message.}}
  *   {{You have {$count} new messages.}}

[account]
role =
  .input {$kind :string}
  .match $kind
  admin {{Administrator}}
  *     {{Member}}

[account.billing]
status = Your plan renews on {$date :date}.

compile the resources

pnpm exec polyglot --base en --in locales --out src/polyglot

each entry becomes one message keyed by its full dotted identifier.

import { m } from './polyglot/messages.ts';

m['greeting']();
m['account.role']({ kind: 'admin' });
// -> "Administrator"
m['account.billing.status']({ date: new Date() });

see the MessageFormat 2.0 specification for the full message syntax — selectors, functions, markup, and escaping.

CLI #

polyglot --base <locale> --out <dir> [--in <dir>] [--functions <file>]
flag description
-b, --base base locale, used as the fallback branch in generated messages
-f, --functions module exporting custom functions (see below)
-i, --in directory searched recursively for .poly files (default: .)
-o, --out directory to write generated modules into

polyglot replaces its output directory on each run to remove stale modules. it rejects non-empty directories without a .polyglot marker and directories containing its inputs. compilation and staging writes finish before existing output is moved or removed.

custom functions #

custom functions are imported by name from the module passed to --functions. declare them with defineFunction, implementing format for placeholders and select for selectors.

literal options are strings; variable options retain their input values. generated message input types are inferred from the custom function's parameter types.

// src/l10n/functions.ts
import { defineFunction } from '@oomfware/polyglot';

export const relativetime = defineFunction({
	format(
		value: number,
		options: { numeric?: 'always' | 'auto'; unit: Intl.RelativeTimeFormatUnit },
		{ locale },
	) {
		return new Intl.RelativeTimeFormat(locale, { numeric: options.numeric }).format(
			value,
			options.unit,
		);
	},
});

export const dialect = defineFunction({
	format(value: string) {
		return value;
	},
	// return the unique matching keys, most preferred first.
	select(value: string, _options, keys) {
		return [...new Set([value, value.split('-')[0]])].filter((key) => keys.includes(key));
	},
});
joined = {$name} joined {$value :relativetime unit=$unit numeric=auto}

spelling =
  .input {$lang :dialect}
  .match $lang
  en-GB {{colour}}
  en    {{color}}
  *     {{colour}}

if later selectors rule out all variants for a preferred key, selection tries the next key, then *. a namespaced function such as :ns:fn uses a string-named export: export { fn as 'ns:fn' }.

programmatic API #

to drive compilation from your own build, you can import from @oomfware/polyglot/compiler.

import { compile, CompileError, formatDiagnostic } from '@oomfware/polyglot/compiler';

try {
	const { files, warnings } = compile({
		baseLocale: 'en',
		resources: [
			{ path: 'locales/en.poly', text: enSource },
			{ path: 'locales/fr.poly', text: frSource },
		],
	});

	for (const { path, content } of files) {
		// ...
	}

	for (const warning of warnings) {
		console.warn(formatDiagnostic(warning));
	}
} catch (error) {
	if (!(error instanceof CompileError)) {
		throw error;
	}
	console.error(formatDiagnostic(error));
	process.exitCode = 1;
}