Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
11 changes: 11 additions & 0 deletions .changeset/quiet-lions-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@code-obos/i18n-tools': major
---

Oppdaterer alle avhengigheter til siste major, og gjør generert output deterministisk.

WHAT: chokidar 4 -> 5, commander 14 -> 15, @formatjs/cli-lib 8 -> 9, vitest 4 -> 5, typescript 5 -> 7, husky 8 -> 9, lint-staged 13 -> 17, @changesets/cli 2 -> 3, samt patch-bump av resten. `@formatjs/cli-lib` er flyttet til devDependencies siden den bare brukes som type, og `json-stable-stringify` er deklarert eksplisitt fordi den faktisk brukes i runtime. Meldinger sorteres nå etter id før bundling, `--ast` kombinert med `-f script` gir en forklarende feilmelding i stedet for en JSON-parsefeil, build og watch deler opsjonsdefinisjonene sine, og WARN-logglinjen er faktisk gul (fargekoden startet med \x0b i stedet for \x1b, så terminaler skrev ut `[33m` som tekst). I tillegg tester for CLI-parsing, watch-kommandoen, chokidar sitt API og sorteringen.

WHY: Utdaterte pakker. Major fordi pakken nå krever node >= 22.12 og dermed slutter å støtte node 20: commander 15 krever det, og `engines` håndhever det. Rekkefølgen i `bundle_<locale>.json` og `lut.ts` fulgte tidligere rekkefølgen filsystemet ga ut katalogoppføringer i, som er sortert på macOS men vilkårlig på ext4. Samme meldinger kunne dermed gi ulike filer på en utviklermaskin og i CI.

HOW: Sorteringen endrer rekkefølgen, ikke innholdet - verifisert mot 1268 meldingsfiler i nybolig-nettsider: identisk nøkkelsett, identiske verdier, og `bundle_<locale>.compiled.json` er bit-identisk. Konsumenter som gitignorerer generert output og regenererer den i et predev-steg, slik nybolig-nettsider gjør, merker ingen forskjell. Konsumenter som committer den, får en engangsomstokking til sortert rekkefølge. README er skrevet om: separatoren i eksemplene var fortsatt `/` fra før 4.0.0, og navnekonvensjonen for meldingsfiler og bruken av `createIntlLUT` var udokumentert.
3 changes: 0 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged
2 changes: 1 addition & 1 deletion CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1 @@
* @code-obos/boligjakt
* @code-obos/team-boligdrom
108 changes: 97 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,37 @@ Utility for building and compiling I18N bundles.

The project uses tools from [FormatJS](https://formatjs.io/), if your project uses other tools it may not work for you.

Requires Node 22.12 or newer.

**Install globally:**
`npm install @code-obos/i18n-tools -g`

**Install in project:**
`npm install @code-obos/i18n-tools --save-dev`

## Breaking Changes (Dependency Modernization)
The generated look-up-table imports from `@formatjs/intl`, so add that too if you use `--lut`:
`npm install @formatjs/intl`

See [CHANGELOG.md](./CHANGELOG.md) for changes between versions.

## Writing messages

One message per file. The filename decides both the message id and the locale:

The latest dependency upgrade introduces breaking changes that must be treated as a major release:
```
messages/
close_nb.txt -> id "close", locale nb
close_sv.txt -> id "close", locale sv
camel-cased-name_en.txt -> id "camel-cased-name", locale en
group-by-page/title_en.txt -> id "group-by-page.title", locale en
```

1. The package now runs as ESM (`"type": "module"`).
2. Node.js must be modern enough for the upgraded dependency set (recommended: active LTS).
3. `@formatjs/intl` peer dependency is upgraded from `^2.8.0` to `^4.1.12`.
- The name must end in `_<locale>` before the extension. Files without a locale suffix, such as `README.md`, are ignored.
- `.txt`, `.html` and `.md` are all picked up. Use `.html` when the message contains tags.
- Folders are preserved in the id, separated by `.`, and become nested objects in the look-up-table.
- The file content is an [ICU message](https://formatjs.io/docs/core-concepts/icu-syntax/). Plurals, selects, tags and date/time skeletons all work.

These changes do not alter the expected CLI output behavior in this repository (verified by tests), but they can affect downstream integrations that depend on CommonJS behavior or older FormatJS versions.
Ids are always emitted in sorted order, so the generated files do not change just because a different filesystem hands out directory entries in a different order.

## Usage

Expand Down Expand Up @@ -53,8 +69,8 @@ Options:
-f, --format <format> Output format (choices: "script", "json", "jsonlut", "formatjs", default: "formatjs")
--typescript Output script files with typescript (default: false)
--strict Run validation before bundling (default: false)
--ast Compile generated bundles (only availble with formatjs) (default: false)
--lut Generate look-up-table (only availble with formatjs) (default: false)
--ast Compile generated bundles into AST (not available with -f script) (default: false)
--lut Generate look-up-table (intended for the formatjs format) (default: false)
-t, --timeZone <timezone> Inject timezone into date/time skeletons
-h, --help display help for command

Expand All @@ -64,13 +80,38 @@ Examples:
i18n-tool build example/messages example/compiled --ast --lut --typescript
```

`watch` takes the same arguments and options as `build`, rebuilds on every change, and lets you press `f` to run the fixer without stopping.

A typical project wires both up as scripts:

```json
{
"scripts": {
"intl": "i18n-tool build src/intl/messages src/intl/compiled --strict --ast --lut --typescript --timeZone Europe/Oslo",
"intl:watch": "i18n-tool watch src/intl/messages src/intl/compiled --strict --ast --lut --typescript --timeZone Europe/Oslo"
}
}
```

#### What gets written

With `--ast --lut` and the default `formatjs` format, `outDir` ends up with:

| File | Written by | Purpose |
| ------------------------------- | ---------- | -------------------------------------------------------------------------- |
| `bundle_<locale>.json` | always | the messages, as ICU source strings |
| `bundle_<locale>.compiled.json` | `--ast` | the same messages pre-parsed to AST, this is what you hand to `createIntl` |
| `lut.ts` / `lut.js` | `--lut` | typed helper functions, one per message |

`--ast` skips the runtime ICU parsing and is what you want in production. `-t/--timeZone` writes the timezone into every date/time skeleton while compiling, so formatting does not depend on the timezone of the machine rendering the page.

#### The different formats;

**script** generates `bundle_[locale].js`

```javascript
const texts = {
'group-by-page/title': 'The folder structure is preserved in the look-up-table.',
'group-by-page.title': 'The folder structure is preserved in the look-up-table.',
};

export default texts;
Expand All @@ -82,7 +123,7 @@ Use `--typescript` to change the file extension

```json
{
"group-by-page/title": "The folder structure is preserved in the look-up-table."
"group-by-page.title": "The folder structure is preserved in the look-up-table."
}
```

Expand All @@ -100,12 +141,53 @@ Use `--typescript` to change the file extension

```json
{
"group-by-page/title": {
"group-by-page.title": {
"defaultMessage": "The folder structure is preserved in the look-up-table."
}
}
```

### Using the look-up-table

`--lut` generates a `createIntlLUT` function. Every message becomes a function whose arguments are derived from the ICU message itself, so a missing or misspelled placeholder is a type error rather than a blank spot on the page:

```typescript
export function createIntlLUT(intl: IntlShape<React.ReactNode>) {
return {
argument: (args: { me: string; other: string }) =>
intl.formatMessage({ id: 'argument' }, { me: args.me, other: args.other }),
camelCasedName: () => intl.formatMessage({ id: 'camel-cased-name' }, {}),
groupByPage: {
title: () => intl.formatMessage({ id: 'group-by-page.title' }, {}),
},
};
}
```

Note that ids keep the filename as written, while the look-up-table keys are camelCased: `camel-cased-name_en.txt` is reached as `intl.camelCasedName()`.

Wire it up once with the compiled bundle:

```typescript
import { createIntl } from '@formatjs/intl';
import messages from './intl/compiled/bundle_nb.compiled.json';
import { createIntlLUT } from './intl/compiled/lut';

const intl = createIntlLUT(
createIntl({
locale: 'nb',
defaultLocale: 'nb',
messages,
timeZone: 'Europe/Oslo',
}),
);

intl.groupByPage.title();
intl.argument({ me: 'Alice', other: 'Bob' });
```

Argument types follow the ICU message: `{count, plural, ...}` becomes `number`, `{when, date, ::ddMMM}` becomes `Date`, `{kind, select, A {..} B {..}}` becomes `'A' | 'B' | 'other' | string`, and a tag such as `<p>` becomes a `FormatXMLElementFn<React.ReactNode>` you can pass a renderer for.

### Validate: check if all files are present for all locales

```shell
Expand All @@ -120,6 +202,8 @@ Options:
-h, --help display help for command
```

Exits with an error and lists the missing ids per locale. `build --strict` and `watch --strict` run the same check before bundling.

### Fix: create missing files

```shell
Expand All @@ -133,3 +217,5 @@ Arguments:
Options:
-h, --help display help for command
```

Creates the missing files with the placeholder content `[<locale>] TODO`, ready to be translated.
2 changes: 1 addition & 1 deletion example/compiled/bundle_en.compiled.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
"value": "start_date"
}
],
"group-by-page/title": [
"group-by-page.title": [
{
"type": 0,
"value": "The folder structure is preserved in the look-up-table."
Expand Down
2 changes: 1 addition & 1 deletion example/compiled/bundle_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"datetime": {
"defaultMessage": "I started this project at {start_time,time,::HHmm} on {start_date,date,::ddMMM}"
},
"group-by-page/title": {
"group-by-page.title": {
"defaultMessage": "The folder structure is preserved in the look-up-table."
},
"literal": {
Expand Down
2 changes: 1 addition & 1 deletion example/compiled/lut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function createIntlLUT(intl: IntlShape<React.ReactNode>) {
"datePluralAndTag": (args: { div: FormatXMLElementFn<React.ReactNode>;gender: 'male' | 'female' | 'other' | string;p: FormatXMLElementFn<React.ReactNode>;date: Date }) => intl.formatMessage({ id: 'datePluralAndTag' }, {div: args.div, gender: args.gender, p: args.p, date: args.date}),
"datetime": (args: { start_time: Date;start_date: Date }) => intl.formatMessage({ id: 'datetime' }, {start_time: args.start_time, start_date: args.start_date}),
"groupByPage": {
"title": () => intl.formatMessage({ id: 'group-by-page/title' }, {}),
"title": () => intl.formatMessage({ id: 'group-by-page.title' }, {}),
},
"literal": () => intl.formatMessage({ id: 'literal' }, {}),
"number": (args: { smallest: number;biggest: number;bagel_price: number }) => intl.formatMessage({ id: 'number' }, {smallest: args.smallest, biggest: args.biggest, bagel_price: args.bagel_price}),
Expand Down
Loading