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
e96c535
chore: bedre tester for å utelukke feil ved oppgradering
morten-falstad Sep 7, 2026
a21f4ff
bump vitest og vitest coverage
morten-falstad Sep 7, 2026
0e421cd
bump husky to 9.1.7
morten-falstad Sep 7, 2026
f1745e1
bump changesets, cli-lib, chokidar og commander
morten-falstad Sep 7, 2026
70428b6
chore: bump ts, prettier, formatjs, lint-staged
morten-falstad Sep 7, 2026
ed637ce
chore: add engines field
morten-falstad Sep 7, 2026
280cae2
convert to minor
morten-falstad Sep 7, 2026
8ffbaaa
test: make the suite report honest coverage and stop flaking
morten-falstad Sep 7, 2026
7b41085
refactor(cli): define the build options once for build and watch
morten-falstad Sep 7, 2026
772bcbf
chore(husky): drop the husky 8 shim from the pre-commit hook
morten-falstad Sep 7, 2026
6911c33
chore(deps): fix where the formatjs and json-stable-stringify deps live
morten-falstad Sep 7, 2026
42b1e9c
chore(ts): target es2025 and module node20
morten-falstad Sep 7, 2026
93d2ef6
fix(build): sort messages by id so generated bundles are reproducible
morten-falstad Sep 7, 2026
d7808d3
refactor(utils): read the message tree with a recursive readdir
morten-falstad Sep 7, 2026
3943328
fix(build): explain why --ast cannot be combined with -f script
morten-falstad Sep 7, 2026
80fb36e
fix(logger): open the WARN colour with an escape character
morten-falstad Sep 7, 2026
1cdb124
docs: document the message file convention and the look-up-table
morten-falstad Sep 7, 2026
da084ca
chore(changeset): release as major
morten-falstad Sep 7, 2026
af38109
updaet codeowners
morten-falstad Sep 7, 2026
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