@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,354 @@
|
|
|
1
|
+
# Catalogues
|
|
2
|
+
|
|
3
|
+
This page is for writing the catalogues: one JSON file of ICU messages per
|
|
4
|
+
locale, checked against each other when the config loads.
|
|
5
|
+
|
|
6
|
+
```json
|
|
7
|
+
// locales/en.json
|
|
8
|
+
{
|
|
9
|
+
"verifyEmail": {
|
|
10
|
+
"subject": "Confirm your e-mail address, {name}",
|
|
11
|
+
"title": "Confirm your e-mail address",
|
|
12
|
+
"greeting": "Hello {name},",
|
|
13
|
+
"expires": "The link expires in {minutes, plural, one {# minute} other {# minutes}}.",
|
|
14
|
+
"sentOn": "Sent on {at, date, long}.",
|
|
15
|
+
"action": "Confirm my address"
|
|
16
|
+
},
|
|
17
|
+
"auth": {
|
|
18
|
+
"resetPassword": {
|
|
19
|
+
"subject": "Reset your password",
|
|
20
|
+
"body": "Someone asked to reset the password of {email}."
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
// locales/fr.json
|
|
28
|
+
{
|
|
29
|
+
"verifyEmail": {
|
|
30
|
+
"subject": "Confirmez votre adresse e-mail, {name}",
|
|
31
|
+
"title": "Confirmez votre adresse e-mail",
|
|
32
|
+
"greeting": "Bonjour {name},",
|
|
33
|
+
"expires": "Le lien expire dans {minutes, plural, one {# minute} other {# minutes}}.",
|
|
34
|
+
"sentOn": "Envoyé le {at, date, long}.",
|
|
35
|
+
"action": "Confirmer mon adresse"
|
|
36
|
+
},
|
|
37
|
+
"auth": {
|
|
38
|
+
"resetPassword": {
|
|
39
|
+
"subject": "Réinitialisez votre mot de passe",
|
|
40
|
+
"body": "Quelqu'un a demandé à réinitialiser le mot de passe de {email}."
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// maizzle.config.ts
|
|
48
|
+
import { defineMailConfig } from '@nxgt/mail-config';
|
|
49
|
+
import { i18n } from '@nxgt/mail-i18n';
|
|
50
|
+
|
|
51
|
+
export default defineMailConfig({
|
|
52
|
+
plugins: [i18n({ locales: ['en', 'fr'], fallbackLocale: 'en' })],
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A template writes `t('verifyEmail.expires', { minutes: 15 })`. The English
|
|
57
|
+
build shows `The link expires in 15 minutes.` and the French one
|
|
58
|
+
`Le lien expire dans 15 minutes.`.
|
|
59
|
+
|
|
60
|
+
## Where they are read
|
|
61
|
+
|
|
62
|
+
`i18n()` reads `<dir>/<locale>.json` for every locale in `locales`, where
|
|
63
|
+
`dir` defaults to `locales` and is resolved against the directory `maizzle`
|
|
64
|
+
runs in. It checks them at once, when `maizzle.config.ts` loads, before any
|
|
65
|
+
template is built. A failure stops the build there.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
i18n({ locales: ['en', 'fr', 'pt-BR'], dir: 'i18n' }); // i18n/en.json, i18n/fr.json, i18n/pt-BR.json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Every locale needs its file, and the file must be valid JSON:
|
|
72
|
+
|
|
73
|
+
| Build failure | Cause |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `i18n: locales/fr.json is missing — every locale has a catalogue` | `fr` is in `locales`, and there is no `locales/fr.json` |
|
|
76
|
+
| `i18n: locales/fr.json is not valid JSON` | The file is empty, or does not parse |
|
|
77
|
+
|
|
78
|
+
`maizzle serve` watches `locales/` already. For another `dir`, the plugin adds
|
|
79
|
+
that folder to Maizzle's `server.watch`. Either way, saving a catalogue reloads
|
|
80
|
+
the config, which checks the catalogues again.
|
|
81
|
+
|
|
82
|
+
## Catalogues from a package
|
|
83
|
+
|
|
84
|
+
A package can ship messages its components or your templates share —
|
|
85
|
+
`@nxgt/mail-ui`'s `uiCatalogues` holds `common.greeting` and
|
|
86
|
+
`common.footer.*` in `en` and `fr`. Give them to the plugin with
|
|
87
|
+
`catalogues`, and your `<locale>.json` goes over them, key by key:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// maizzle.config.ts
|
|
91
|
+
import { defineMailConfig } from '@nxgt/mail-config';
|
|
92
|
+
import { i18n } from '@nxgt/mail-i18n';
|
|
93
|
+
import { ui, uiCatalogues } from '@nxgt/mail-ui';
|
|
94
|
+
|
|
95
|
+
export default defineMailConfig({
|
|
96
|
+
plugins: [
|
|
97
|
+
ui({ brand: { name: 'Acme' } }),
|
|
98
|
+
i18n({ locales: ['en', 'fr'], catalogues: [uiCatalogues] }),
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
interface I18nOptions {
|
|
105
|
+
// …
|
|
106
|
+
readonly catalogues?: readonly Catalogues[]; // default []
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A source is a `Catalogues`: catalogues by locale, `{ en: {...}, fr: {...} }`,
|
|
111
|
+
as `createTranslator` takes them. A package's own is typed with it:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import type { Catalogues } from '@nxgt/mail-i18n';
|
|
115
|
+
|
|
116
|
+
export const shared = {
|
|
117
|
+
en: { common: { greeting: 'Hello {name},' } },
|
|
118
|
+
fr: { common: { greeting: 'Bonjour {name},' } },
|
|
119
|
+
} as const satisfies Catalogues;
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### How they merge
|
|
123
|
+
|
|
124
|
+
For each locale in `locales`, the plugin layers every source in the order
|
|
125
|
+
listed, then your `<dir>/<locale>.json` on top:
|
|
126
|
+
|
|
127
|
+
- **An object merges** key by key: a key only a lower layer has stays.
|
|
128
|
+
- **Anything else replaces**: a message over a message, a message over a
|
|
129
|
+
group, a group over a message.
|
|
130
|
+
- **A later source wins over an earlier one**, and your catalogue over all.
|
|
131
|
+
|
|
132
|
+
With the sources and your file below, the `en` catalogue checked and used is
|
|
133
|
+
the last block:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const ui = { en: { common: { greeting: 'Hello {name},', footer: { why: 'Why' } } } };
|
|
137
|
+
const brand = { en: { common: { greeting: 'Hey {name},' } } };
|
|
138
|
+
|
|
139
|
+
i18n({ locales: ['en'], catalogues: [ui, brand] });
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
// locales/en.json
|
|
144
|
+
{ "welcome": { "title": "Welcome" } }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"common": { "greeting": "Hey {name},", "footer": { "why": "Why" } },
|
|
150
|
+
"welcome": { "title": "Welcome" }
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A message replaces a group whole: `{ "common": "Hi" }` in your file would drop
|
|
155
|
+
every `common.*` key the sources bring.
|
|
156
|
+
|
|
157
|
+
### What stays the same
|
|
158
|
+
|
|
159
|
+
- **Your file is still required** for every locale, even when the sources
|
|
160
|
+
cover everything you need: `{}` is a valid catalogue.
|
|
161
|
+
- **A source's locale you do not build is left out.** `uiCatalogues` has
|
|
162
|
+
`fr`; with `locales: ['en']`, it is ignored.
|
|
163
|
+
- **A locale no source has** gets nothing from them. With
|
|
164
|
+
`locales: ['en', 'de']` and `uiCatalogues`, `locales/de.json` writes the
|
|
165
|
+
`common` keys itself, or the build fails on the first one missing.
|
|
166
|
+
- **The merged catalogues are checked**, as described on this page: the
|
|
167
|
+
format, the fallback locale's keys and arguments, the subjects. An error
|
|
168
|
+
names the locale and the key, not the source it came from.
|
|
169
|
+
|
|
170
|
+
| Failure | Cause |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| `TypeError: i18n: catalogues must be a list of catalogues by locale, as [{ en: {...}, fr: {...} }]` | `catalogues` is not an array (`catalogues: uiCatalogues`, without the brackets), or holds something other than an object of catalogues by locale (`[null]`, `[{ en: 'Hello' }]`) |
|
|
173
|
+
| `i18n: de: common.footer.ignore is missing — en, the fallback locale, has it` | A source brings the key in the fallback locale, and nothing brings it in `de` |
|
|
174
|
+
|
|
175
|
+
The first is a wiring mistake, thrown when the config loads; the second is a
|
|
176
|
+
build failure, as any other on this page.
|
|
177
|
+
|
|
178
|
+
## The format
|
|
179
|
+
|
|
180
|
+
A catalogue is an object. A leaf is an ICU message (a string), and anything
|
|
181
|
+
else is an object of messages. The key of a message is its path, dotted:
|
|
182
|
+
`verifyEmail.title`, `auth.resetPassword.body`.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import type { Catalogue } from '@nxgt/mail-i18n';
|
|
186
|
+
|
|
187
|
+
const en: Catalogue = { verifyEmail: { subject: 'Confirm, {name}' } };
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
interface Catalogue {
|
|
192
|
+
readonly [key: string]: string | Catalogue;
|
|
193
|
+
}
|
|
194
|
+
type Catalogues = Readonly<Record<string, Catalogue>>; // { en, fr }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Every segment of a key is `camelCase`**: a lower-case letter, then letters
|
|
198
|
+
and digits. Keys are **nested, never dotted**: a dot inside a key is refused
|
|
199
|
+
the same way as a dash.
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{ "verifyEmail": { "title": "…" } }
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
| Build failure | Cause |
|
|
206
|
+
| --- | --- |
|
|
207
|
+
| `i18n: en: verify-email is not camelCase — every segment of a key is camelCase, and nested rather than dotted, as verifyEmail.title` | A key with a dash, an underscore, or a capital first letter |
|
|
208
|
+
| `i18n: en: verifyEmail.title is not camelCase — …` | A dotted key, `{ "verifyEmail.title": "…" }`: nest it |
|
|
209
|
+
| `i18n: en: the catalogue must be an object of messages` | The file holds an array, a string, `null` |
|
|
210
|
+
| `i18n: en: verifyEmail.expires must be a message (a string) or an object of messages` | A leaf that is a number, a boolean, `null`, an array |
|
|
211
|
+
| `i18n: en: verifyEmail.greeting is not a valid ICU message (EXPECT_ARGUMENT_CLOSING_BRACE)` | The message does not parse: here an unclosed `{name`. A `plural` or `select` with no `other` gives `(MISSING_OTHER_CLAUSE)`. The parser's reason is in brackets. The text of the message is not repeated |
|
|
212
|
+
|
|
213
|
+
HTML-like tags in a message are text: `"<b>{name}</b>"` is a message whose
|
|
214
|
+
argument `name` counts, and whose `<b>` is written as it is.
|
|
215
|
+
|
|
216
|
+
## Arguments
|
|
217
|
+
|
|
218
|
+
An argument is a named value a message writes. Its **kind** comes from the way
|
|
219
|
+
the message uses it:
|
|
220
|
+
|
|
221
|
+
| In the message | Kind | What `t` accepts for it |
|
|
222
|
+
| --- | --- | --- |
|
|
223
|
+
| `{name}` | `string` | a string or a number |
|
|
224
|
+
| `{gender, select, female {…} other {…}}` | `string` | a string or a number |
|
|
225
|
+
| `{total, number}`, `{total, number, ::currency/EUR}` | `number` | a number |
|
|
226
|
+
| `{count, plural, one {# item} other {# items}}` | `number` | a number |
|
|
227
|
+
| `{at, date, long}`, `{at, time, short}` | `date` | a `Date`, or a timestamp in milliseconds |
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
import type { ArgumentKind } from '@nxgt/mail-i18n'; // 'string' | 'number' | 'date'
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A placeholder is a string, so it can only fill a `string` argument — and not
|
|
234
|
+
one a `select` chooses on, which would always choose `other`. See
|
|
235
|
+
[Templates](templates.md#placeholdername).
|
|
236
|
+
|
|
237
|
+
An argument's name is `camelCase`, like a key. A message may use one name
|
|
238
|
+
twice, as long as it keeps one kind: a plain `{n}` beside `{n, number}` takes
|
|
239
|
+
the kind `number`.
|
|
240
|
+
|
|
241
|
+
| Build failure | Cause |
|
|
242
|
+
| --- | --- |
|
|
243
|
+
| `i18n: en: welcome.body uses {first_name}, which is not camelCase — an argument is a camelCase name, as {firstName}` | An argument name that is not `camelCase` |
|
|
244
|
+
| `i18n: en: welcome.body uses {n} as number and as date` | One name used as two kinds in one message |
|
|
245
|
+
|
|
246
|
+
## The fallback locale is the reference
|
|
247
|
+
|
|
248
|
+
The fallback locale (`fallbackLocale`, the first of `locales` by default)
|
|
249
|
+
declares the keys and the arguments. Every other locale is checked against
|
|
250
|
+
it:
|
|
251
|
+
|
|
252
|
+
- **The same keys.** A key the fallback has must be in every locale; a key the
|
|
253
|
+
fallback does not have is refused.
|
|
254
|
+
- **No new argument.** A translation may leave an argument out ("Bonjour"
|
|
255
|
+
for "Hello {name}"), but never use one the fallback does not declare.
|
|
256
|
+
- **The same kind.** A translation that uses an argument uses it as the kind
|
|
257
|
+
the fallback declares.
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
// en.json
|
|
261
|
+
{ "welcome": { "body": "Hello {name}, you have {count, plural, one {# message} other {# messages}}." } }
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
// fr.json — {name} left out: allowed
|
|
266
|
+
{ "welcome": { "body": "Vous avez {count, plural, one {# message} other {# messages}}." } }
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
| Build failure | Cause |
|
|
270
|
+
| --- | --- |
|
|
271
|
+
| `i18n: fr: verifyEmail.title is missing — en, the fallback locale, has it` | A key only the fallback has |
|
|
272
|
+
| `i18n: fr: welcome.extra is not a key of en, the fallback locale` | A key the fallback does not have: add it there first |
|
|
273
|
+
| `i18n: fr: welcome.body uses {name}, which en does not declare` | An argument a translation invents |
|
|
274
|
+
| `i18n: fr: welcome.body uses {n} as date, and en declares it as number` | An argument a translation uses as another kind |
|
|
275
|
+
|
|
276
|
+
The keys are compared in sorted order, so the first one reported is always the
|
|
277
|
+
same one.
|
|
278
|
+
|
|
279
|
+
## The subject
|
|
280
|
+
|
|
281
|
+
Every e-mail has a subject in every locale: the message `<emailKey>.subject`.
|
|
282
|
+
`emailKey` turns a template's path under `emails/` into the key its messages
|
|
283
|
+
live under. Each path segment is converted from kebab-case to `camelCase`,
|
|
284
|
+
and the segments are joined with dots:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { emailKey } from '@nxgt/mail-i18n';
|
|
288
|
+
|
|
289
|
+
emailKey('verify-email'); // 'verifyEmail'
|
|
290
|
+
emailKey('auth/reset-password'); // 'auth.resetPassword'
|
|
291
|
+
emailKey('auth/reset-password-2'); // 'auth.resetPassword2'
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
function emailKey(email: string): string;
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
| Template | Its subject |
|
|
299
|
+
| --- | --- |
|
|
300
|
+
| `emails/verify-email.vue` | `verifyEmail.subject` |
|
|
301
|
+
| `emails/auth/reset-password.vue` | `auth.resetPassword.subject` |
|
|
302
|
+
|
|
303
|
+
The template never writes the subject. After the build, the plugin formats
|
|
304
|
+
it in each locale into the [manifest](manifest.md), and **each argument
|
|
305
|
+
becomes a placeholder**:
|
|
306
|
+
|
|
307
|
+
```json
|
|
308
|
+
{ "verifyEmail": { "subject": "Confirm your e-mail address, {name}" } }
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
{ "subject": { "en": "Confirm your e-mail address, {{ name }}" } }
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`name` is then one of the e-mail's variables, filled at send time like any
|
|
316
|
+
other placeholder. A line break in a value filled into a subject is the
|
|
317
|
+
sending side's to remove. The vocabulary's *subject* row says how.
|
|
318
|
+
|
|
319
|
+
A subject's arguments are therefore plain `{name}`: they are strings, and
|
|
320
|
+
they are not known when the subject is formatted. A `select` would always
|
|
321
|
+
choose `other` on a placeholder, so it is refused too.
|
|
322
|
+
|
|
323
|
+
| Build failure | Cause |
|
|
324
|
+
| --- | --- |
|
|
325
|
+
| `i18n: welcome has no subject — add welcome.subject to the catalogues` | `emails/welcome.vue` exists, and `welcome.subject` does not |
|
|
326
|
+
| `i18n: en: welcome.subject uses {count} as a number — a subject's arguments are placeholders, filled at send time as strings` | A `number`, `plural` or `date` argument in a subject |
|
|
327
|
+
| `i18n: en: welcome.subject chooses on {kind} with a select — a subject's arguments are placeholders, which always choose other` | A `select` in a subject: the placeholder `{{ kind }}` would always pick `other`. Write one subject, or one e-mail per case |
|
|
328
|
+
|
|
329
|
+
These three fail at the end of `maizzle build`, when the manifest is written.
|
|
330
|
+
`maizzle serve` does not write a manifest, so it does not report them.
|
|
331
|
+
|
|
332
|
+
Keeping the rest of an e-mail's messages under the same key
|
|
333
|
+
(`verifyEmail.title`, `verifyEmail.action`) is a convention, not a rule. A
|
|
334
|
+
template can call any key, such as a shared `common.footer`.
|
|
335
|
+
|
|
336
|
+
## In CI
|
|
337
|
+
|
|
338
|
+
The build is the check. To fail a pull request on a catalogue that
|
|
339
|
+
cannot be right, build in CI:
|
|
340
|
+
|
|
341
|
+
```sh
|
|
342
|
+
maizzle build
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
A catalogue that fails prints its message (`i18n: fr: … is missing — en, the
|
|
346
|
+
fallback locale, has it`) and exits non-zero.
|
|
347
|
+
|
|
348
|
+
## See also
|
|
349
|
+
|
|
350
|
+
- [Templates](templates.md) — calling `t` with the right arguments, and what
|
|
351
|
+
fails when a template does not.
|
|
352
|
+
- [The manifest](manifest.md) — where the subjects end up.
|
|
353
|
+
- [Translating outside templates](translator.md) — the same catalogues in your
|
|
354
|
+
application's code.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Editor and type checking
|
|
2
|
+
|
|
3
|
+
This page is for getting `t`, `locale` and `placeholder` typed in your
|
|
4
|
+
templates, so the editor completes a message key and flags a wrong call before
|
|
5
|
+
you build.
|
|
6
|
+
|
|
7
|
+
```jsonc
|
|
8
|
+
// tsconfig.json — the official starter's, unchanged
|
|
9
|
+
{
|
|
10
|
+
"include": ["**/*.vue", ".maizzle/*.d.ts"]
|
|
11
|
+
}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```jsonc
|
|
15
|
+
// package.json — the starter's postinstall, and a check for CI
|
|
16
|
+
{
|
|
17
|
+
"scripts": {
|
|
18
|
+
"postinstall": "maizzle prepare",
|
|
19
|
+
"typecheck": "vue-tsc --noEmit"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bun add -d vue-tsc vue
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
In the editor, install Vue's language tools (the **Vue - Official**
|
|
29
|
+
extension). With the catalogue of [Catalogues](catalogues.md), a template is
|
|
30
|
+
then checked against its keys and arguments:
|
|
31
|
+
|
|
32
|
+
```vue
|
|
33
|
+
<!-- emails/verify-email.vue -->
|
|
34
|
+
<template>
|
|
35
|
+
<p>{{ t('verifyEmail.titel') }}</p>
|
|
36
|
+
<!-- Argument of type '"verifyEmail.titel"' is not assignable to parameter of type 'keyof TemplateMessages'. -->
|
|
37
|
+
<p>{{ t('verifyEmail.greeting') }}</p>
|
|
38
|
+
<!-- Expected 2 arguments, but got 1. -->
|
|
39
|
+
<p>{{ t('verifyEmail.title', { name: 'Ada' }) }}</p>
|
|
40
|
+
<!-- Type 'string' is not assignable to type 'never'. -->
|
|
41
|
+
<p>{{ t('verifyEmail.expires', { minutes: placeholder('minutes') }) }}</p>
|
|
42
|
+
<!-- Type 'string' is not assignable to type 'number'. -->
|
|
43
|
+
</template>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Each of these would also fail the build (see
|
|
47
|
+
[What fails the build](templates.md#what-fails-the-build)); the types report
|
|
48
|
+
it as you type.
|
|
49
|
+
|
|
50
|
+
## Where the types come from
|
|
51
|
+
|
|
52
|
+
`maizzle.config.ts` is not in the starter's `tsconfig.json`, so nothing it
|
|
53
|
+
imports reaches the editor. `i18n()` therefore writes a declaration file where
|
|
54
|
+
the starter looks, `.maizzle/nxgt-mail-i18n.d.ts`, each time the config
|
|
55
|
+
loads:
|
|
56
|
+
|
|
57
|
+
- `maizzle prepare` — the starter runs it on `postinstall`;
|
|
58
|
+
- `maizzle serve`, when it starts and when a saved catalogue reloads the config;
|
|
59
|
+
- `maizzle build`.
|
|
60
|
+
|
|
61
|
+
It lists every key of the fallback locale's catalogue — your
|
|
62
|
+
`locales/<locale>.json` merged over the `catalogues` option, so a package's
|
|
63
|
+
shared messages are in it too — with the arguments each message declares:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// .maizzle/nxgt-mail-i18n.d.ts, generated
|
|
67
|
+
// Generated by @nxgt/mail-i18n from the en catalogue, each time the config loads.
|
|
68
|
+
// Never edited, never committed: it types t() in the templates.
|
|
69
|
+
import type {} from '@nxgt/mail-i18n';
|
|
70
|
+
|
|
71
|
+
declare module '@nxgt/mail-i18n' {
|
|
72
|
+
interface TemplateMessages {
|
|
73
|
+
'verifyEmail.action': { };
|
|
74
|
+
'verifyEmail.expires': { minutes: number };
|
|
75
|
+
'verifyEmail.greeting': { name: string | number };
|
|
76
|
+
'verifyEmail.sentOn': { at: Date | number };
|
|
77
|
+
'verifyEmail.subject': { name: string | number };
|
|
78
|
+
'verifyEmail.title': { };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The file is rewritten only when its text changes, and only by the main thread
|
|
84
|
+
of a parallel build. `.maizzle/` is already in the starter's `.gitignore`;
|
|
85
|
+
the file is never committed. It is written under the directory `maizzle` runs
|
|
86
|
+
in, so run it from the project root.
|
|
87
|
+
|
|
88
|
+
**After changing a catalogue**, the types follow on the next config load:
|
|
89
|
+
save it while `maizzle serve` runs (the server reloads the config) or restart
|
|
90
|
+
the server, or run `maizzle prepare` or `maizzle build`. If the editor still
|
|
91
|
+
shows the old keys, restart its Vue language server.
|
|
92
|
+
|
|
93
|
+
## What `t` accepts
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// what the package declares for Vue's template checker
|
|
97
|
+
declare module 'vue' {
|
|
98
|
+
interface ComponentCustomProperties {
|
|
99
|
+
t<K extends TemplateKey>(key: K, ...args: TemplateArgs<K>): string;
|
|
100
|
+
readonly locale: string;
|
|
101
|
+
placeholder(name: string): string;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// exported from '@nxgt/mail-i18n'
|
|
106
|
+
interface TemplateMessages {} // filled by the generated file
|
|
107
|
+
type TemplateKey = [keyof TemplateMessages] extends [never] ? string : keyof TemplateMessages;
|
|
108
|
+
// simplified: the checks in capitals are spelled out in the package
|
|
109
|
+
type TemplateArgs<K> = [K] extends [keyof TemplateMessages]
|
|
110
|
+
? EVERY_KEY<K> extends true
|
|
111
|
+
? [args?: MessageArgs] // any message: see below
|
|
112
|
+
: SAME_ARGUMENT_NAMES<K> extends true // every message K may be
|
|
113
|
+
? NO_ARGUMENT<K> extends true
|
|
114
|
+
? [args?: Readonly<Record<string, never>>] // a message with no argument
|
|
115
|
+
: [args: Readonly<ALL_OF<TemplateMessages[K]>>] // required, exactly these
|
|
116
|
+
: [args: never] // messages with different arguments: no call fits
|
|
117
|
+
: [args?: MessageArgs]; // while no key is declared
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
When the catalogues have more than one key, TypeScript reads a key they do not
|
|
121
|
+
have as *every* key, so a key that may be any message takes any arguments:
|
|
122
|
+
the error it reports is then the key's, `Argument of type '"verifyEmail.titel"' is not assignable to parameter
|
|
123
|
+
of type 'keyof TemplateMessages'`, rather than one about the arguments. A key
|
|
124
|
+
typed as every key on purpose is checked by the build alone, and so is a key
|
|
125
|
+
that may be every message — `ok ? 'a' : 'b'` in catalogues of only those two
|
|
126
|
+
keys:
|
|
127
|
+
|
|
128
|
+
```vue
|
|
129
|
+
<script setup lang="ts">
|
|
130
|
+
import type { TemplateKey } from '@nxgt/mail-i18n';
|
|
131
|
+
const reason = 'security.reason.newDevice' as TemplateKey; // any message: any arguments
|
|
132
|
+
</script>
|
|
133
|
+
<template>
|
|
134
|
+
<Text>{{ t(reason, { device: placeholder('device') }) }}</Text>
|
|
135
|
+
</template>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
A key that may be one of several messages, as
|
|
139
|
+
`t(ok ? 'verifyEmail.greeting' : 'verifyEmail.subject', { name })`, compiles
|
|
140
|
+
when every one of them uses the same argument names, and takes arguments all
|
|
141
|
+
of them accept. Otherwise no call fits: the build refuses an argument a
|
|
142
|
+
message does not use, and one it leaves out.
|
|
143
|
+
|
|
144
|
+
Each kind of argument, as the catalogue declares it, takes:
|
|
145
|
+
|
|
146
|
+
| In the message | Kind | `t` accepts |
|
|
147
|
+
| --- | --- | --- |
|
|
148
|
+
| `{name}`, `{plan, select, …}` | string | `string \| number` — a placeholder too |
|
|
149
|
+
| `{n, number}`, `{n, plural, …}` | number | `number` — never a placeholder |
|
|
150
|
+
| `{at, date}`, `{at, time}` | date | `Date \| number` (a timestamp) |
|
|
151
|
+
|
|
152
|
+
```vue
|
|
153
|
+
<Text>{{ t('verifyEmail.greeting', { name: placeholder('name') }) }}</Text>
|
|
154
|
+
<Text>{{ t('verifyEmail.expires', { minutes: 15 }) }}</Text>
|
|
155
|
+
<Text>{{ t('verifyEmail.sentOn', { at: Date.UTC(2026, 0, 2) }) }}</Text>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
What the types do not see, the build still refuses: a placeholder passed to an
|
|
159
|
+
argument a `select` chooses on compiles, since the argument is a string, and
|
|
160
|
+
fails the build — see
|
|
161
|
+
[a placeholder as an argument](templates.md#a-placeholder-as-an-argument).
|
|
162
|
+
|
|
163
|
+
**Without the file** — before the first `maizzle prepare`, or with a
|
|
164
|
+
`tsconfig.json` that leaves `.maizzle/*.d.ts` out — nothing in the program
|
|
165
|
+
loads `@nxgt/mail-i18n`, so the template checker does not know `t`, `locale`
|
|
166
|
+
or `placeholder` at all: `Property 't' does not exist on type
|
|
167
|
+
'ComponentPublicInstance<…>'`. The build is not affected; see
|
|
168
|
+
[the troubleshooting entry](../troubleshooting.md#the-editor-says-property-t-does-not-exist-in-a-template-or-completes-no-key).
|
|
169
|
+
With the file but catalogues that declare no key, `t` takes any string with
|
|
170
|
+
any arguments, as a plain `t(key: string, args?: MessageArgs)`.
|
|
171
|
+
|
|
172
|
+
**Only where Maizzle's starter keeps its types.** The file goes to
|
|
173
|
+
`.maizzle/` in the folder Maizzle runs from. A project that sets Maizzle's
|
|
174
|
+
`root`, or a Laravel project (whose types Maizzle writes to
|
|
175
|
+
`resources/js/types/maizzle`), must include that `.maizzle/*.d.ts` in its
|
|
176
|
+
`tsconfig.json` itself.
|
|
177
|
+
|
|
178
|
+
## With Biome
|
|
179
|
+
|
|
180
|
+
If Biome is the editor's linter, set
|
|
181
|
+
`html.experimentalFullSupportEnabled` in `biome.json`: otherwise Biome 2.5
|
|
182
|
+
reads a template without `<script>` as JavaScript once you edit it — see
|
|
183
|
+
[the troubleshooting entry](../troubleshooting.md#biome-reports-parse-errors-in-a-template-as-soon-as-you-edit-it).
|
|
184
|
+
|
|
185
|
+
## In CI
|
|
186
|
+
|
|
187
|
+
`vue-tsc` checks the templates as the editor does. Generate the types first:
|
|
188
|
+
`bun install` runs the `postinstall` above, or call `maizzle prepare`
|
|
189
|
+
yourself.
|
|
190
|
+
|
|
191
|
+
```yaml
|
|
192
|
+
# a CI step
|
|
193
|
+
- run: bun install # postinstall: maizzle prepare
|
|
194
|
+
- run: bunx vue-tsc --noEmit
|
|
195
|
+
- run: bunx maizzle build
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
To hold a refusal in place — a call that must keep failing to compile — mark
|
|
199
|
+
it with `@vue-expect-error` in a template the build does not read (outside
|
|
200
|
+
`emails/`); `vue-tsc` fails the moment the call compiles again:
|
|
201
|
+
|
|
202
|
+
```vue
|
|
203
|
+
<!-- types/refusals.vue — listed in tsconfig.json's include, never built -->
|
|
204
|
+
<template>
|
|
205
|
+
<!-- @vue-expect-error -->
|
|
206
|
+
{{ t('verify_email.title') }}
|
|
207
|
+
</template>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## See also
|
|
211
|
+
|
|
212
|
+
- [Templates](templates.md) — what `t`, `locale` and `placeholder` do at build
|
|
213
|
+
time, and every build failure.
|
|
214
|
+
- [Catalogues](catalogues.md) — the kinds of argument, and the fallback
|
|
215
|
+
locale's role.
|