Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Runs the Definition-of-done checks on pull requests and pushes to main
# so every PR has green CI (tests, type-check, and build) before review.

name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx tsc --noEmit
- run: npm test
- run: npm run build
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ out
.nuxt
dist

# Generated API documentation (regenerate with `npm run docs`)
docs

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
Expand Down
98 changes: 98 additions & 0 deletions GETTING_STARTED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Getting Started with `@worldware/msg`

This guide walks you from installation to formatting your first messages,
including choosing between MessageFormat 2 (MF2), MessageFormat 1 (MF1), and
unformatted (`NONE`) strings. For the full API, see [`README.md`](./README.md).

## 1. Install

```bash
npm install @worldware/msg
```

The MF1 and MF2 formatters (`messageformat` and
`@messageformat/icu-messageformat-1`) are bundled as dependencies — you do not
need to install them separately.

## 2. Create a project

A project holds shared configuration: locales, a translation loader, and the
default message `format` that resources and messages inherit.

```typescript
import { MsgProject } from '@worldware/msg';

const loader = async (project, title, language) => {
const path = `../l10n/translations/${project}/${language}/${title}.json`;
const module = await import(path, { with: { type: 'json' } });
return module.default;
};

const project = MsgProject.create({
project: { name: 'my-app', version: 1 }, // format defaults to 'MF2'
locales: {
sourceLocale: 'en',
pseudoLocale: 'en-XA',
targetLocales: { en: ['en'], es: ['es'] }
},
loader
});
```

## 3. Create a resource and add messages

A resource is a keyed collection of messages. It inherits the project's
`format` unless you set your own on the resource or a message.

```typescript
import { MsgResource } from '@worldware/msg';

const resource = MsgResource.create({
title: 'CommonMessages',
attributes: { lang: 'en', dir: 'ltr' }, // inherits format: 'MF2'
messages: [
{ key: 'greeting', value: 'Hello, {$name}!' }
]
}, project);

resource.add('itemCount', 'You have {$count} items');
```

## 4. Format messages

```typescript
resource.get('greeting')?.format({ name: 'Alice' }); // "Hello, Alice!"
```

## 5. Choose a format

Set `format` to `'MF1'`, `'MF2'`, or `'NONE'` on a project, resource, or
message. It is a TypeScript union type (there is no enum), and lower levels
inherit from higher levels unless they override it.

```typescript
// MF1 syntax (ICU MessageFormat 1)
resource.add('files', '{count, plural, one {# file} other {# files}}', { format: 'MF1' });
resource.get('files')?.format({ count: 3 }); // "3 files"

// NONE — returned verbatim, no interpolation
resource.add('token', 'build:{sha}', { format: 'NONE' });
resource.get('token')?.format({ sha: 'abc' }); // "build:{sha}"
```

Because the default is `MF2`, existing MF2 code keeps working with no changes.

## 6. Load translations

```typescript
const spanish = await resource.getTranslation('es');
```

Translations preserve each message's `format`, so an MF1 or `NONE` message stays
MF1 or `NONE` after translation unless the translation overrides it.

## Next steps

- Read [`README.md`](./README.md) for the complete API, serialization rules,
language fallback chains, and pseudo-localization.
- Generate browsable API docs locally with `npm run docs` (output in `docs/`).
64 changes: 54 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ A TypeScript library for managing internationalization (i18n) messages with supp

## Overview

`msg` provides a structured approach to managing translatable messages in your application. It integrates with [MessageFormat 2](https://messageformat.unicode.org/) (MF2) for advanced message formatting and supports:
`msg` provides a structured approach to managing translatable messages in your application. It integrates with [MessageFormat 2](https://messageformat.unicode.org/) (MF2) and [ICU MessageFormat 1](https://messageformat.github.io/) (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) syntax
- **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`, or `NONE` per project, resource, or message via an inheritable `format` attribute (defaults to `MF2`)
- **Attributes & Notes**: Attach metadata (language, direction, do-not-translate flags) and notes to messages
- **Project Configuration**: Configure projects with locale settings and translation loaders

Expand All @@ -25,6 +26,7 @@ npm install @worldware/msg

A project configuration that defines:
- Project name and version
- The default message `format` (`MF1` | `MF2` | `NONE`, defaults to `MF2`) inherited by resources and messages
- Source and target locales (with language fallback chains)
- Pseudo locale (for pseudolocalized output via `getTranslation`)
- A translation loader function
Expand All @@ -41,10 +43,10 @@ A collection of messages (extends `Map<string, MsgMessage>`) representing a reso

An individual message with:
- A key (identifier)
- A value (the message text, supports MessageFormat 2 (MF2) syntax)
- Attributes (lang, dir, dnt)
- A value (the message text, in MF2, MF1, or plain syntax depending on its `format`)
- Attributes (lang, dir, dnt, format)
- Notes
- Formatting methods using MessageFormat 2
- Formatting methods that honor the resolved `format` (MF2, MF1, or NONE)

## Usage

Expand Down Expand Up @@ -128,6 +130,42 @@ const formatted = greetingMsg?.format({ name: 'Alice' });
// Result: "Hello, Alice!"
```

### Message Formats (MF1, MF2, NONE)

Every message is formatted according to its resolved `format` attribute:

- `MF2` (default) — [Unicode MessageFormat 2](https://messageformat.unicode.org/) syntax, e.g. `Hello, {$name}!`.
- `MF1` — [ICU MessageFormat 1](https://messageformat.github.io/) syntax, e.g. `{count, plural, one {# file} other {# files}}`, formatted via [`@messageformat/icu-messageformat-1`](https://www.npmjs.com/package/@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.

```typescript
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

```typescript
Expand Down Expand Up @@ -214,9 +252,10 @@ const data = resource.getData();
- `create(data: MsgProjectData): MsgProject` - Create a new project instance

**Properties:**
- `project: MsgProjectSettings` - Project name and version
- `project: MsgProjectSettings` - Project name, version, and default `format`
- `locales: MsgLocalesSettings` - Locale configuration
- `loader: MsgTranslationLoader` - Translation loader function
- `format: 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, or `undefined` if the locale is not configured in `targetLocales`
Expand All @@ -231,7 +270,7 @@ const data = resource.getData();
- `translate(data: MsgResourceData): MsgResource` - Create a translated version
- `getTranslation(lang: string): Promise<MsgResource>` - Load and apply translations. When `lang` matches the project's `pseudoLocale`, returns a resource with pseudolocalized message values instead of loading from the loader.
- `getProject(): MsgProject` - Returns the project instance associated with the resource
- `getData(stripNotes?: boolean): MsgResourceData` - Get resource data. Message objects in the output omit `attributes` when they match the resource's attributes (to avoid redundancy)
- `getData(stripNotes?: boolean): MsgResourceData` - Get resource data. Message objects in the output omit `attributes` when they match the resource's attributes (to avoid redundancy). The resource's `format` is omitted when it equals the project's, and a message's `format` is omitted when it equals the resource's
- `toJSON(stripNotes?: boolean): string` - Serialize to JSON

**Properties:**
Expand All @@ -245,18 +284,23 @@ const data = resource.getData();
- `create(data: MsgMessageData): MsgMessage` - Create a new message

**Methods:**
- `format(data: Record<string, any>, options?: MessageFormatOptions): string` - Format the message
- `formatToParts(data: Record<string, any>, options?: MessageFormatOptions): MessagePart[]` - Format to parts
- `format(data: Record<string, any>, options?: MessageFormatOptions): string` - Format the message according to its resolved `format`: `MF2` uses MessageFormat 2, `MF1` compiles via `@messageformat/icu-messageformat-1`, and `NONE` returns the raw value
- `formatToParts(data: Record<string, any>, options?: MessageFormatOptions): MessagePart[]` - Format to parts (for `NONE`, a single `{ type: 'text', value }` part)
- `addNote(note: MsgNote): void` - Add a note
- `getData(stripNotes?: boolean): MsgMessageData` - Get message data
- `toJSON(stripNotes?: boolean): string` - Serialize to JSON

**Properties:**
- `key: string` - Message key
- `value: string` - Message value
- `attributes: MsgAttributes` - Message attributes (lang, dir, dnt)
- `attributes: 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 }`.

## Development

```bash
Expand Down
Loading
Loading