@worldware/msg
v0.12.0
Published
Message localization tooling
Readme
msg
A TypeScript library for managing internationalization (i18n) messages with support for message formatting, translation management, and localization workflows.
Overview
msg provides a structured approach to managing translatable messages in your application. It integrates with MessageFormat 2 (MF2) and ICU MessageFormat 1 (MF1) for message formatting and supports:
- Message Management: Organize messages into resources with keys and values
- Translation Loading: Load translations from external sources via customizable loaders
- Pseudo Localization: Request a pseudolocalized resource for UI testing via
getTranslation(pseudoLocale) - Message Formatting: Format messages with parameters using MessageFormat 2 (MF2) or MessageFormat 1 (MF1) syntax, or pass strings through unformatted
- Configurable Format: Choose
MF1,MF2, orNONEper project, resource, or message via an inheritableformatattribute (defaults toMF2) - Attributes & Notes: Attach metadata (language, direction, do-not-translate flags) and notes to messages
- Project Configuration: Configure projects with locale settings and translation loaders
Installation
npm install @worldware/msgCore Concepts
MsgProject
A project configuration that defines:
- Project name and version
- The default message
format(MF1|MF2|NONE, defaults toMF2) inherited by resources and messages - Source and target locales (with language fallback chains)
- Pseudo locale (for pseudolocalized output via
getTranslation) - A translation loader function
MsgResource
A collection of messages (extends Map<string, MsgMessage>) representing a resource bundle. Each resource has:
- A title/name
- Attributes (language, text direction, do-not-translate flag)
- Notes (descriptions, context, etc.)
- Messages indexed by key
MsgMessage
An individual message with:
- A key (identifier)
- A value (the message text, in MF2, MF1, or plain syntax depending on its
format) - Attributes (lang, dir, dnt, format)
- Notes
- Formatting methods that honor the resolved
format(MF2, MF1, or NONE)
Usage
Basic Setup
The following example matches the ES module output of the msg-cli command msg create project Main en zh fr --format MF1—a typical project file that loads translations from JSON under a translations directory (TRANSLATION_IMPORT_PATH may differ based on your directories.i18n / directories.l10n layout):
import { MsgProject } from '@worldware/msg';
const TRANSLATION_IMPORT_PATH = "../l10n/translations";
const loader = async (project, title, language) => {
const path = `${TRANSLATION_IMPORT_PATH}/${project}/${language}/${title}.json`;
try {
const module = await import(path, { with: { type: 'json' } });
return module.default;
} catch (error) {
console.warn(`Translations for locale ${language} could not be loaded.`, error);
return {
title,
attributes: { lang: language, dir: 'auto' },
notes: [],
messages: []
};
}
};
export default MsgProject.create({
project: { name: "Main", version: 1, format: "MF1" },
locales: {
sourceLocale: "en",
pseudoLocale: "en-XA",
targetLocales: {"en":["en"],"zh":["zh"],"fr":["fr"]}
},
loader
});When using this in your app, import the default export as your project (msg-cli scaffolds resources that import it via #i18n/projects/Main.js).
Creating a Resource
The following example matches the ES module output of msg-cli msg create resource Main Messages:
/** ESM module **/
import { MsgResource, getLang } from '@worldware/msg';
import project from '#i18n/projects/Main.js';
/** Create a MsgResource object */
export const resource = MsgResource.create({
title: 'Messages',
attributes: {
lang: 'en',
dir: 'ltr'
},
notes: [
{type: 'DESCRIPTION', content: 'This is the Messages resource.'}
]
}, project);
/**
* Add messages to the resource using add(key, value, attributes, notes)
* The add method is chainable.
*/
resource
.add('sampleKey', 'Sample value.', {}, [
{ type: 'DESCRIPTION', content: 'This is first message.' }
])
.add('sampleKey2', 'Hi, {name}', { dnt: true }, [
{ type: 'DESCRIPTION', content: 'This is the second message.' },
{ type: 'PARAMETERS', content: 'The {name} parameter holds the user name.' }
]);
/**
* An async function to get a translated version of the resource
* If the runtime language has not been set using `setLang()`,
* it will return the original resource
*/
export async function getMessages() {
return await resource.getTranslation(getLang());
}Formatting Messages
// Messages inherit MF1 from the Main project scaffold above
resource.get('sampleKey')?.format();
// Result: "Sample value."
resource.get('sampleKey2')?.format({ name: 'Alice' });
// Result: "Hi, Alice"
// Prefer getMessages() when you want the runtime locale (via setLang/getLang)
const messages = await getMessages();
messages.get('sampleKey2')?.format({ name: 'Alice' });Message Formats (MF1, MF2, NONE)
Every message is formatted according to its resolved format attribute:
MF2(default) — Unicode MessageFormat 2 syntax, e.g.Hello, {$name}!.MF1— ICU MessageFormat 1 syntax, e.g.{count, plural, one {# file} other {# files}}, formatted via@messageformat/icu-messageformat-1.NONE— the value is returned verbatim, with no parsing or interpolation.
The format is inheritable: a resource inherits its project's format unless it sets its own, and a message inherits its resource's format unless it sets its own. The default is MF2, so existing code keeps working unchanged. Use a TypeScript union ('MF1' | 'MF2' | 'NONE') — there is no enum.
lang, dir, and dnt inherit the same way on messages: omitted fields (or omitted attributes) take the resource's values, and an explicit per-message value still wins. Compact translation JSON can therefore list only overrides. create(), add(), translate(), and getTranslation() all apply this merge.
The following is a separate illustration of format inheritance and per-message overrides (not the CLI Main / Messages scaffold above):
import { MsgProject, MsgResource } from '@worldware/msg';
// A project whose messages are MF1 by default
const project = MsgProject.create({
project: { name: 'legacy-app', version: 1, format: 'MF1' },
locales: { sourceLocale: 'en', pseudoLocale: 'en-XA', targetLocales: { en: ['en'] } },
loader
});
const resource = MsgResource.create({
title: 'Files',
attributes: { lang: 'en', dir: 'ltr' } // inherits format: 'MF1' from the project
}, project);
resource.add('files', '{count, plural, one {# file} other {# files}}'); // MF1 (inherited)
resource.add('brand', 'msg {version}', { format: 'NONE' }); // passed through
resource.add('hi', 'Hello, {$name}!', { format: 'MF2' }); // MF2 (override)
resource.get('files')?.format({ count: 2 }); // "2 files"
resource.get('brand')?.format({ version: 1 }); // "msg {version}"
resource.get('hi')?.format({ name: 'Ada' }); // "Hello, Ada!"When serializing, an inherited format is omitted to keep output compact: a resource omits format when it equals the project's, and a message omits format when it equals its resource's.
Loading Translations
// Load a translation for a configured target locale (zh or fr in the Main project)
const zhResource = await resource.getTranslation('zh');
// The translated resource will have Chinese messages where available,
// falling back to the source messages for missing translations
// Or use the scaffolded helper (honors setLang() / getLang())
const messages = await getMessages();Language fallbacks and translation layering
The project's targetLocales maps each requested locale to a fallback chain: an array of locale codes ordered from least specific to most specific (e.g. base language first, then region-specific). For example, 'zh-HK': ['zh', 'zh-Hant', 'zh-HK'] means that when you request zh-HK, the chain is first zh, then zh-Hant, then zh-HK. You can get the chain for any locale with project.getTargetLocale(locale).
When you call resource.getTranslation(locale):
- The source resource (the resource you called it on) is the base.
- For each locale in that locale's chain, the project loader is called to load that locale's translation data.
- Each loaded dataset is layered onto the current result: messages in the new data add or override by key; keys missing in the new layer keep the value from the previous layer.
- The final resource is the result after all layers have been applied.
So for getTranslation('zh-HK') with chain ['zh', 'zh-Hant', 'zh-HK'], you get: source → then zh overlay → then zh-Hant overlay → then zh-HK overlay. Later entries in the chain override earlier ones for the same key; missing keys fall back to the previous layer (and ultimately to the source).
Pseudo Localization
When getTranslation is called with the project's pseudoLocale (e.g. en-XA), it returns a new resource with pseudolocalized message values—useful for testing UI layout and finding hardcoded strings without loading translation files. Each message is handled according to its resolved format:
- MF2 — only literal text parts are transformed; expressions like
{$name}stay intact. - MF1 — only human-readable content is transformed; ICU placeholders, plural/select structure,
#, and formatter styles (e.g.::currency/EUR) stay intact. - NONE — the whole string is transformed.
// Request pseudolocalized messages (project locales.pseudoLocale is 'en-XA')
const pseudoResource = await resource.getTranslation('en-XA');
// MF1 example: literals are accented; syntax is preserved
// "{count, plural, one {# file} other {# files}}"
// → "{count, plural, one {# ƒīŀḗ} other {# ƒīŀḗş}}"
const files = pseudoResource.get('files')?.format({ count: 2 });
// MF2 example: "Hello, {$name}!" → "Ħḗŀŀǿ, {$name}!"
const greeting = pseudoResource.get('sampleKey2')?.format({ name: 'Alice' });
// Result includes accented "Hello" with Alice substituted for {$name}Working with Attributes and Notes
// Add notes to messages (MF1 syntax, matching the Main project's format)
resource.add('complex-message', 'You have {count} items', {
lang: 'en',
dir: 'ltr',
dnt: false // do-not-translate flag
}, [
{
type: 'DESCRIPTION',
content: 'This message appears on the welcome screen'
},
{
type: 'CONTEXT',
content: 'Used when user first logs in'
}
]);
// Access attributes
const message = resource.get('complex-message');
console.log(message?.attributes.lang); // 'en'
console.log(message?.attributes.dir); // 'ltr'
console.log(message?.attributes.dnt); // falseSerialization
// Convert resource to JSON
const json = resource.toJSON();
// or without notes
const jsonWithoutNotes = resource.toJSON(true);
// Get data object
const data = resource.getData();
// Message objects in the output only include `attributes` when they differ from
// the resource's attributes, keeping the serialized data compactAPI Reference
MsgProject
Static Methods:
create(data: MsgProjectData): MsgProject- Create a new project instance
Properties:
project: MsgProjectSettings- Project name, version, and defaultformatlocales: MsgLocalesSettings- Locale configurationloader: MsgTranslationLoader- Translation loader functionformat: MsgFormat- The project-wide default format ('MF1' | 'MF2' | 'NONE'), defaulting to'MF2'; resources (and, through them, messages) inherit this value unless they specify their own
Methods:
getTargetLocale(locale: string): string[] | undefined- Returns the language fallback chain (array of locale codes) for the specified locale, orundefinedif the locale is not configured intargetLocales
MsgResource
Static Methods:
create(data: MsgResourceData, project: MsgProject): MsgResource- Create a new resource
Methods:
add(key: string, value: string, attributes?: MsgAttributes, notes?: MsgNote[]): MsgResource- Add a messageaddNote(type: NoteTypes, content: string): MsgResource- Add a note (returnsthisfor chaining)translate(data: MsgResourceData): MsgResource- Create a translated versiongetTranslation(lang?: string): Promise<MsgResource>- Load and apply translations. Whenlangmatches the project'spseudoLocale, returns a resource with message values pseudo-localized according to each message's resolvedformat(MF1/MF2/NONE) instead of loading from the loader. Whenlangis omitted, returns a clone of the current resource.getProject(): MsgProject- Returns the project instance associated with the resourcegetData(stripNotes?: boolean): MsgResourceData- Get resource data. Message objects in the output omitattributeswhen they match the resource's attributes (to avoid redundancy). The resource'sformatis omitted when it equals the project's, and a message'sformatis omitted when it equals the resource'stoJSON(stripNotes?: boolean): string- Serialize to JSON
Properties:
title: string- Resource titleattributes: MsgAttributes- Resource attributesnotes: MsgNote[]- Resource notes
MsgMessage
Static Methods:
create(data: MsgMessageData): MsgMessage- Create a new message
Methods:
format(data: Record<string, any>, options?: MessageFormatOptions): string- Format the message according to its resolvedformat:MF2uses MessageFormat 2,MF1compiles via@messageformat/icu-messageformat-1, andNONEreturns the raw valueformatToParts(data: Record<string, any>, options?: MessageFormatOptions): MessagePart[]- Format to parts (forNONE, a single{ type: 'text', value }part)addNote(type: NoteTypes, content: string): MsgMessage- Add a note (returnsthisfor chaining)getData(stripNotes?: boolean): MsgMessageData- Get message datatoJSON(stripNotes?: boolean): string- Serialize to JSON
Properties:
key: string- Message keyvalue: string- Message valueattributes: MsgAttributes- Message attributes (lang, dir, dnt, format)notes: MsgNote[]- Message notes
Types
MsgFormat-'MF1' | 'MF2' | 'NONE'; the formatting syntax for a message.MsgAttributes-{ lang?: string; dir?: string; dnt?: boolean; format?: MsgFormat }. Omitted or emptydirresolves to'auto'.
Pseudo-localization helpers
Exported from @worldware/msg for advanced use (also used internally by MsgResource.getTranslation):
pseudoLocalize(source, format, options?)- Dispatch byMsgFormatpseudoLocalizeMF1(source, options?)/pseudoLocalizeMF2(source, options?)/pseudoLocalizeNone(source, options?)- Format-specific helpersPseudoLocalizeOptions-{ strategy?: 'accented' | 'bidi' }
Development
# Run tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run coverage
# Build the project
npm run buildLicense
See LICENSE file for details.
