@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.
- package/LICENSE +21 -0
- package/README.md +470 -0
- package/dist/catalogues.d.ts +40 -0
- package/dist/catalogues.d.ts.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +675 -0
- package/dist/index.js.map +17 -0
- package/dist/manifest.d.ts +45 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/plugin.d.ts +55 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/sources.d.ts +19 -0
- package/dist/sources.d.ts.map +1 -0
- package/dist/template.d.ts +22 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/translator.d.ts +31 -0
- package/dist/translator.d.ts.map +1 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/vue.d.ts +44 -0
- package/dist/vue.d.ts.map +1 -0
- package/dist/wrappers.d.ts +62 -0
- package/dist/wrappers.d.ts.map +1 -0
- package/docs/README.md +17 -0
- package/docs/guide/catalogues.md +354 -0
- package/docs/guide/editor.md +215 -0
- package/docs/guide/manifest.md +343 -0
- package/docs/guide/templates.md +421 -0
- package/docs/guide/translator.md +170 -0
- package/docs/roadmap.md +91 -0
- package/docs/troubleshooting.md +1096 -0
- package/package.json +71 -0
|
@@ -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.
|
package/docs/roadmap.md
ADDED
|
@@ -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.
|