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
278 changes: 278 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,284 @@ per-version change log, see [CHANGELOG.md](./CHANGELOG.md).

---

## 2.0.0 release candidates → next

`@ribajs/i18n` was rewritten. A pure `Translator` resolves a key through a
fallback chain, `LocalesService` owns the language state (detection,
persistence, source language) and only the loaders know how the messages of one
language arrive. Failures are events and rejections instead of return values,
and the markup you wrote is the source language.

### @ribajs/i18n

#### Constructor arguments: three booleans → options object

The booleans `doNotRetranslateDefaultLanguage`, `showMissingTranslation` and
`autoDetectLangcode` are gone. Pass a `LocalesOptions` object instead,
`LocalesRestService` adds `loadSourceCatalog` to it.

| Old flag | New |
| --------------------------------- | ---------------------------------------------------------- |
| `doNotRetranslateDefaultLanguage` | removed, `sourceLangcode` names the language of the markup |
| `showMissingTranslation: true` | `missing: "marker"` |
| `autoDetectLangcode: true` | `detect: ["navigator", "html"]` |

```ts
// before
new LocalesRestService("/locales.json", false, true, true);

// after
new LocalesRestService("/locales.json", {
langcodes: ["en", "de"],
detect: ["query", "storage", "html", "navigator"],
persist: true,
missing: "marker",
});
```

`detect` is read in the given order and the first match wins, its default is
`["html"]`. `persist` writes the chosen language to `localStorage`
(`storageKey`, default `riba:i18n:langcode`), `queryParam` (default `lang`)
serves `detect: ["query"]` and `syncHtmlLang: false` stops writing the `lang`
attribute of the html element. Every access to `document`, `window`,
`navigator` and `localStorage` is guarded, so the service also runs without a
DOM (SSR).

The base class needs the `langcodes` option,
`LocalesStaticService` falls back to the keys of its catalog. Its `id`
argument is gone:

```ts
// before
new LocalesStaticService(catalog, "main", false, false, true);
// after
new LocalesStaticService(catalog, { detect: ["navigator", "html"] });
```

#### Instance access: `getInstance()` → `I18nService.getLocalesService()`

`LocalesRestService.getInstance(url)`, `LocalesStaticService.getInstance(id)`
and both `instances` maps are removed, and a constructor no longer hands back a
shared instance. Read the registered service, or keep your own reference:

```ts
// before
const localesService = LocalesRestService.getInstance("/locales.json");

// after
import { I18nService } from "@ribajs/i18n";
const localesService = I18nService.getLocalesService();
```

`i18nModule.init({ localesService })` registers the service and calls its
`init()`; registering a different service afterwards throws until
`I18nService.reset()` was called.

#### `get()` / `getByCurrentLang()` → `translate(path, vars?, langcode?)`

`translate()` is synchronous, takes the key without the langcode and accepts a
dot path or an array of segments. The langcode is the last argument and
defaults to the active language, so the `force` argument has no equivalent:

```ts
// before
await localesService.get([langcode, "form", "newsletter", "title"], { name });
await localesService.getByCurrentLang(["form", "newsletter", "title"]);

// after
localesService.translate("form.newsletter.title", { name });
localesService.translate(["form", "newsletter", "title"], { name }, "de-AT");
```

- `translate()` returns `undefined` when no language of the fallback chain has
the key and applies the `missing` option.
- `lookup()` reads without the `missing` option and without emitting `missing`.
- `has()` answers the same question as a boolean.
- All three throw before `init()` has resolved; await `init()` or `whenReady()`
first.

#### `getAvailableLangcodes()` is synchronous

```ts
// before
const langcodes: Langcode[] = await localesService.getAvailableLangcodes();
// after
const langcodes: Langcode[] = localesService.getAvailableLangcodes();
```

The list is a read of the configured languages, so it no longer triggers a
request.

#### `setLangcode()` is async and rejects

```ts
// before
localesService.setLangcode("de");
localesService.setLangcode("de", true); // initial

// after
await localesService.setLangcode("de");
```

The switch loads the messages of the language first, so `changed` fires when
translations can be read synchronously. It rejects for a langcode that is not
available and keeps the current language. The `initial` argument is gone:
`changed` fires only when the language really changed and only for a user
choice, never for the detected one. `preload("de")` loads a language ahead of a
switch.

#### Event arguments

```ts
// before
localesService.event.on("ready", (langcode, translationNeeded) => {}, this);
localesService.event.on("changed", (langcode, initial) => {}, this);

// after
const offReady = localesService.on("ready", (langcode) => {});
const offChanged = localesService.on(
"changed",
(langcode, previousLangcode) => {},
);
offReady();
```

- `ready(langcode)` — the second argument `translationNeeded` is gone.
- `changed(langcode, previousLangcode)` — the second argument is the previous
language, not an `initial` flag.
- New: `missing(path, langcode)` and `error(error)`.
- `service.on(name, callback)` returns the unsubscribe function and is typed
with `LocalesEvents`. `service.event` is still the raw `EventDispatcher`, but
it is no longer registered under the global `i18n` namespace, so
`EventDispatcher.getInstance("i18n")` does not return it. `dispose()` drops
all listeners and cached messages.

#### Errors: return value → events and rejection

`get()` caught its own errors and returned the `Error` as a value, `init()`
reset `ready` and returned the error as well:

```ts
// before
const locale = await localesService.get(["de", "form", "title"]);

// after
localesService.on("error", (error) => console.error(error));
try {
await localesService.init();
} catch (error) {
console.error(error);
}
```

`init()` is idempotent, rejects and emits `error`; the service stays not ready.
`setLangcode()` emits `error`, rejects and leaves the language unchanged.

#### `LocalesRestService`: one request per language, plus hooks

```ts
// before: one file with all languages, read once
new LocalesRestService("/locales.json", false, false, true);

// after: the url may be a function of the langcode
new LocalesRestService((langcode) => `/locales/${langcode}.json`, {
langcodes: ["en", "de"],
});
```

- A language is requested the first time it is needed and cached afterwards.
Requests to the same URL are shared, so several languages in one file still
cost one request.
- The catalog of the source language is not requested unless
`loadSourceCatalog: true` — the markup already is the source language.
- The generic service no longer appends `?shop=`.
[`@ribajs/shopify-tda`](./packages/shopify-tda/src/services/locales.service.ts)
adds it in its own `buildUrl`.

Subclasses use the new hooks instead of patching the request:

```ts
// one file with all languages
class MyLocalesService extends LocalesRestService {
protected buildUrl(url: string, langcode: string) {
return `${url}?lang=${langcode}`;
}

protected parse(body: unknown, langcode: string) {
return (body as Record<string, MessageTree>)[langcode];
}

protected async resolveLangcodes() {
const body = await this.fetchJSON(this.urlFor(this.sourceLangcode));
return Object.keys(body as Record<string, MessageTree>);
}
}
```

`parseTemplateVars()` and `parseLocalVars()` were removed from the base class;
the markup helpers are now the exported functions `parseTemplateVars()` and
`parseInlineTranslations()` in
[`packages/i18n/src/utils/template-vars.ts`](./packages/i18n/src/utils/template-vars.ts).
`getInitialLangcode()` is now `getSourceLangcode()`; `getHTMLLangcode()` and
`getBrowserLangcode()` are gone, the service reads the `lang` attribute and the
browser languages itself. A custom service implements
`protected load(langcode): Promise<MessageTree | undefined>` instead of the old
`protected getAll(): Promise<any>`.

#### Missing keys keep the source markup

`rv-i18n-*` remembers the content the element had when the binder bound and
restores it when the source language is active or a key has no translation, so
a missing key no longer leaves the text of the previous language in place. The
`missing` option decides what happens instead:

| `missing` | `translate()` returns |
| ------------------ | ----------------------------------------------- |
| `"keep"` (default) | `undefined`, the binder keeps the source markup |
| `"marker"` | `translation missing: "path.to.key"` |
| `"throw"` | throws |

An inline `<template lang="de">` is matched by language variant now, so it also
serves `de-AT`. `<template lang="default">`, `<template name="…">` vars and
`data-*` vars work as before.

#### Empty strings are valid translations

A message that exists is a message, so an empty string is a translation and not
a missing key:

```json
{ "en": { "cart": { "note": "" } } }
```

The old code tested `if (!locale)` and treated `""` as missing.

#### The `t` formatter is synchronous

`read()` returned a promise before (and `null` when no language was active), and a
key without a translation never produced a value. It now reads synchronously
and renders the key itself when nothing translates it. Arguments are a langcode
and/or a keypath with variables:

```html
<!-- before -->
<p>{ 'examples.newsletter.title' | t }</p>

<!-- after -->
<p>{ 'examples.newsletter.title' | t }</p>
<p>{ 'cart.items' | t 'de' }</p>
<p>{ 'cart.items' | t vars }</p>
<p>{ 'cart.items' | t 'de' vars }</p>
```

Plural forms are selected by the `count` variable, `zero`, `one` and `two` match
the exact number first, everything else follows `Intl.PluralRules` of the
language. A key that is missing renders as the key path until the service is
ready, then as the key path again. `LocalVar` is now `LocalVars` and
`LocalPluralization` is now `PluralForms`.

---

## 1.x → 2.0.0

Riba.js 2.0 is an ESM-only, modernized baseline. Downstream projects need to
Expand Down
73 changes: 70 additions & 3 deletions infra/doc/src/doc/reference/i18n/binders/i18n-[type].md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Gets the (current selected) translation of a keypath string from your locales object (over the TranslationService).
The translation is set as `html`, `text` or `value` depending on the passed type, available types are `html`, `text` and `value`.
If the type is not known, the translation is instead set as an attribute with the given name, a useful example for this could be the `placeholder` attribute.
Sets the translation of a key path as `text`, `html`, `value` or any attribute: `rv-i18n-<target>="'<key path>'"`. The value of the binder is the key path as a quoted string, the target comes from the attribute name.

| Binder | Sets |
| ------ | ---- |
| `rv-i18n-text` | `textContent` |
| `rv-i18n-html` | `innerHTML`, for a message that contains markup |
| `rv-i18n-value` | `value` of an input, `innerHTML` on a `contenteditable` element |
| `rv-i18n-<attributeName>` | the attribute of that name, e.g. `rv-i18n-placeholder` |

The binder reads the messages from the registered `LocalesService` and re-renders on its `ready` and `changed` events; the listeners are removed when the view is unbound.

### i18n-text

Expand Down Expand Up @@ -49,3 +56,63 @@ If the type is not known, the translation is instead set as an attribute with th
</rv-example-tabs>
</template>
</rv-bind-content>

### Source restore

The content an element has when the binder binds is its **source**: the language the markup is written in (option `sourceLangcode`). The source is restored when the active language has no message for the key, so a page that is only partly translated stays readable, and switching back to the source language gives your markup back unchanged:

```html
<h1 rv-i18n-text="'examples.newsletter.title'">Sign up for the newsletter</h1>
<!-- source language active: "Sign up for the newsletter"
de active: "Melde dich für den Newsletter an"
fr active: no message in fr or its fallbacks, the markup again -->
```

For an attribute target the source is the attribute as it was; an attribute that did not exist is removed again instead of being set to an empty string. Empty messages are applied, so a translation can blank a text.

What happens with a key that no language has is the `missing` option of the service: `keep` (default) keeps the source markup, `marker` writes `translation missing: "<key>"`, and `throw` logs the error to the console and keeps the markup, because the binder catches it.

### Inline templates

Instead of a catalog you can put the translations of one element into `<template lang="…">` children. The `{{ placeholders }}` of a message are filled from the `data-*` attributes of the element and from `<template name="…">` children:

```html
<p rv-i18n-html="'form.greeting'" data-name="Pascal">
Hello
<template lang="de">Hallo {{ name }}</template>
<template lang="default">Welcome</template>
</p>
```

```html
<p rv-i18n-html="'form.greeting'">
Hello
<template lang="de">Hallo {{ name }}</template>
<template name="name">Pascal</template>
</p>
```

```typescript
const locales = {
de: { form: { greeting: "Hallo {{ name }}" } },
};
```

The variable `count` provided by `data-count="3"` selects the plural form of a message, see [LocalesService](services/locales-base-service.md).

A `<template lang>` is matched like a language code, so `de-AT` uses the `<template lang="de">`. Templates and variables are read once when the binder binds, and only the `lang` attribute makes a template a translation — a `<template name>` is a variable, never a translation.

The element picks the first of these that exists:

1. the `<template lang>` of the active language
2. the message of the active language or of a fallback, with the variables interpolated
3. the `<template lang="default">`
4. the `missing` strategy of the service, which keeps the source markup by default

Without a key in the binder expression (`rv-i18n-html=""`) only the inline templates apply, which is the shortest way to translate one element:

```html
<p rv-i18n-html="">Hello<template lang="de">Hallo</template></p>
```

For the content of a mustache text node, use the [`t` formatter](../formatters/t.md) instead.
Loading
Loading