@nxgt/mail-i18n 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,421 @@
1
+ # Templates
2
+
3
+ This page is for writing a template once for every locale: what `t`, `locale`
4
+ and `placeholder` do, how templates a package ships are built with yours,
5
+ what fails the build, and what `maizzle serve` and `maizzle build` do with it.
6
+
7
+ ```vue
8
+ <!-- emails/verify-email.vue -->
9
+ <template>
10
+ <Html :lang="locale">
11
+ <Body>
12
+ <Container>
13
+ <Heading>{{ t('verifyEmail.title') }}</Heading>
14
+ <Text>{{ t('verifyEmail.greeting', { name: placeholder('name') }) }}</Text>
15
+ <Text>{{ t('verifyEmail.expires', { minutes: 15 }) }}</Text>
16
+ <Text>{{ t('verifyEmail.sentOn', { at: new Date(Date.UTC(2026, 0, 2, 12)) }) }}</Text>
17
+ <Button :href="placeholder('link')">{{ t('verifyEmail.action') }}</Button>
18
+ </Container>
19
+ </Body>
20
+ </Html>
21
+ </template>
22
+ ```
23
+
24
+ With the catalogues of [Catalogues](catalogues.md), `maizzle build` writes, in
25
+ `dist/fr/verify-email.html`:
26
+
27
+ ```html
28
+ <html lang="fr" …>
29
+ <h1>Confirmez votre adresse e-mail</h1>
30
+ <p>Bonjour {{ name }},</p>
31
+ <p>Le lien expire dans 15 minutes.</p>
32
+ <p>Envoyé le 2 janvier 2026.</p>
33
+ <a href="{{ link }}">… Confirmer mon adresse …</a>
34
+ ```
35
+
36
+ and the same in English in `dist/en/verify-email.html`, each with its `.txt`.
37
+ Plurals and dates are formatted by each locale's rules.
38
+
39
+ ## What a template gets
40
+
41
+ The plugin sets three global properties before each render. They need no
42
+ import:
43
+
44
+ ```ts
45
+ // what the package declares for Vue's template checker
46
+ declare module 'vue' {
47
+ interface ComponentCustomProperties {
48
+ t<K extends TemplateKey>(key: K, ...args: TemplateArgs<K>): string;
49
+ readonly locale: string;
50
+ placeholder(name: string): string;
51
+ }
52
+ }
53
+ ```
54
+
55
+ `TemplateKey` is a key of your catalogues, and `TemplateArgs<K>` its
56
+ arguments, once the plugin has written their types — see
57
+ [Editor and type checking](editor.md). Before that, the template checker does
58
+ not know `t` at all; the build is not affected.
59
+
60
+ A component used by the template sees them too: they are global, and they
61
+ hold the locale being built.
62
+
63
+ ### `t(key, args?)`
64
+
65
+ Formats the message `key` in the locale being built. It checks each call
66
+ against the fallback locale's message, which declares every argument:
67
+
68
+ - the key must exist;
69
+ - every argument the message declares must be passed, even one this
70
+ locale's translation leaves out;
71
+ - no argument may be passed that the message does not use;
72
+ - each argument must be of its kind: a number for `{n, number}` or a plural,
73
+ a `Date` or a timestamp for a date, a string or a number for the rest. See
74
+ [Arguments](catalogues.md#arguments).
75
+
76
+ ```vue
77
+ <Text>{{ t('verifyEmail.expires', { minutes: 15 }) }}</Text>
78
+ <!-- 'The link expires in 15 minutes.' / 'Le lien expire dans 15 minutes.' -->
79
+ ```
80
+
81
+ The formatted message is text: Vue escapes it where it is written, as it
82
+ escapes any `{{ }}`.
83
+
84
+ ### `locale`
85
+
86
+ The locale this build of the template is in: `'en'`, `'fr'`, `'pt-BR'`.
87
+ Read it; never assign it.
88
+
89
+ ```vue
90
+ <Html :lang="locale">
91
+ ```
92
+
93
+ ```vue
94
+ <img :src="`/images/banner-${locale}.png`" alt="">
95
+ ```
96
+
97
+ ### `placeholder(name)`
98
+
99
+ Answers `{{ name }}`, a mark kept as it is through inlining, minification and
100
+ plain text, for the sending side to fill with a value only known then: a
101
+ name, a link, a code. `name` is `camelCase`.
102
+
103
+ **In text:**
104
+
105
+ ```vue
106
+ <Text>{{ placeholder('code') }}</Text>
107
+ <!-- <p>{{ code }}</p> -->
108
+ ```
109
+
110
+ **In an attribute** — bind it, with `:`:
111
+
112
+ ```vue
113
+ <Button :href="placeholder('link')">{{ t('verifyEmail.action') }}</Button>
114
+ <!-- <a href="{{ link }}"> -->
115
+ ```
116
+
117
+ A placeholder that starts the value of an `href`, a `src`, a `background`, a
118
+ `poster` or an `action` is recorded as a URL variable in the
119
+ [manifest](manifest.md). The sending side must fill it with a URL. One later
120
+ in the value, as `?token={{ token }}`, is an ordinary variable.
121
+
122
+ **In the text part only**: a placeholder inside Maizzle's `<Plaintext>`
123
+ block is in the `.txt` file alone, and is still one of the e-mail's
124
+ variables.
125
+
126
+ <a id="a-placeholder-as-an-argument"></a>
127
+ **As an argument** of a message, where the message uses it as a string:
128
+
129
+ ```vue
130
+ <Text>{{ t('verifyEmail.greeting', { name: placeholder('name') }) }}</Text>
131
+ <!-- <p>Bonjour {{ name }},</p> -->
132
+ ```
133
+
134
+ A placeholder is a string. It cannot fill a `number` or `date` argument,
135
+ because the plural rule or the date format would have to be applied to a
136
+ value that does not exist yet — nor an argument a `select` chooses on, which
137
+ would always choose `other`. Format those at build time, or write the value
138
+ outside the message.
139
+
140
+ **Never write `{{ name }}` yourself.** Vue would evaluate `name` as an
141
+ expression, at build time, and the mark would never reach the built file. **Never branch on a placeholder** either:
142
+ `v-if="placeholder('link').startsWith('https:')"` is decided on the string
143
+ `{{ link }}`, once, at build time.
144
+
145
+ Keep Maizzle's `url.base` off for links: it would turn `href="{{ link }}"` into
146
+ `href="https://cdn.example.com/{{ link }}"`.
147
+
148
+ ## Template names
149
+
150
+ A template's path under `emails/` names the e-mail: `emails/verify-email.vue`
151
+ is `verify-email`, and `emails/auth/reset-password.vue` is
152
+ `auth/reset-password`. Every segment is kebab-case: lower-case letters and
153
+ digits, joined by single dashes.
154
+
155
+ The name also gives the key of its subject, `verifyEmail.subject`, through
156
+ [`emailKey`](catalogues.md#the-subject).
157
+
158
+ ## Templates from a package
159
+
160
+ A package can ship templates as well as messages — `@nxgt/mail-presets`
161
+ ships nine ready e-mails. The `templates` option builds a package's folder
162
+ with yours, once per locale, into the same output and the same manifest:
163
+
164
+ ```ts
165
+ // maizzle.config.ts
166
+ import { defineMailConfig } from '@nxgt/mail-config';
167
+ import { i18n } from '@nxgt/mail-i18n';
168
+ import { presets } from '@nxgt/mail-presets';
169
+ import { ui, uiCatalogues } from '@nxgt/mail-ui';
170
+
171
+ const mails = presets({ only: ['verify-email', 'welcome'] });
172
+
173
+ export default defineMailConfig({
174
+ plugins: [
175
+ ui({ brand: { name: 'Acme' } }),
176
+ i18n({
177
+ locales: ['en', 'fr'],
178
+ catalogues: [uiCatalogues, mails.catalogues],
179
+ templates: [mails.templates],
180
+ }),
181
+ ],
182
+ });
183
+ ```
184
+
185
+ ```ts
186
+ interface I18nOptions {
187
+ // …
188
+ readonly templates?: readonly TemplateSource[]; // default []
189
+ }
190
+
191
+ interface TemplateSource {
192
+ /** The folder, absolute. */
193
+ readonly dir: string;
194
+ /** The e-mails of the folder to build, as ['verify-email']. Default every one. */
195
+ readonly emails?: readonly [string, ...string[]]; // at least one
196
+ }
197
+ ```
198
+
199
+ A package answers its `TemplateSource` from a function, as `presets()` does,
200
+ so you rarely write one. By hand, `dir` is absolute — a package finds its own
201
+ with `fileURLToPath(new URL('../emails', import.meta.url))` — and `emails`
202
+ names templates of that folder as the plugin names them, `auth/reset-password`
203
+ for `auth/reset-password.vue`:
204
+
205
+ ```ts
206
+ import type { TemplateSource } from '@nxgt/mail-i18n';
207
+ import { TEMPLATES_DIR } from '@nxgt/mail-presets';
208
+
209
+ const two: TemplateSource = { dir: TEMPLATES_DIR, emails: ['sign-in-code', 'magic-link'] };
210
+ ```
211
+
212
+ ### Which template is built
213
+
214
+ Your `emails/` is read first, then each source as listed:
215
+
216
+ - **Your template replaces a package's.** With `emails/welcome.vue` in your
217
+ project, `welcome` is built from yours; the package's `welcome.vue` is
218
+ never read.
219
+ - **Two sources with the same e-mail are refused.** Neither would be the
220
+ obvious one, so the build stops rather than pick: keep one with `emails`,
221
+ or write your own in `emails/`.
222
+ - **`emails` keeps only those.** A source's other templates are not built,
223
+ and are not in the manifest. Leave it out for every one; an empty list does
224
+ not compile, and is refused at run time. Your own `emails/` is always built
225
+ whole.
226
+ - **A source must hold templates.** A `dir` that is missing or has no `.vue`
227
+ file is refused — usually a path to the package rather than to its
228
+ e-mails folder.
229
+
230
+ ```ts
231
+ import { i18n } from '@nxgt/mail-i18n';
232
+
233
+ // the e-mails folders of two packages that both ship welcome.vue
234
+ declare const accountEmails: string;
235
+ declare const otherEmails: string;
236
+
237
+ // keep the first one's welcome
238
+ i18n({
239
+ locales: ['en', 'fr'],
240
+ templates: [
241
+ { dir: accountEmails, emails: ['welcome', 'verify-email'] },
242
+ { dir: otherEmails, emails: ['invitation'] },
243
+ ],
244
+ });
245
+ ```
246
+
247
+ The wrapper of a package's template imports it from where it is:
248
+
249
+ ```vue
250
+ <!-- .maizzle/i18n/fr/verify-email.vue, generated; the path follows your install -->
251
+ <script setup>
252
+ import Email from '../../../node_modules/@nxgt/mail-presets/emails/verify-email.vue';
253
+ </script>
254
+ <template><Email /></template>
255
+ ```
256
+
257
+ A package's template is built like yours: it calls `t` on the catalogues you
258
+ give the plugin, so its package's messages go in `catalogues` — see
259
+ [Catalogues from a package](catalogues.md#catalogues-from-a-package) — and
260
+ its placeholders are recorded in the manifest. Its names are checked for
261
+ kebab-case like yours, reported as `templates[0]/…`. `maizzle serve` watches
262
+ your `emails/` for added and removed templates, not a package's folder.
263
+
264
+ The tags of a template under `node_modules` are not resolved by Maizzle,
265
+ which skips that folder. The plugin that ships the components resolves them —
266
+ `@nxgt/mail-ui`'s `ui()` does, for its `Nx*` components and Maizzle's
267
+ built-ins, so list it in `plugins`. A tag left unresolved renders nothing,
268
+ and the build fails on the empty e-mail (see
269
+ [What fails the build](#what-fails-the-build)).
270
+
271
+ ### Its errors
272
+
273
+ | Error | When |
274
+ | --- | --- |
275
+ | `TypeError: i18n: templates must be a list of template folders, as [{ dir: '/abs/path/emails' }] — emails, when given, names at least one, each once` | When the config loads: `templates` not a list (`templates: mails.templates`), a `dir` that is not absolute, or `emails` that is not a list of names, is empty, or names one twice |
276
+ | `Error: i18n: templates[0] has no template sign-in.vue — name one of its e-mails` | When the config loads: a name in `emails` the folder does not have. `templates[0]` is the source's place in the list |
277
+ | `Error: i18n: templates[0] holds no template — is /…/emails the folder of a package's e-mails?` | When the config loads: a `dir` that does not exist, or holds no `.vue` file |
278
+ | `Error: i18n: templates[0] and templates[1] both have welcome.vue — keep one with emails: [...], or write the project's own in its folder` | When the config loads: two sources ship an e-mail of the same name, and your `emails/` does not have it |
279
+
280
+ ```ts
281
+ i18n({ locales: ['en'], templates: [{ dir: 'node_modules/@nxgt/mail-presets/emails' }] });
282
+ // TypeError: i18n: templates must be a list of template folders, as [{ dir: '/abs/path/emails' }] — emails, when given, names at least one, each once
283
+ ```
284
+
285
+ ## What fails the build
286
+
287
+ A template that cannot be right stops the build. The message names the
288
+ locale, the e-mail and the key. Maizzle builds the locales in no set order, so
289
+ either locale may be the one reported. Each is a plain `Error`: a mistake in
290
+ the template or the catalogues, to fix, not a condition to catch.
291
+
292
+ | Build failure | Cause |
293
+ | --- | --- |
294
+ | `i18n: fr: verify-email calls t('verifyEmail.titel'), which is not a key of the catalogues` | A key that does not exist |
295
+ | `i18n: en: verify-email calls t('verifyEmail.expires') without {minutes}` | An argument the message declares, not passed |
296
+ | `i18n: fr: verify-email passes {minutes} to verifyEmail.expires as a string — the message uses it as a number` | An argument of the wrong kind; here, a placeholder where a plural needs a number |
297
+ | `i18n: en: verify-email passes {name} to verifyEmail.title, which does not use it` | An argument the message does not use |
298
+ | `i18n: en: verify-email passes a placeholder to {plan}, which verifyEmail.title chooses on with a select — a placeholder always chooses other` | `placeholder()` passed to an argument the message chooses on with a `select`: it would always choose `other`. Pass a value the build knows |
299
+ | `i18n: en: verify-email calls t('verifyEmail.title') with arguments that are not an object, as { name: placeholder('name') }` | `t('verifyEmail.greeting', placeholder('name'))`: the arguments go in an object |
300
+ | `i18n: fr: verify-email calls placeholder() with a name that is not camelCase — as placeholder('firstName')` | `placeholder('first name')`, `placeholder('first_name')` |
301
+ | `i18n: fr: verifyEmail.sentOn could not be formatted` | The formatter refused the value, such as a date that is `NaN`; its error is the `cause` |
302
+ | `i18n: templates[0] has no template sign-in.vue — name one of its e-mails` | A [package's source](#templates-from-a-package) lists, in `emails`, a template its folder does not have; checked when the config loads |
303
+ | `i18n: templates[0] holds no template — is /…/emails the folder of a package's e-mails?` | A package's source whose `dir` is missing or has no template; checked when the config loads |
304
+ | `i18n: templates[0] and templates[1] both have welcome.vue — keep one with emails: [...], or write the project's own in its folder` | Two package sources ship the same e-mail; checked when the config loads |
305
+ | `i18n: fr/welcome.html is empty — a tag of its template resolved to no component; list the plugin that brings it, as ui()` | The e-mail rendered nothing but the doctype: a tag of its template, usually the layout, matched no component, and Vue renders an unknown component as nothing. Typically `ui()` is missing from `plugins`, or a package's template uses a component no plugin brings. Reported when the manifest is written |
306
+ | `i18n: emails/Welcome.vue is not a kebab-case name — name a template as verify-email.vue` | A template whose path is not kebab-case, checked when the config loads. Under `maizzle serve`, one added while the server runs is printed with `console.error`, and the server keeps running |
307
+ | `i18n: welcome has no subject — add welcome.subject to the catalogues` | The e-mail has no subject message; reported when the manifest is written, at the end of the build. The other manifest failures, including an output path set in a template, are in [The manifest](manifest.md#where-it-is-written) |
308
+ | `i18n: emails/verify-email.vue is not built through the i18n plugin — leave content to it, and put templates in emails/` | Maizzle rendered a template directly: the project set its own `content` |
309
+
310
+ The last one comes from replacing the plugin's `content`:
311
+
312
+ ```ts
313
+ // maizzle.config.ts — wrong: content replaces the plugin's, which points at the generated files
314
+ export default defineMailConfig({
315
+ plugins: [i18n({ locales: ['en', 'fr'] })],
316
+ content: ['emails/**/*.vue'],
317
+ });
318
+
319
+ // right: templates live in `emails` (or the folder named by the `emails` option)
320
+ export default defineMailConfig({
321
+ plugins: [i18n({ locales: ['en', 'fr'] })],
322
+ });
323
+ ```
324
+
325
+ ## What the build writes
326
+
327
+ For each template and locale, the plugin generates a small file, a
328
+ **wrapper**, under `.maizzle/i18n/`. The wrapper imports the template and
329
+ nothing else, and the plugin points Maizzle's `content` at these files. One
330
+ `maizzle build` then renders every template once per locale, and `t` knows
331
+ which locale each render is for.
332
+
333
+ ```vue
334
+ <!-- .maizzle/i18n/fr/verify-email.vue, generated -->
335
+ <!-- Generated by @nxgt/mail-i18n: one per template and locale. Never edited, never committed. -->
336
+ <script setup>
337
+ import Email from '../../../emails/verify-email.vue';
338
+ </script>
339
+ <template><Email /></template>
340
+ ```
341
+
342
+ Wrappers are written when the config loads, only when their text changed, and
343
+ only on the main thread: above 50 wrappers (templates × locales; Maizzle's
344
+ `parallel.threshold`) Maizzle builds in parallel, each worker loads the
345
+ config again, and the workers do not write. A parallel build
346
+ writes the same files and the same manifest, which the package's specs check. A wrapper whose template is gone is removed.
347
+ `.maizzle/` belongs in `.gitignore`.
348
+
349
+ The output follows the wrappers, under Maizzle's `output.path` (`dist` by
350
+ default):
351
+
352
+ | `layout` | Wrapper | Built files |
353
+ | --- | --- | --- |
354
+ | `'nested'` (default) | `.maizzle/i18n/fr/auth/reset-password.vue` | `dist/fr/auth/reset-password.html`, `.txt` |
355
+ | `'flat'` | `.maizzle/i18n/auth/reset-password.fr.vue` | `dist/auth/reset-password.fr.html`, `.txt` |
356
+
357
+ ```ts
358
+ // maizzle.config.ts — the fixture's flat build, in its own folder
359
+ import { defineMailConfig } from '@nxgt/mail-config';
360
+ import { i18n } from '@nxgt/mail-i18n';
361
+
362
+ export default defineMailConfig({
363
+ plugins: [i18n({ locales: ['en', 'fr'], layout: 'flat' })],
364
+ output: { path: 'dist-flat' },
365
+ });
366
+ ```
367
+
368
+ Then the plugin writes `mail-manifest.json` next to them. See
369
+ [The manifest](manifest.md).
370
+
371
+ ## `maizzle serve`
372
+
373
+ The dev UI lists each template once per locale, by its wrapper:
374
+
375
+ ```text
376
+ .maizzle/i18n/en/auth/reset-password.vue
377
+ .maizzle/i18n/en/verify-email.vue
378
+ .maizzle/i18n/fr/auth/reset-password.vue
379
+ .maizzle/i18n/fr/verify-email.vue
380
+ ```
381
+
382
+ Placeholders show as they are, `Bonjour {{ name }},`: the preview is the file
383
+ that will be built.
384
+
385
+ While it runs:
386
+
387
+ - **Editing a template** refreshes every locale of it: each wrapper imports
388
+ it.
389
+ - **Adding or removing a template** under `emails/` writes or removes its
390
+ wrappers, and the list follows. A template added with a name that is not
391
+ kebab-case is reported with `console.error`, and the server keeps
392
+ running; `maizzle build` fails on it.
393
+ - **Saving a catalogue** reloads the config, which checks the catalogues
394
+ again. Maizzle watches `locales/`; when `dir` names another folder, the
395
+ plugin adds it to `server.watch`.
396
+
397
+ A failure in `t` shows in the preview of that template and locale, with the
398
+ same message, instead of the e-mail. `maizzle serve` does not write the
399
+ manifest, so a missing subject is only reported by `maizzle build`.
400
+
401
+ ## Typing templates
402
+
403
+ `i18n()` writes `.maizzle/nxgt-mail-i18n.d.ts` each time the config loads, so
404
+ Vue's language tools complete a key of `t` and flag an unknown key or a wrong
405
+ argument in the editor and in `vue-tsc`:
406
+
407
+ ```vue
408
+ <Text>{{ t('verifyEmail.titel') }}</Text>
409
+ <!-- Argument of type '"verifyEmail.titel"' is not assignable to parameter of type 'keyof TemplateMessages'. -->
410
+ ```
411
+
412
+ The setup, what the file holds, and each kind of argument are in
413
+ [Editor and type checking](editor.md).
414
+
415
+ ## See also
416
+
417
+ - [Catalogues](catalogues.md) — the messages `t` reads, and the kinds of
418
+ argument.
419
+ - [Editor and type checking](editor.md) — `t`'s keys and arguments, typed
420
+ from the catalogues.
421
+ - [The manifest](manifest.md) — what the build records about placeholders.
@@ -0,0 +1,170 @@
1
+ # Translating outside templates
2
+
3
+ This page is for using the project's catalogues from your application's own
4
+ code, with `createTranslator`. Typical uses are the text of a notification, a
5
+ text message, a string a test compares with, or a subject built outside
6
+ Maizzle.
7
+
8
+ ```ts
9
+ import { pickLocale } from '@nxgt/mail';
10
+ import { createTranslator } from '@nxgt/mail-i18n';
11
+ import en from './locales/en.json';
12
+ import fr from './locales/fr.json';
13
+
14
+ declare const user: { locale: string | null };
15
+
16
+ const t = createTranslator({ en, fr }, () => pickLocale(user.locale, ['en', 'fr'], 'en'));
17
+
18
+ t('verifyEmail.expires', { minutes: 15 }); // 'The link expires in 15 minutes.' for an English user
19
+ ```
20
+
21
+ `pickLocale` comes from `@nxgt/mail` (`bun add @nxgt/mail`, a package with
22
+ no dependency). It answers the first of the user's locales that the
23
+ catalogues have, or the fallback. It picks `fr` for `fr-CA`, and `en` for
24
+ `null` or `de`. Put it in the language provider, and `t` never meets a
25
+ language it has no catalogue for. Any function that returns one of the
26
+ catalogues' locales works as well: `() => (user.locale === 'fr' ? 'fr' : 'en')`.
27
+
28
+ ## The signature
29
+
30
+ ```ts
31
+ import type { Catalogues } from '@nxgt/mail-i18n';
32
+
33
+ type LanguageProvider = string | (() => string);
34
+ type MessageArgs = Readonly<Record<string, string | number | Date>>;
35
+ type Translate = (key: string, args?: MessageArgs, language?: LanguageProvider) => string;
36
+
37
+ function createTranslator(catalogues: Catalogues, getLanguage: LanguageProvider): Translate;
38
+ ```
39
+
40
+ | Parameter | Type | Effect |
41
+ | --- | --- | --- |
42
+ | `catalogues` | `Catalogues` | The catalogues by locale, `{ en, fr }`, as written in `locales/`. A JSON import is accepted as it is |
43
+ | `getLanguage` | `string \| () => string` | The language of every call that does not name one. A function is called **at each call**, so it can read the current user or request |
44
+
45
+ `t(key, args?, language?)`:
46
+
47
+ | Argument | Effect |
48
+ | --- | --- |
49
+ | `key` | The dotted key, `verifyEmail.title` |
50
+ | `args` | The message's arguments: a string, a number or a `Date` each |
51
+ | `language` | This call's language, a locale or a function: overrides `getLanguage` |
52
+
53
+ ```ts
54
+ const t = createTranslator({ en, fr }, 'fr');
55
+
56
+ t('verifyEmail.expires', { minutes: 1 }); // 'Le lien expire dans 1 minute.'
57
+ t('verifyEmail.expires', { minutes: 1 }, 'en'); // 'The link expires in 1 minute.'
58
+ t('verifyEmail.expires', { minutes: 1 }, () => 'en'); // 'The link expires in 1 minute.'
59
+ ```
60
+
61
+ Compiled messages are cached per locale and key, so calling `t` in a loop is
62
+ cheap.
63
+
64
+ ## How it differs from `@nxgt/i18n`
65
+
66
+ The shape is the same: `createTranslator(resources, getLanguage)` answers
67
+ `t(key, args, language?)`, and a language is a string or a function that
68
+ answers one. The difference is what happens when a translation cannot be
69
+ right:
70
+
71
+ | Situation | `@nxgt/i18n` | `@nxgt/mail-i18n` |
72
+ | --- | --- | --- |
73
+ | A key the language's catalogue does not have | Answers the key, `'verifyEmail.titel'` | **Throws** `t: fr: verifyEmail.titel is not a key`. So does a key that names a group of messages, `verifyEmail` |
74
+ | A language with no catalogue | Answers the key | **Throws** `t: the language is not a locale of the catalogues — pick one with pickLocale` |
75
+ | A message that does not format (an argument missing) | Logs, and answers the raw message with `{name}` in it | **Throws** `t: en: common.greeting could not be formatted`, the formatter's error as `cause` |
76
+
77
+ A web page can show a key for a moment and correct it on the next deploy. An
78
+ e-mail cannot be corrected once it has gone out, and one with `{link}` in it
79
+ is worse than none. So this `t` refuses, and the caller decides what to do.
80
+
81
+ The error never names the language itself when it is unknown: a language
82
+ often comes from a user's profile, and a message reports a shape, never a
83
+ value.
84
+
85
+ ## Errors
86
+
87
+ A `TypeError` when the translator is created with the wrong arguments, a
88
+ plain `Error` when `t` refuses. Neither has a `code`: each is a mistake in the
89
+ code or the catalogues, to fix, never a condition to `switch` on.
90
+
91
+ | Thrown | When | Message |
92
+ | --- | --- | --- |
93
+ | `TypeError` | `createTranslator` is called | `createTranslator: catalogues must be an object of catalogues by locale, as { en, fr }` |
94
+ | `TypeError` | `createTranslator` is called | `createTranslator: getLanguage must be a locale or a function that answers one` |
95
+ | `Error` | `t` is called | `t: the language is not a locale of the catalogues — pick one with pickLocale` |
96
+ | `Error` | `t` is called | `t: fr: common.greting is not a key` |
97
+ | `Error` | `t` is called | `t: en: common.greeting could not be formatted` (with `cause`) |
98
+
99
+ The error's own message names the locale and the key, never the text of the
100
+ message. Its `cause`, the formatter's error, may quote that text: it is kept
101
+ for debugging, and a catalogue's text is not a secret.
102
+
103
+ ```ts
104
+ const t = createTranslator({ en, fr }, () => 'de');
105
+
106
+ t('verifyEmail.title'); // Error: t: the language is not a locale of the catalogues — pick one with pickLocale
107
+ ```
108
+
109
+ ## What it does not check
110
+
111
+ `createTranslator` reads the catalogues it is given. It does not run the
112
+ checks of the build: that every locale has the fallback's keys, that the
113
+ arguments agree. The build has run them on the same files, so import the
114
+ catalogues the project builds from. An argument passed and not used is
115
+ ignored. An argument the message needs and did not get is the formatting
116
+ failure above.
117
+
118
+ ## A realistic use
119
+
120
+ A reminder sent as a text message, in the recipient's locale, from the same
121
+ catalogues as the e-mails:
122
+
123
+ ```ts
124
+ // reminders.ts, in the application
125
+ import { pickLocale } from '@nxgt/mail';
126
+ import { createTranslator } from '@nxgt/mail-i18n';
127
+ import en from './locales/en.json';
128
+ import fr from './locales/fr.json';
129
+
130
+ const locales = ['en', 'fr'] as const;
131
+ const t = createTranslator({ en, fr }, 'en');
132
+
133
+ interface User {
134
+ readonly phone: string;
135
+ readonly locale: string | null;
136
+ }
137
+
138
+ export function reminderText(user: User, minutes: number): string {
139
+ const language = pickLocale(user.locale, locales, 'en');
140
+ return t('verifyEmail.expires', { minutes }, language);
141
+ }
142
+ ```
143
+
144
+ The recipient's locale is a field of the user, not the language of the
145
+ request that triggers the send. A language per call keeps one translator for
146
+ the whole application.
147
+
148
+ In a test, `t` gives the string a built e-mail should contain, in the
149
+ locale under test:
150
+
151
+ ```ts
152
+ import { expect, test } from 'bun:test';
153
+ import { readFileSync } from 'node:fs';
154
+ import { createTranslator } from '@nxgt/mail-i18n';
155
+ import en from './locales/en.json';
156
+ import fr from './locales/fr.json';
157
+
158
+ const t = createTranslator({ en, fr }, 'en');
159
+
160
+ test.each(['en', 'fr'])('the %s verification e-mail says when the link expires', (locale) => {
161
+ const html = readFileSync(`dist/${locale}/verify-email.html`, 'utf8');
162
+ expect(html).toContain(t('verifyEmail.expires', { minutes: 15 }, locale));
163
+ });
164
+ ```
165
+
166
+ ## See also
167
+
168
+ - [Catalogues](catalogues.md) — the format `createTranslator` reads.
169
+ - [Templates](templates.md) — `t` inside a template, which also checks the
170
+ arguments against the fallback locale.
@@ -0,0 +1,91 @@
1
+ # Roadmap
2
+
3
+ Where `@nxgt/mail-i18n` is heading. A direction, not a commitment: there are
4
+ no dates here, and the version something shipped in is the only number.
5
+
6
+ ## Now
7
+
8
+ Nothing between releases.
9
+
10
+ ## Next
11
+
12
+ Nothing yet.
13
+
14
+ ## Later
15
+
16
+ Nothing yet. A request is welcome as an
17
+ [issue](https://github.com/softistx/nxgt-mail/issues).
18
+
19
+ ## Not planned
20
+
21
+ - **Answering the key on a missing message** — `@nxgt/i18n` answers the key
22
+ when a message is missing; here a missing key, a language with no catalogue
23
+ or a formatting failure throws, in templates and in `createTranslator`
24
+ alike. An e-mail must never go out with `verifyEmail.title` or `{link}` in
25
+ it, so the failure belongs at build time.
26
+ - **A template engine at run time** — templates are built by `maizzle build`;
27
+ at send time only `{{ name }}` placeholders are filled in the built files.
28
+ No Handlebars, no MJML, no Maizzle in your server.
29
+ - **One HTML file per language, written by hand** — one template per e-mail,
30
+ its text keys into catalogues; building one file per locale is this
31
+ plugin's job.
32
+ - **A translation-management integration** — the catalogues are JSON files in
33
+ your project, in the `@nxgt/i18n` layout; syncing them with a translation
34
+ service is left to your own tooling.
35
+
36
+ ## Shipped
37
+
38
+ The last ten, newest first, each with the version it came in. Everything
39
+ before is in the [CHANGELOG](../CHANGELOG.md).
40
+
41
+ - **i18n as a plugin, v0.1.0** — `i18n({ locales, fallbackLocale })`, listed in
42
+ `defineMailConfig`'s `plugins`: one template per e-mail, its text keys into
43
+ `locales/<locale>.json` ICU catalogues, and one output per locale from a
44
+ single `maizzle build` — `dist/<locale>/<template>.html`, or
45
+ `dist/<template>.<locale>.html` with `layout: 'flat'`. `maizzle serve` lists
46
+ every locale, and picks up a template added or removed.
47
+ - **Catalogues checked at build time, v0.1.0** — a missing or broken catalogue, a key
48
+ that is not camelCase, a message that does not parse, a key missing in one
49
+ locale, an argument a translation invents or types differently: the build
50
+ fails, naming the locale and the key.
51
+ - **`t`, `locale` and `placeholder` in templates, v0.1.0** — `{{ t('verifyEmail.title')
52
+ }}` translates in the template's locale, and fails the build on an unknown
53
+ key or a wrong, missing or unused argument; `placeholder('name')` writes
54
+ `{{ name }}` for a value only known at send time, and can be passed as an
55
+ ICU argument.
56
+ - **The manifest, v0.1.0** — `dist/mail-manifest.json`: per e-mail, its variables and
57
+ the ones a URL attribute starts with (so they decide the scheme), its subject per locale (the
58
+ required `<email>.subject` message) and its files per locale — what
59
+ `createMailRenderer` from `@nxgt/mail/renderer` reads to send the built
60
+ `html` and `text` of a locale, every placeholder filled and escaped at send
61
+ time.
62
+ - **Catalogues from a package, v0.1.0** — `i18n({ catalogues: [uiCatalogues] })`:
63
+ catalogues a package ships, as `@nxgt/mail-ui`'s shared messages, merged key
64
+ by key under your project's `locales/<locale>.json`, which overrides any of
65
+ them, and checked with it.
66
+ - **Templates from a package, v0.1.0** — `i18n({ templates: [{ dir, emails }] })`:
67
+ folders of templates a package ships, as `@nxgt/mail-presets`' ready
68
+ e-mails, built with your project's own; `dir` is absolute, `emails` keeps
69
+ the ones you name, and a template of the same name in your project's
70
+ `emails/` replaces a package's.
71
+ - **`createTranslator` outside templates, v0.1.0** — `createTranslator(catalogues,
72
+ getLanguage)` and `t(key, args, language?)`, shaped like `@nxgt/i18n`, for a
73
+ message an application formats itself.
74
+ - **Typed templates and `t` in the editor, v0.1.0** — `t`, `locale` and
75
+ `placeholder` are known to Vue's template checker, so a template that calls
76
+ them type-checks. The plugin writes `.maizzle/nxgt-mail-i18n.d.ts` from the
77
+ catalogues each time the config loads, so an editor completes `t('…')` and
78
+ flags an unknown key or a wrong argument, and `vue-tsc` checks templates in
79
+ CI.
80
+ - **The renderer typed by the build, v0.1.0** — after each `maizzle build`, the
81
+ plugin writes `generated/mail.ts`: `MailEmails`, each e-mail with the
82
+ variables it takes, a URL variable as a `string`. Committed, it lets
83
+ `createMailRenderer<MailEmails>` from `@nxgt/mail/renderer` refuse an
84
+ unknown e-mail, a missing or unknown variable, or a number for a URL at
85
+ compile time, without a build in CI. Rewritten only when it changes;
86
+ `rendererTypes` moves it, or `false` turns it off.
87
+ - **A starter project, with v0.1.0** — the official Maizzle starter with
88
+ `defineMailConfig`, the i18n and the UI plugins wired in as the READMEs
89
+ say, built, rendered in `en` and `fr` and served in CI, so the snippets are
90
+ known to work: [`examples/starter`](https://github.com/softistx/nxgt-mail/tree/develop/examples/starter).
91
+ In the repository; its README says how to start your own from npm.