@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,1096 @@
1
+ # Troubleshooting `@nxgt/mail-i18n`
2
+
3
+ Each entry is headed by the message you see. The last section holds the traps
4
+ that fail no build — a build that succeeds and is wrong. Search this page for
5
+ the words of your message.
6
+
7
+ How the messages are shaped:
8
+
9
+ - **A wiring mistake is a `TypeError`**, from the call you wrote: `i18n: …`
10
+ when `maizzle.config.ts` loads, `createTranslator: …` when your server
11
+ creates its translator. Fix the call.
12
+ - **A build failure is a plain `Error` starting `i18n:`**, naming what to fix
13
+ (the locale, the template, the key or the file). It has no `code`: it is a
14
+ mistake in the templates or the catalogues, to fix, not a condition to
15
+ catch. The catalogues are checked when `maizzle.config.ts` loads, before
16
+ any template is built; a template is checked while it is rendered; the
17
+ subjects and the files after the build.
18
+ - **A run-time failure is a plain `Error` starting `t:`**, from the `t` that
19
+ `createTranslator` answers, and like a build failure it is a mistake to
20
+ fix. It throws where `@nxgt/i18n` would answer the key: an e-mail is not
21
+ sent with a key in it.
22
+ - **The error's own message never holds the text of a message.** Its
23
+ `cause` may: a "could not be formatted" error keeps the formatter's error
24
+ as its `cause`, and that error quotes the message, for debugging. A
25
+ catalogue's text is not a secret; the values filled at send time never
26
+ reach these errors.
27
+
28
+ The samples below use the locales `en` (the fallback locale) and `fr`, the
29
+ template `emails/verify-email.vue`, and keys such as `verifyEmail.title`.
30
+
31
+ ## Index
32
+
33
+ **Install and types**
34
+ - [Which `moduleResolution` is supported](#install-and-types)
35
+
36
+ **Wiring**
37
+ - [`i18n: options must be an object, as { locales: ['en', 'fr'] }`](#i18n-options-must-be-an-object-as--locales-en-fr-)
38
+ - [`i18n: locales must hold at least one locale, as ['en', 'fr']`](#i18n-locales-must-hold-at-least-one-locale-as-en-fr)
39
+ - [`i18n: locales holds something that is not a locale — write each as a BCP 47 tag, as en or pt-BR`](#i18n-locales-holds-something-that-is-not-a-locale--write-each-as-a-bcp-47-tag-as-en-or-pt-br)
40
+ - [`i18n: locales holds the same locale twice`](#i18n-locales-holds-the-same-locale-twice)
41
+ - [`i18n: fallbackLocale must be one of locales`](#i18n-fallbacklocale-must-be-one-of-locales)
42
+ - [`i18n: dir must be a folder of the project`](#i18n-dir-must-be-a-folder-of-the-project)
43
+ - [`i18n: layout must be 'nested' or 'flat'`](#i18n-layout-must-be-nested-or-flat)
44
+ - [`i18n: catalogues must be a list of catalogues by locale, as [{ en: {...}, fr: {...} }]`](#i18n-catalogues-must-be-a-list-of-catalogues-by-locale-as--en--fr--)
45
+ - [`i18n: templates must be a list of template folders, as [{ dir: '/abs/path/emails' }] — emails, when given, names at least one, each once`](#i18n-templates-must-be-a-list-of-template-folders-as--dir-abspathemails---emails-when-given-names-at-least-one-each-once)
46
+ - [`i18n: templates[0] has no template sign-in.vue — name one of its e-mails`](#i18n-templates0-has-no-template-sign-invue--name-one-of-its-e-mails)
47
+ - [`i18n: templates[0] holds no template — is … the folder of a package's e-mails?`](#i18n-templates0-holds-no-template--is--the-folder-of-a-packages-e-mails)
48
+ - [`i18n: templates[0] and templates[1] both have welcome.vue — keep one with emails: [...], or write the project's own in its folder`](#i18n-templates0-and-templates1-both-have-welcomevue--keep-one-with-emails--or-write-the-projects-own-in-its-folder)
49
+ - [`i18n: rendererTypes must be the path of a .ts file, as generated/mail.ts, or false`](#i18n-renderertypes-must-be-the-path-of-a-ts-file-as-generatedmailts-or-false)
50
+ - [`createTranslator: catalogues must be an object of catalogues by locale, as { en, fr }`](#createtranslator-catalogues-must-be-an-object-of-catalogues-by-locale-as--en-fr-)
51
+ - [`createTranslator: getLanguage must be a locale or a function that answers one`](#createtranslator-getlanguage-must-be-a-locale-or-a-function-that-answers-one)
52
+
53
+ **Catalogues** — when `maizzle.config.ts` loads
54
+ - [`i18n: locales/fr.json is missing — every locale has a catalogue`](#i18n-localesfrjson-is-missing--every-locale-has-a-catalogue)
55
+ - [`i18n: locales/fr.json is not valid JSON`](#i18n-localesfrjson-is-not-valid-json)
56
+ - [`i18n: fr: the catalogue must be an object of messages`](#i18n-fr-the-catalogue-must-be-an-object-of-messages)
57
+ - [`i18n: en: verifyEmail.title must be a message (a string) or an object of messages`](#i18n-en-verifyemailtitle-must-be-a-message-a-string-or-an-object-of-messages)
58
+ - [`i18n: en: verify-email is not camelCase — every segment of a key is camelCase, and nested rather than dotted, as verifyEmail.title`](#i18n-en-verify-email-is-not-camelcase--every-segment-of-a-key-is-camelcase-and-nested-rather-than-dotted-as-verifyemailtitle)
59
+ - [`i18n: en: verifyEmail.greeting is not a valid ICU message (EXPECT_ARGUMENT_CLOSING_BRACE)`](#i18n-en-verifyemailgreeting-is-not-a-valid-icu-message-expect_argument_closing_brace)
60
+ - [`i18n: en: verifyEmail.greeting uses {first_name}, which is not camelCase — an argument is a camelCase name, as {firstName}`](#i18n-en-verifyemailgreeting-uses-first_name-which-is-not-camelcase--an-argument-is-a-camelcase-name-as-firstname)
61
+ - [`i18n: en: verifyEmail.expires uses {minutes} as number and as date`](#i18n-en-verifyemailexpires-uses-minutes-as-number-and-as-date)
62
+ - [`i18n: fr: verifyEmail.title is missing — en, the fallback locale, has it`](#i18n-fr-verifyemailtitle-is-missing--en-the-fallback-locale-has-it)
63
+ - [`i18n: fr: verifyEmail.titel is not a key of en, the fallback locale`](#i18n-fr-verifyemailtitel-is-not-a-key-of-en-the-fallback-locale)
64
+ - [`i18n: fr: verifyEmail.title uses {name}, which en does not declare`](#i18n-fr-verifyemailtitle-uses-name-which-en-does-not-declare)
65
+ - [`i18n: fr: verifyEmail.expires uses {minutes} as date, and en declares it as number`](#i18n-fr-verifyemailexpires-uses-minutes-as-date-and-en-declares-it-as-number)
66
+
67
+ **Templates** — while `maizzle build` renders
68
+ - [`[Vue warn]: Unhandled error during execution of render function`](#vue-warn-unhandled-error-during-execution-of-render-function)
69
+ - [`i18n: emails/Welcome.vue is not a kebab-case name — name a template as verify-email.vue`](#i18n-emailswelcomevue-is-not-a-kebab-case-name--name-a-template-as-verify-emailvue)
70
+ - [`i18n: emails/verify-email.vue is not built through the i18n plugin — leave content to it, and put templates in emails/`](#i18n-emailsverify-emailvue-is-not-built-through-the-i18n-plugin--leave-content-to-it-and-put-templates-in-emails)
71
+ - [`i18n: en: verify-email calls t('verifyEmail.titel'), which is not a key of the catalogues`](#i18n-en-verify-email-calls-tverifyemailtitel-which-is-not-a-key-of-the-catalogues)
72
+ - [`i18n: en: verify-email calls t('verifyEmail.expires') without {minutes}`](#i18n-en-verify-email-calls-tverifyemailexpires-without-minutes)
73
+ - [`i18n: en: verify-email passes {minutes} to verifyEmail.expires as a string — the message uses it as a number`](#i18n-en-verify-email-passes-minutes-to-verifyemailexpires-as-a-string--the-message-uses-it-as-a-number)
74
+ - [`i18n: en: verify-email passes {name} to verifyEmail.title, which does not use it`](#i18n-en-verify-email-passes-name-to-verifyemailtitle-which-does-not-use-it)
75
+ - [`i18n: en: verify-email calls t('verifyEmail.title') with arguments that are not an object, as { name: placeholder('name') }`](#i18n-en-verify-email-calls-tverifyemailtitle-with-arguments-that-are-not-an-object-as--name-placeholdername-)
76
+ - [`i18n: en: verify-email passes a placeholder to {plan}, which verifyEmail.title chooses on with a select — a placeholder always chooses other`](#i18n-en-verify-email-passes-a-placeholder-to-plan-which-verifyemailtitle-chooses-on-with-a-select--a-placeholder-always-chooses-other)
77
+ - [`i18n: en: verify-email calls placeholder() with a name that is not camelCase — as placeholder('firstName')`](#i18n-en-verify-email-calls-placeholder-with-a-name-that-is-not-camelcase--as-placeholderfirstname)
78
+ - [`i18n: en: verifyEmail.sentOn could not be formatted`](#i18n-en-verifyemailsenton-could-not-be-formatted)
79
+
80
+ **Manifest and subject** — after the build
81
+ - [`i18n: welcome has no subject — add welcome.subject to the catalogues`](#i18n-welcome-has-no-subject--add-welcomesubject-to-the-catalogues)
82
+ - [`i18n: en: verifyEmail.subject uses {minutes} as a number — a subject's arguments are placeholders, filled at send time as strings`](#i18n-en-verifyemailsubject-uses-minutes-as-a-number--a-subjects-arguments-are-placeholders-filled-at-send-time-as-strings)
83
+ - [`i18n: en: welcome.subject chooses on {kind} with a select — a subject's arguments are placeholders, which always choose other`](#i18n-en-welcomesubject-chooses-on-kind-with-a-select--a-subjects-arguments-are-placeholders-which-always-choose-other)
84
+ - [`i18n: fr/welcome.html is empty — a tag of its template resolved to no component; list the plugin that brings it, as ui()`](#i18n-frwelcomehtml-is-empty--a-tag-of-its-template-resolved-to-no-component-list-the-plugin-that-brings-it-as-ui)
85
+ - [`i18n: welcome was not built in fr`](#i18n-welcome-was-not-built-in-fr)
86
+ - [`i18n: ../text/welcome.en.txt was written outside the output folder — …`](#i18n-textwelcomeentxt-was-written-outside-the-output-folder--the-i18n-plugin-lays-out-every-e-mail-set-no-plaintextdestination-and-no-output-path-in-a-template)
87
+ - [`i18n: custom/welcome.html is not where the i18n plugin puts an e-mail — set no output path in a template`](#i18n-customwelcomehtml-is-not-where-the-i18n-plugin-puts-an-e-mail--set-no-output-path-in-a-template)
88
+ - [`i18n: src/index.ts was not written by i18n() — point rendererTypes at a file of its own`](#i18n-srcindexts-was-not-written-by-i18n--point-renderertypes-at-a-file-of-its-own)
89
+
90
+ **Run time** — `createTranslator`'s `t`
91
+ - [`t: the language is not a locale of the catalogues — pick one with pickLocale`](#t-the-language-is-not-a-locale-of-the-catalogues--pick-one-with-picklocale)
92
+ - [`t: fr: verifyEmail.titel is not a key`](#t-fr-verifyemailtitel-is-not-a-key)
93
+ - [`t: en: verifyEmail.expires could not be formatted`](#t-en-verifyemailexpires-could-not-be-formatted)
94
+
95
+ **Traps: a build that succeeds and is wrong**
96
+ - [`[Vue warn]: Property "name" was accessed during render but is not defined on instance.`](#vue-warn-property-name-was-accessed-during-render-but-is-not-defined-on-instance)
97
+ - [A link's placeholder is prefixed with a domain](#a-links-placeholder-is-prefixed-with-a-domain)
98
+ - [`.maizzle/` shows up in `git status`](#maizzle-shows-up-in-git-status)
99
+ - [`No templates found`, or old templates, when Maizzle is built from a worker thread](#no-templates-found-or-old-templates-when-maizzle-is-built-from-a-worker-thread)
100
+ - [The editor says `Property 't' does not exist` in a template, or completes no key](#the-editor-says-property-t-does-not-exist-in-a-template-or-completes-no-key)
101
+ - [The editor flags a key you just added to a catalogue](#the-editor-flags-a-key-you-just-added-to-a-catalogue)
102
+ - [Biome reports `parse` errors in a template as soon as you edit it](#biome-reports-parse-errors-in-a-template-as-soon-as-you-edit-it)
103
+ - [A bug in `@nxgt/mail-i18n` itself](#a-bug-in-nxgtmail-i18n-itself)
104
+
105
+ ---
106
+
107
+ ## Install and types
108
+
109
+ Resolve as a bundler does (`"moduleResolution": "bundler"`, as Maizzle's jiti
110
+ loader does): that is the supported contract. `nodenext` and `node16` are out
111
+ of contract — they may work today, and are not tested.
112
+
113
+ ---
114
+
115
+ ## Wiring
116
+
117
+ ### `i18n: options must be an object, as { locales: ['en', 'fr'] }`
118
+
119
+ **When:** loading `maizzle.config.ts`, when `i18n` is called with nothing,
120
+ `null`, or a string such as `i18n('en')`.
121
+ **Why:** `i18n` takes one object of options; `locales` is the one it
122
+ requires.
123
+ **Fix:**
124
+
125
+ ```ts
126
+ // maizzle.config.ts
127
+ import { defineMailConfig } from '@nxgt/mail-config';
128
+ import { i18n } from '@nxgt/mail-i18n';
129
+
130
+ export default defineMailConfig({
131
+ plugins: [i18n({ locales: ['en', 'fr'] })], // not i18n('en')
132
+ });
133
+ ```
134
+
135
+ ### `i18n: locales must hold at least one locale, as ['en', 'fr']`
136
+
137
+ **When:** loading `maizzle.config.ts`, when `locales` is missing, empty, or a
138
+ single string — or the locales passed alone, `i18n(['en', 'fr'])`: a list is
139
+ an object, and it has no `locales`.
140
+ **Why:** the plugin builds each template once per locale; with none there is
141
+ nothing to build.
142
+ **Fix:**
143
+
144
+ ```ts
145
+ i18n({ locales: ['en'] }); // not locales: 'en', not i18n(['en'])
146
+ ```
147
+
148
+ ### `i18n: locales holds something that is not a locale — write each as a BCP 47 tag, as en or pt-BR`
149
+
150
+ **When:** loading `maizzle.config.ts`, for a locale such as `'EN'`, `'pt_BR'`,
151
+ `'french'` or `''`.
152
+ **Why:** a locale names a catalogue (`locales/pt-BR.json`) and an output
153
+ folder, and is handed to `Intl` to format numbers and dates. It is a BCP 47
154
+ tag: a lowercase language of two or three letters, then `-` and subtags.
155
+ **Fix:**
156
+
157
+ ```ts
158
+ i18n({ locales: ['en', 'pt-BR'] }); // not 'EN', not 'pt_BR'
159
+ ```
160
+
161
+ ### `i18n: locales holds the same locale twice`
162
+
163
+ **When:** loading `maizzle.config.ts`, when a locale is listed twice —
164
+ often after joining two lists.
165
+ **Why:** each locale is built once, into its own files; a duplicate would
166
+ write the same files twice.
167
+ **Fix:**
168
+
169
+ ```ts
170
+ i18n({ locales: [...new Set([...ours, ...shared])] });
171
+ ```
172
+
173
+ ### `i18n: fallbackLocale must be one of locales`
174
+
175
+ **When:** loading `maizzle.config.ts`, when `fallbackLocale` is set to a
176
+ locale `locales` does not list.
177
+ **Why:** every other catalogue is checked against the fallback locale's; it
178
+ must have a catalogue itself.
179
+ **Fix:**
180
+
181
+ ```ts
182
+ i18n({ locales: ['en', 'fr'], fallbackLocale: 'en' });
183
+ ```
184
+
185
+ Leave `fallbackLocale` out to use the first locale of `locales`.
186
+
187
+ ### `i18n: dir must be a folder of the project`
188
+
189
+ The option named is `dir` or `emails`.
190
+
191
+ **When:** loading `maizzle.config.ts`, when `dir` (the catalogues' folder) or
192
+ `emails` (the templates' folder) is not a string, or is empty.
193
+ **Why:** both are paths from the project's root, where `maizzle` runs.
194
+ **Fix:**
195
+
196
+ ```ts
197
+ i18n({ locales: ['en', 'fr'], dir: 'i18n', emails: 'templates' });
198
+ ```
199
+
200
+ Leave them out for the defaults, `locales` and `emails`.
201
+
202
+ ### `i18n: layout must be 'nested' or 'flat'`
203
+
204
+ **When:** loading `maizzle.config.ts`, for any other `layout`.
205
+ **Why:** `nested` writes `dist/en/verify-email.html`; `flat` writes
206
+ `dist/verify-email.en.html`. There is no third way.
207
+ **Fix:**
208
+
209
+ ```ts
210
+ i18n({ locales: ['en', 'fr'], layout: 'flat' });
211
+ ```
212
+
213
+ ### `i18n: catalogues must be a list of catalogues by locale, as [{ en: {...}, fr: {...} }]`
214
+
215
+ **When:** loading `maizzle.config.ts`, when `catalogues` is one set of
216
+ catalogues rather than a list of them — `catalogues: uiCatalogues` — or the
217
+ list holds something else than an object of catalogues keyed by locale:
218
+ `null`, or a locale whose catalogue is a string (`[{ en: 'Hello' }]`).
219
+ **Why:** `catalogues` is a list, so that several packages can each ship
220
+ their messages. Each entry is keyed by locale, like the project's
221
+ `locales/<locale>.json`; each is merged key by key under the next, and the
222
+ project's own catalogues over all of them.
223
+ **Fix:** wrap it in a list, even when there is one:
224
+
225
+ ```ts
226
+ // maizzle.config.ts
227
+ import { defineMailConfig } from '@nxgt/mail-config';
228
+ import { i18n } from '@nxgt/mail-i18n';
229
+ import { ui, uiCatalogues } from '@nxgt/mail-ui';
230
+
231
+ export default defineMailConfig({
232
+ plugins: [
233
+ ui({ brand: { name: 'Acme' } }),
234
+ i18n({ locales: ['en', 'fr'], catalogues: [uiCatalogues] }), // not catalogues: uiCatalogues
235
+ ],
236
+ });
237
+ ```
238
+
239
+ ### `i18n: templates must be a list of template folders, as [{ dir: '/abs/path/emails' }] — emails, when given, names at least one, each once`
240
+
241
+ **When:** loading `maizzle.config.ts`, when `templates` is one folder rather
242
+ than a list of them, as in `templates: mails.templates`. It also appears when
243
+ an entry of the list is not `{ dir, emails? }` with `dir` an absolute path
244
+ and `emails` a list of at least one name, each once. Examples:
245
+ `[{ dir: 'presets' }]`, `[{ dir, emails: 'welcome' }]`, `[{ dir, emails: [] }]`,
246
+ `[{ dir, emails: ['welcome', 'welcome'] }]`, or the folder alone as a string.
247
+ **Why:** `templates` is a list, so that several packages can each ship
248
+ templates, and each `dir` is absolute because a package answers where it is
249
+ installed, not a path from your project. An empty `emails` would build
250
+ nothing from the folder; leave it out to build every e-mail of it. The
251
+ project's `emails/` comes first, so a template of the same name there
252
+ replaces a package's.
253
+ **Fix:** wrap it in a list, even when there is one:
254
+
255
+ ```ts
256
+ // maizzle.config.ts
257
+ import { defineMailConfig } from '@nxgt/mail-config';
258
+ import { i18n } from '@nxgt/mail-i18n';
259
+ import { presets } from '@nxgt/mail-presets';
260
+ import { ui, uiCatalogues } from '@nxgt/mail-ui';
261
+
262
+ const mails = presets();
263
+
264
+ export default defineMailConfig({
265
+ plugins: [
266
+ ui({ brand: { name: 'Acme' } }),
267
+ i18n({
268
+ locales: ['en', 'fr'],
269
+ catalogues: [uiCatalogues, mails.catalogues],
270
+ templates: [mails.templates], // not templates: mails.templates
271
+ }),
272
+ ],
273
+ });
274
+ ```
275
+
276
+ ### `i18n: templates[0] has no template sign-in.vue — name one of its e-mails`
277
+
278
+ **When:** loading `maizzle.config.ts`, so `maizzle build` stops before any
279
+ template is built. It happens when an entry of `templates` lists in `emails`
280
+ a name its folder has no `.vue` file for. Common causes are a typo, a name
281
+ with `.vue`, or an e-mail the package renamed or does not ship. The message
282
+ names the entry by its place in `templates`.
283
+ **Why:** `emails` keeps the templates it names and no other, so a name with
284
+ no template would silently build nothing.
285
+ **Fix:** name a template of that folder, without `.vue`. For
286
+ `@nxgt/mail-presets`, let `presets({ only })` write the entry. It checks the
287
+ names when it is called, and TypeScript checks them too:
288
+
289
+ ```ts
290
+ import { presets } from '@nxgt/mail-presets';
291
+
292
+ const mails = presets({ only: ['verify-email', 'sign-in-code'] });
293
+
294
+ i18n({
295
+ locales: ['en', 'fr'],
296
+ catalogues: [uiCatalogues, mails.catalogues],
297
+ templates: [mails.templates], // not [{ dir: TEMPLATES_DIR, emails: ['sign-in'] }]
298
+ });
299
+ ```
300
+
301
+ ### `i18n: templates[0] holds no template — is … the folder of a package's e-mails?`
302
+
303
+ The message names the folder, as an absolute path, where `…` is here.
304
+
305
+ **When:** loading `maizzle.config.ts`, so `maizzle build` stops before any
306
+ template is built. It happens when an entry of `templates` points at a folder
307
+ that does not exist, or that holds no `.vue` file. Common causes are a `dir`
308
+ written by hand that names the package's root rather than its `emails/`
309
+ folder, a typo in the path, or a package installed without its templates.
310
+ **Why:** an entry of `templates` is a package's folder of e-mails. One that
311
+ holds none would build nothing from it, and the build would pass.
312
+ **Fix:** take the entry from the package rather than writing its path. For
313
+ `@nxgt/mail-presets`, `presets().templates` is the folder its templates ship
314
+ in:
315
+
316
+ ```ts
317
+ import { presets } from '@nxgt/mail-presets';
318
+
319
+ const mails = presets();
320
+
321
+ i18n({
322
+ locales: ['en', 'fr'],
323
+ catalogues: [uiCatalogues, mails.catalogues],
324
+ templates: [mails.templates], // not [{ dir: '/…/node_modules/@nxgt/mail-presets' }]
325
+ });
326
+ ```
327
+
328
+ When the entry comes from the package and the error remains, reinstall it:
329
+ its templates are missing from `node_modules`.
330
+
331
+ ### `i18n: templates[0] and templates[1] both have welcome.vue — keep one with emails: [...], or write the project's own in its folder`
332
+
333
+ **When:** loading `maizzle.config.ts`, so `maizzle build` stops before any
334
+ template is built. It happens when two entries of `templates` each ship an
335
+ e-mail of the same name.
336
+ **Why:** each e-mail is built once, under its name. Between two packages,
337
+ neither is the obvious one, so the build refuses to pick. The project's own
338
+ `emails/` is not concerned: a template there replaces a package's of the same
339
+ name, on purpose.
340
+ **Fix:** keep the e-mail from one package only, with `emails` on the other,
341
+ or write the project's own `emails/welcome.vue`, which replaces both:
342
+
343
+ ```ts
344
+ i18n({
345
+ locales: ['en', 'fr'],
346
+ templates: [
347
+ { dir: onboardingDir }, // its welcome.vue is the one built
348
+ { dir: accountDir, emails: ['verify-email', 'reset-password'] }, // not its welcome.vue
349
+ ],
350
+ });
351
+ ```
352
+
353
+ With `@nxgt/mail-presets`, `presets({ only: [...] })` writes that entry, and
354
+ leaves out the presets not named.
355
+
356
+ ### `i18n: rendererTypes must be the path of a .ts file, as generated/mail.ts, or false`
357
+
358
+ **When:** loading `maizzle.config.ts`, when `rendererTypes` is `true`, an
359
+ empty string, a number, or a path that does not end in `.ts`
360
+ (`'generated/mail.d.json'`, `'generated/'`).
361
+ **Why:** `rendererTypes` says where each build writes `MailEmails`, the type
362
+ `createMailRenderer<MailEmails>` takes: a TypeScript module the code that
363
+ sends imports, resolved against the folder `maizzle` runs in. It is written
364
+ by default; there is nothing to turn on.
365
+ **Fix:** leave it out for `generated/mail.ts`, give the path of a `.ts` file,
366
+ or `false` for no file:
367
+
368
+ ```ts
369
+ i18n({ locales: ['en', 'fr'] }); // generated/mail.ts
370
+ i18n({ locales: ['en', 'fr'], rendererTypes: 'src/generated/mail.ts' }); // elsewhere
371
+ i18n({ locales: ['en', 'fr'], rendererTypes: false }); // none
372
+ ```
373
+
374
+ See [The manifest — the renderer's types](guide/manifest.md#the-renderers-types--generatedmailts).
375
+
376
+ ### `createTranslator: catalogues must be an object of catalogues by locale, as { en, fr }`
377
+
378
+ **When:** calling `createTranslator`, with `null`, `undefined` or no argument
379
+ — typically a JSON import that answered nothing.
380
+ **Why:** the first argument is the catalogues, keyed by locale.
381
+ **Fix:**
382
+
383
+ ```ts
384
+ import { createTranslator } from '@nxgt/mail-i18n';
385
+ import en from './locales/en.json';
386
+ import fr from './locales/fr.json';
387
+
388
+ const t = createTranslator({ en, fr }, 'en');
389
+ ```
390
+
391
+ ### `createTranslator: getLanguage must be a locale or a function that answers one`
392
+
393
+ **When:** calling `createTranslator`, with a second argument that is neither a
394
+ string nor a function — often the result of calling the function instead of
395
+ passing it, when that result is `undefined`.
396
+ **Why:** the language is read at each call of `t`, from a locale or from a
397
+ function; it is not fixed from a value that may be missing.
398
+ **Fix:**
399
+
400
+ ```ts
401
+ import { pickLocale } from '@nxgt/mail';
402
+ import { createTranslator } from '@nxgt/mail-i18n';
403
+
404
+ const t = createTranslator({ en, fr }, () =>
405
+ pickLocale(user.locale, ['en', 'fr'], 'en'),
406
+ );
407
+ ```
408
+
409
+ ---
410
+
411
+ ## Catalogues
412
+
413
+ These fail when `maizzle.config.ts` loads: `maizzle build` and `maizzle serve`
414
+ stop before any template is built. The locale in the message is the
415
+ catalogue's; fix that file.
416
+
417
+ ### `i18n: locales/fr.json is missing — every locale has a catalogue`
418
+
419
+ **When:** loading `maizzle.config.ts`, when a locale of `locales` has no
420
+ `<dir>/<locale>.json`.
421
+ **Why:** every locale is built from its own catalogue; a missing one would
422
+ leave every key untranslated.
423
+ **Fix:** add the file, with every key of the fallback locale's catalogue —
424
+ or remove the locale from `locales`. A catalogue is named by its locale
425
+ exactly: `locales/pt-BR.json` for `pt-BR`.
426
+
427
+ ### `i18n: locales/fr.json is not valid JSON`
428
+
429
+ **When:** loading `maizzle.config.ts`, for an empty file, a trailing comma, a
430
+ comment, or single quotes.
431
+ **Why:** a catalogue is read with `JSON.parse`; JSON5 and JSONC are not
432
+ accepted.
433
+ **Fix:** find the mistake with a JSON parser, which points at it:
434
+
435
+ ```sh
436
+ bun -e "JSON.parse(await Bun.file('locales/fr.json').text())"
437
+ ```
438
+
439
+ ### `i18n: fr: the catalogue must be an object of messages`
440
+
441
+ **When:** loading `maizzle.config.ts`, when a catalogue's JSON is an array, a
442
+ string, or `null`.
443
+ **Why:** a catalogue is an object: its keys are the first segments of the
444
+ message keys.
445
+ **Fix:**
446
+
447
+ ```json
448
+ { "verifyEmail": { "title": "Confirmez votre adresse" } }
449
+ ```
450
+
451
+ ### `i18n: en: verifyEmail.title must be a message (a string) or an object of messages`
452
+
453
+ **When:** loading `maizzle.config.ts`, for a value that is a number, a
454
+ boolean, `null` or an array.
455
+ **Why:** a leaf is an ICU message, always a string; a number goes in the
456
+ message as an argument, not as its value.
457
+ **Fix:**
458
+
459
+ ```json
460
+ { "verifyEmail": { "expires": "The link expires in {minutes, plural, one {# minute} other {# minutes}}." } }
461
+ ```
462
+
463
+ ### `i18n: en: verify-email is not camelCase — every segment of a key is camelCase, and nested rather than dotted, as verifyEmail.title`
464
+
465
+ **When:** loading `maizzle.config.ts`, for a key in kebab-case or snake_case,
466
+ starting with a capital, or written with a dot (`"verifyEmail.title"` as one
467
+ key).
468
+ **Why:** a key is camelCase segments, one object per segment, as in
469
+ `@nxgt/i18n`. The subject of `emails/verify-email.vue` is looked up as
470
+ `verifyEmail.subject`, so the template's kebab-case name is not a key.
471
+ **Fix:**
472
+
473
+ ```json
474
+ { "verifyEmail": { "title": "Confirm your e-mail address" } }
475
+ ```
476
+
477
+ not `{ "verify-email": { … } }`, and not `{ "verifyEmail.title": "…" }`.
478
+
479
+ ### `i18n: en: verifyEmail.greeting is not a valid ICU message (EXPECT_ARGUMENT_CLOSING_BRACE)`
480
+
481
+ The reason in brackets is the ICU parser's own code.
482
+
483
+ **When:** loading `maizzle.config.ts`, for a message the ICU parser refuses:
484
+ an unclosed `{`, a `plural` without an `other` case, a literal `{` that is not
485
+ quoted.
486
+ **Why:** every message is parsed at build time, so none fails later. Only
487
+ the parser's code is kept: the error names the key, not the message's text.
488
+ **Fix:** close the argument, give each `plural` and `select` an `other` case,
489
+ and quote a literal brace with apostrophes:
490
+
491
+ ```json
492
+ {
493
+ "verifyEmail": {
494
+ "greeting": "Hello {name},",
495
+ "code": "Your code is '{'{code}'}'."
496
+ }
497
+ }
498
+ ```
499
+
500
+ ### `i18n: en: verifyEmail.greeting uses {first_name}, which is not camelCase — an argument is a camelCase name, as {firstName}`
501
+
502
+ **When:** loading `maizzle.config.ts`, for an argument in snake_case,
503
+ kebab-case, or starting with a capital.
504
+ **Why:** an argument is also the name of a placeholder, and every name is
505
+ camelCase.
506
+ **Fix:**
507
+
508
+ ```json
509
+ { "verifyEmail": { "greeting": "Hello {firstName}," } }
510
+ ```
511
+
512
+ ### `i18n: en: verifyEmail.expires uses {minutes} as number and as date`
513
+
514
+ **When:** loading `maizzle.config.ts`, for a message that uses one argument
515
+ as two kinds — `{minutes, number}` and `{minutes, date}`, or a `plural` and a
516
+ `date`.
517
+ **Why:** each argument has one kind, read from the message, and a template's
518
+ value is checked against it; one value cannot be both.
519
+ **Fix:** use two arguments:
520
+
521
+ ```json
522
+ { "verifyEmail": { "expires": "Expires in {minutes, number} minutes, at {at, time, short}." } }
523
+ ```
524
+
525
+ ### `i18n: fr: verifyEmail.title is missing — en, the fallback locale, has it`
526
+
527
+ **When:** loading `maizzle.config.ts`, when a key of the fallback locale's
528
+ catalogue is not in another locale's.
529
+ **Why:** every locale is built from its own catalogue, with no fallback at
530
+ build time: a missing translation fails the build rather than writing the
531
+ fallback's text, or the key, into a French e-mail.
532
+ **Fix:** add the key to `locales/fr.json`. Keys are sorted in the check, so
533
+ the first missing key is reported; there may be others.
534
+
535
+ ### `i18n: fr: verifyEmail.titel is not a key of en, the fallback locale`
536
+
537
+ **When:** loading `maizzle.config.ts`, when a locale's catalogue has a key the
538
+ fallback locale's does not — usually a typo, or a key renamed in one file
539
+ only.
540
+ **Why:** the fallback locale's catalogue is the reference; a key only a
541
+ translation has cannot be used by a template.
542
+ **Fix:** rename the key to match, or add it to the fallback locale's
543
+ catalogue first.
544
+
545
+ ### `i18n: fr: verifyEmail.title uses {name}, which en does not declare`
546
+
547
+ **When:** loading `maizzle.config.ts`, when a translation uses an argument the
548
+ fallback locale's message does not.
549
+ **Why:** the fallback locale's message declares the arguments a template
550
+ passes; one only the translation uses would never be given a value. A
551
+ translation may leave an argument out.
552
+ **Fix:** add the argument to the fallback locale's message, or remove it from
553
+ the translation.
554
+
555
+ ### `i18n: fr: verifyEmail.expires uses {minutes} as date, and en declares it as number`
556
+
557
+ **When:** loading `maizzle.config.ts`, when a translation uses an argument as
558
+ another kind than the fallback locale's message does.
559
+ **Why:** a template passes one value for every locale; it cannot be a number
560
+ in one and a date in the other.
561
+ **Fix:** use the same kind in both:
562
+
563
+ ```json
564
+ { "verifyEmail": { "expires": "Le lien expire dans {minutes, plural, one {# minute} other {# minutes}}." } }
565
+ ```
566
+
567
+ ---
568
+
569
+ ## Templates
570
+
571
+ These fail while `maizzle build` renders a template. Maizzle builds the
572
+ locales in no set order, so the locale in the message is either one — `en`
573
+ in one run, `fr` in the next — and it names the first build that failed, not
574
+ the only locale concerned.
575
+
576
+ ### `[Vue warn]: Unhandled error during execution of render function`
577
+
578
+ **When:** `maizzle build` stops with this warning, a stack trace, and the
579
+ Node.js version as the last line.
580
+ **Why:** a template's `t()` or `placeholder()` threw while Vue rendered it.
581
+ Vue prints its own warning, then the file and line of the `throw` in this
582
+ package, then the error — the useful line is in the middle.
583
+ **Fix:** look for the line starting `Error: i18n:`:
584
+
585
+ ```
586
+ [Vue warn]: Unhandled error during execution of render function
587
+ at <VerifyEmail >
588
+ …
589
+ Error: i18n: en: verify-email calls t('verifyEmail.titel'), which is not a key of the catalogues
590
+ at Proxy.<anonymous> (…)
591
+ ```
592
+
593
+ and find that message on this page.
594
+
595
+ ### `i18n: emails/Welcome.vue is not a kebab-case name — name a template as verify-email.vue`
596
+
597
+ **When:** loading `maizzle.config.ts`, so `maizzle build` stops before any
598
+ template is built. For a template or a folder in PascalCase, camelCase,
599
+ snake_case, or with a dot in its name. Under `maizzle serve`, a file so named
600
+ added while the server runs is printed with `console.error`, and the server
601
+ keeps running without it; the next `maizzle build` fails on it.
602
+ **Why:** the name becomes the output file (`dist/en/welcome.html`), the key
603
+ of its messages (`welcome.subject`) and its name in the manifest; a
604
+ kebab-case name gives all three.
605
+ **Fix:** rename it, and its folders, in kebab-case:
606
+
607
+ ```
608
+ emails/welcome.vue
609
+ emails/auth/reset-password.vue → messages under auth.resetPassword
610
+ ```
611
+
612
+ ### `i18n: emails/verify-email.vue is not built through the i18n plugin — leave content to it, and put templates in emails/`
613
+
614
+ **When:** `maizzle build`, on the first template, when the project sets
615
+ `content` itself.
616
+ **Why:** the plugin builds each template through one generated wrapper per
617
+ locale, under `.maizzle/i18n/`, and lists those in `content`. A `content`
618
+ set in the project **replaces** the plugin's — arrays are not joined — so
619
+ Maizzle builds the templates directly, with no locale.
620
+ **Fix:** leave `content` out, and put every template in the `emails` folder
621
+ (or the folder the `emails` option names):
622
+
623
+ ```ts
624
+ export default defineMailConfig({
625
+ plugins: [i18n({ locales: ['en', 'fr'], emails: 'templates' })],
626
+ // no content: the plugin sets it
627
+ });
628
+ ```
629
+
630
+ ### `i18n: en: verify-email calls t('verifyEmail.titel'), which is not a key of the catalogues`
631
+
632
+ **When:** `maizzle build`, rendering a template that calls `t()` with a key
633
+ no catalogue has — a typo, a key renamed in the catalogues, or a key that is
634
+ an object of messages (`t('verifyEmail')`).
635
+ **Why:** every locale has the same keys, so a key missing in one is missing
636
+ in all; the build fails rather than writing the key into the e-mail.
637
+ **Fix:** use a key of the catalogues, down to its message:
638
+
639
+ ```vue
640
+ <Heading>{{ t('verifyEmail.title') }}</Heading>
641
+ ```
642
+
643
+ ### `i18n: en: verify-email calls t('verifyEmail.expires') without {minutes}`
644
+
645
+ **When:** `maizzle build`, when a template calls `t()` without an argument
646
+ the fallback locale's message uses.
647
+ **Why:** a message is formatted at build time; an argument it uses must have
648
+ a value then. A value only known at send time is a placeholder.
649
+ **Fix:**
650
+
651
+ ```vue
652
+ <Text>{{ t('verifyEmail.expires', { minutes: 15 }) }}</Text>
653
+ <Text>{{ t('verifyEmail.greeting', { name: placeholder('name') }) }}</Text>
654
+ ```
655
+
656
+ ### `i18n: en: verify-email passes {minutes} to verifyEmail.expires as a string — the message uses it as a number`
657
+
658
+ The kinds are `string`, `number` and `date`, as the message uses the argument
659
+ and as the template passes it.
660
+
661
+ **When:** `maizzle build`, when a template passes a value of another kind
662
+ than the message uses: most often a `placeholder()` — always a string — to a
663
+ `plural`, a `number` or a `date`.
664
+ **Why:** a plural is chosen, and a number or a date is formatted, at build
665
+ time. A placeholder is only filled at send time, when there is no more ICU
666
+ to apply. A number is accepted for a string, and for a date (as a
667
+ timestamp).
668
+ **Fix:** pass the value itself when the build knows it; when only send time
669
+ knows it, write the message with a plain argument:
670
+
671
+ ```vue
672
+ <Text>{{ t('verifyEmail.expires', { minutes: 15 }) }}</Text>
673
+ ```
674
+
675
+ ```json
676
+ { "verifyEmail": { "expiresAt": "The link expires at {time}." } }
677
+ ```
678
+
679
+ ```vue
680
+ <Text>{{ t('verifyEmail.expiresAt', { time: placeholder('time') }) }}</Text>
681
+ ```
682
+
683
+ ### `i18n: en: verify-email passes {name} to verifyEmail.title, which does not use it`
684
+
685
+ **When:** `maizzle build`, when a template passes an argument the fallback
686
+ locale's message does not use.
687
+ **Why:** an argument no message uses is a value lost without a word — often
688
+ the sign that the message or the argument was renamed.
689
+ **Fix:** remove the argument, or add it to the message in every locale:
690
+
691
+ ```vue
692
+ <Heading>{{ t('verifyEmail.title') }}</Heading>
693
+ ```
694
+
695
+ ### `i18n: en: verify-email calls t('verifyEmail.title') with arguments that are not an object, as { name: placeholder('name') }`
696
+
697
+ **When:** `maizzle build`, when a template passes its arguments as something
698
+ other than an object — a placeholder or a value on its own, or `null`.
699
+ **Why:** a message's arguments are named; `t` takes them as `{ name: value }`.
700
+ **Fix:**
701
+
702
+ ```vue
703
+ <Text>{{ t('verifyEmail.greeting', { name: placeholder('name') }) }}</Text>
704
+ ```
705
+
706
+ not `t('verifyEmail.greeting', placeholder('name'))`.
707
+
708
+ ### `i18n: en: verify-email passes a placeholder to {plan}, which verifyEmail.title chooses on with a select — a placeholder always chooses other`
709
+
710
+ **When:** `maizzle build`, when a template passes `placeholder()` to an
711
+ argument the message chooses on with a `select`:
712
+ `t('verifyEmail.title', { plan: placeholder('plan') })` for
713
+ `"{plan, select, pro {Pro} other {Free}}"`.
714
+ **Why:** a `select` chooses at build time, and a placeholder is the string
715
+ `{{ plan }}` then: it always matches `other`, and the `pro` branch would never
716
+ be sent.
717
+ **Fix:** choose at build time with a value the build knows, or make one
718
+ e-mail (or one message) per case, and choose which to send in your
719
+ application:
720
+
721
+ ```vue
722
+ <Heading>{{ t('verifyEmail.title', { plan: 'pro' }) }}</Heading>
723
+ ```
724
+
725
+ ### `i18n: en: verify-email calls placeholder() with a name that is not camelCase — as placeholder('firstName')`
726
+
727
+ **When:** `maizzle build`, for `placeholder()` called with no name, a name
728
+ with a space, in snake_case or kebab-case, or starting with a capital.
729
+ **Why:** the name is the variable the renderer fills at send time, and
730
+ `{{ first name }}` could not be read back from the built file.
731
+ **Fix:**
732
+
733
+ ```vue
734
+ <Text>{{ t('verifyEmail.greeting', { name: placeholder('firstName') }) }}</Text>
735
+ ```
736
+
737
+ ### `i18n: en: verifyEmail.sentOn could not be formatted`
738
+
739
+ **When:** `maizzle build`, when a message is given a value that passes the
740
+ checks and still cannot be formatted — typically an invalid `Date`
741
+ (`new Date('')`) for a `date` or `time` argument.
742
+ **Why:** the formatter refused the value; its own error is the `cause` of
743
+ this one, printed under it. The `cause` may quote the message's text, which
744
+ this error's own message never does.
745
+ **Fix:** pass a valid date or timestamp:
746
+
747
+ ```vue
748
+ <Text>{{ t('verifyEmail.sentOn', { at: new Date(Date.UTC(2026, 0, 2)) }) }}</Text>
749
+ ```
750
+
751
+ ---
752
+
753
+ ## Manifest and subject
754
+
755
+ These fail after every template is built, while `dist/mail-manifest.json` is
756
+ written.
757
+
758
+ ### `i18n: welcome has no subject — add welcome.subject to the catalogues`
759
+
760
+ **When:** `maizzle build`, after the templates, for a template whose key has
761
+ no `subject` — a new template, or one renamed.
762
+ **Why:** the subject is a message like any other, looked up from the
763
+ template's name: `emails/welcome.vue` reads `welcome.subject`,
764
+ `emails/auth/reset-password.vue` reads `auth.resetPassword.subject`. An
765
+ e-mail is not sent without one.
766
+ **Fix:** add it to every catalogue:
767
+
768
+ ```json
769
+ { "welcome": { "subject": "Welcome, {name}" } }
770
+ ```
771
+
772
+ ### `i18n: en: verifyEmail.subject uses {minutes} as a number — a subject's arguments are placeholders, filled at send time as strings`
773
+
774
+ The kind is `number` or `date`.
775
+
776
+ **When:** `maizzle build`, after the templates, for a subject with a
777
+ `plural`, a `number` or a `date` argument.
778
+ **Why:** a subject is not rendered by a template, so its arguments get no
779
+ value at build time: each one becomes a placeholder, filled at send time
780
+ with a string. No ICU can be applied to it then.
781
+ **Fix:** use plain arguments in a subject:
782
+
783
+ ```json
784
+ { "verifyEmail": { "subject": "Confirm your e-mail address, {name}" } }
785
+ ```
786
+
787
+ ### `i18n: en: welcome.subject chooses on {kind} with a select — a subject's arguments are placeholders, which always choose other`
788
+
789
+ **When:** `maizzle build`, after the templates, for a subject with a
790
+ `select`.
791
+ **Why:** each argument of a subject becomes a placeholder, `{{ kind }}`, and a
792
+ `select` given that string always chooses `other`: the other branches would
793
+ never be sent.
794
+ **Fix:** write one subject, with plain arguments. When the subjects really
795
+ differ, make one e-mail per case:
796
+
797
+ ```json
798
+ { "welcome": { "subject": "Welcome, {name}" } }
799
+ ```
800
+
801
+ ### `i18n: fr/welcome.html is empty — a tag of its template resolved to no component; list the plugin that brings it, as ui()`
802
+
803
+ The message names the built file, in `en` or in `fr` from one run to the
804
+ next. It follows `[Vue warn]: Failed to resolve component: NxLayout`, one
805
+ such warning for each tag that did not resolve.
806
+
807
+ **When:** `maizzle build`, after the templates, when a template's outermost
808
+ tag is a component that nothing resolves. Most often `ui()` is missing from
809
+ the plugins while a template uses `<NxLayout>`. It also happens when a
810
+ package's template, under `node_modules`, uses a component that no plugin in
811
+ the list resolves for it.
812
+ **Why:** Vue renders a component it cannot resolve as nothing, and Maizzle
813
+ still writes the file, with its doctype alone. The e-mail would go out
814
+ blank, so the build stops.
815
+ **Fix:** list the plugin that brings the components. For the `Nx*`
816
+ components, it is `ui()`, which also resolves the tags of templates
817
+ installed from npm:
818
+
819
+ ```ts
820
+ // maizzle.config.ts
821
+ import { defineMailConfig } from '@nxgt/mail-config';
822
+ import { i18n } from '@nxgt/mail-i18n';
823
+ import { ui, uiCatalogues } from '@nxgt/mail-ui';
824
+
825
+ export default defineMailConfig({
826
+ plugins: [
827
+ ui({ brand: { name: 'Acme' } }),
828
+ i18n({ locales: ['en', 'fr'], catalogues: [uiCatalogues] }),
829
+ ],
830
+ });
831
+ ```
832
+
833
+ A template that reads `brand` stops earlier, while it renders, on
834
+ `TypeError: Cannot read properties of undefined (reading 'name')`. The cause
835
+ and the fix are the same.
836
+
837
+ ### `i18n: welcome was not built in fr`
838
+
839
+ **When:** `maizzle build`, after the templates, when Maizzle wrote no HTML for
840
+ an e-mail in one of `locales`.
841
+ **Why:** the manifest lists every e-mail in every locale, and the sending
842
+ side relies on that. The build's files for that e-mail and locale hold no
843
+ file with the HTML extension (`output.extension`, `html` by default): the
844
+ file was not written, or a template changed its own output settings.
845
+ **Fix:** leave `content` and the output settings to the plugin and the
846
+ project config, and set none in a template.
847
+
848
+ ### `i18n: ../text/welcome.en.txt was written outside the output folder — the i18n plugin lays out every e-mail; set no plaintext.destination and no output path in a template`
849
+
850
+ The path is relative to the output folder.
851
+
852
+ **When:** `maizzle build`, after the templates, when a built file is outside
853
+ `output.path`.
854
+ **Why:** the plugin decides where each e-mail's files go, per locale, and the
855
+ manifest points at them inside the output folder. A `plaintext.destination`,
856
+ or an output path set in a template, writes elsewhere.
857
+ **Fix:** remove `plaintext.destination` from the config and any output path
858
+ from the templates. To build somewhere else, set the project's `output.path`:
859
+
860
+ ```ts
861
+ export default defineMailConfig({
862
+ plugins: [i18n({ locales: ['en', 'fr'] })],
863
+ output: { path: 'build/mails' },
864
+ });
865
+ ```
866
+
867
+ ### `i18n: custom/welcome.html is not where the i18n plugin puts an e-mail — set no output path in a template`
868
+
869
+ **When:** `maizzle build`, after the templates, when a file inside the output
870
+ folder is not at `<locale>/<email>.html` (or `<email>.<locale>.html` with
871
+ `layout: 'flat'`).
872
+ **Why:** the manifest reads the locale and the e-mail back from each file's
873
+ path. A template that sets its own output path breaks that.
874
+ **Fix:** remove the output path from the template, and use `layout` to choose
875
+ between the two layouts.
876
+
877
+ ### `i18n: src/index.ts was not written by i18n() — point rendererTypes at a file of its own`
878
+
879
+ The path is `rendererTypes` as given.
880
+
881
+ **When:** `maizzle build`, after the manifest, when the file `rendererTypes`
882
+ names exists and does not start with the plugin's header line,
883
+ `// Generated by @nxgt/mail-i18n from the build`.
884
+ **Why:** each build rewrites that file whole. The plugin replaces only a file
885
+ it wrote, so a path that points at a hand-written module — `src/index.ts`, a
886
+ typo, a shared `types.ts` — keeps it rather than losing its code.
887
+ **Fix:** give `rendererTypes` a file of its own, and import `MailEmails` from
888
+ it:
889
+
890
+ ```ts
891
+ i18n({ locales: ['en', 'fr'], rendererTypes: 'src/generated/mail.ts' });
892
+ ```
893
+
894
+ ---
895
+
896
+ ## Run time
897
+
898
+ ### `t: the language is not a locale of the catalogues — pick one with pickLocale`
899
+
900
+ **When:** calling `t`, when the language — the second argument of
901
+ `createTranslator`, or the third of `t` — is not a key of the catalogues:
902
+ a recipient's `'de'`, a region such as `'fr-CA'`, or `undefined`. The
903
+ language is left out of the message.
904
+ **Why:** `t` does not choose a locale; given one it has no catalogue for, it
905
+ throws rather than answering the key.
906
+ **Fix:** choose the locale with `pickLocale`, which answers one of those you
907
+ support:
908
+
909
+ ```ts
910
+ import { pickLocale } from '@nxgt/mail';
911
+ import { createTranslator } from '@nxgt/mail-i18n';
912
+ import en from './locales/en.json';
913
+ import fr from './locales/fr.json';
914
+
915
+ const t = createTranslator({ en, fr }, () =>
916
+ pickLocale(user.locale, ['en', 'fr'], 'en'),
917
+ );
918
+ ```
919
+
920
+ ### `t: fr: verifyEmail.titel is not a key`
921
+
922
+ **When:** calling `t` with a key the language's catalogue does not have — a
923
+ typo, or a key that is an object of messages (`t('verifyEmail')`).
924
+ **Why:** where `@nxgt/i18n` answers the key, this `t` throws: an e-mail is
925
+ not sent with a key in it.
926
+ **Fix:** use a key of the catalogues, down to its message:
927
+
928
+ ```ts
929
+ t('verifyEmail.subject', { name: 'Ada' });
930
+ ```
931
+
932
+ ### `t: en: verifyEmail.expires could not be formatted`
933
+
934
+ **When:** calling `t`, when the message cannot be formatted with the
935
+ arguments given — most often an argument left out, or an invalid `Date` for a
936
+ `date`.
937
+ **Why:** the formatter refused; its own error, which names the argument and
938
+ may quote the message's text, is the `cause` of this one. `createTranslator` does not check arguments as the
939
+ build does.
940
+ **Fix:** pass every argument the message uses:
941
+
942
+ ```ts
943
+ t('verifyEmail.expires', { minutes: 15 });
944
+ ```
945
+
946
+ ---
947
+
948
+ ## Traps: a build that succeeds and is wrong
949
+
950
+ ### `[Vue warn]: Property "name" was accessed during render but is not defined on instance.`
951
+
952
+ **When:** `maizzle build` succeeds, prints this warning once per locale, and
953
+ the built e-mail has nothing where `{{ name }}` was written in the template.
954
+ **Why:** a template is Vue: `{{ name }}` in its markup is an expression,
955
+ evaluated at build time, and `name` has no value then. Nothing is left for
956
+ the renderer to fill. When `name` does exist — a prop, a global property — it
957
+ is written in, silently.
958
+ **Fix:** write the placeholder with `placeholder()`, which puts `{{ name }}`
959
+ in the built file:
960
+
961
+ ```vue
962
+ <Text>{{ placeholder('name') }}</Text>
963
+ <Text>{{ t('verifyEmail.greeting', { name: placeholder('name') }) }}</Text>
964
+ <Button :href="placeholder('link')">{{ t('verifyEmail.action') }}</Button>
965
+ ```
966
+
967
+ ### A link's placeholder is prefixed with a domain
968
+
969
+ **When:** `maizzle build` succeeds, and a link written as
970
+ `:href="placeholder('link')"` comes out as
971
+ `href="https://example.com/{{ link }}"`.
972
+ **Why:** Maizzle's `url.base` prefixes every relative URL, and a placeholder
973
+ looks like one. At send time the link is then the domain followed by the
974
+ whole URL filled in.
975
+ **Fix:** leave `url.base` off, and write the absolute URL of an image or a
976
+ static link in the template:
977
+
978
+ ```ts
979
+ export default defineMailConfig({
980
+ plugins: [i18n({ locales: ['en', 'fr'] })],
981
+ // no url.base
982
+ });
983
+ ```
984
+
985
+ ### `.maizzle/` shows up in `git status`
986
+
987
+ **When:** after the first `maizzle build` or `maizzle serve`, `git status`
988
+ lists `.maizzle/i18n/en/verify-email.vue` and one file per template and
989
+ locale.
990
+ **Why:** those are the wrappers the plugin generates, one per template and
991
+ locale, so that one build writes every locale. They are rewritten on every
992
+ build, and removed when their template is.
993
+ **Fix:** ignore the folder:
994
+
995
+ ```gitignore
996
+ # .gitignore
997
+ .maizzle/
998
+ ```
999
+
1000
+ Never edit a wrapper: the change is lost on the next build.
1001
+
1002
+ ### `No templates found`, or old templates, when Maizzle is built from a worker thread
1003
+
1004
+ **When:** Maizzle's `build()` is called from code that runs in a worker
1005
+ thread, such as a job runner or a Vitest `threads` pool. The build prints
1006
+ `No templates found`, or builds templates that were since renamed or
1007
+ removed, without the ones added.
1008
+ **Why:** the plugin writes the wrappers under `.maizzle/i18n/` only on the
1009
+ main thread, so that the workers of a parallel build never write the same
1010
+ file twice. From a worker thread it writes none, and the build finds whatever
1011
+ wrappers are already there, or none at all.
1012
+ **Fix:** run the build on the main thread. Spawn the command:
1013
+
1014
+ ```ts
1015
+ const child = Bun.spawn(['bunx', 'maizzle', 'build'], { cwd: 'mails', stdout: 'inherit', stderr: 'inherit' });
1016
+ if ((await child.exited) !== 0) throw new Error('maizzle build failed');
1017
+ ```
1018
+
1019
+ or, under Vitest, run those tests in processes rather than threads:
1020
+
1021
+ ```ts
1022
+ // vitest.config.ts
1023
+ export default { test: { pool: 'forks' } };
1024
+ ```
1025
+
1026
+ ### The editor says `Property 't' does not exist` in a template, or completes no key
1027
+
1028
+ The same for `placeholder` or `locale`: `Property 'placeholder' does not exist
1029
+ on type 'ComponentPublicInstance<…>'`. The build is not affected.
1030
+
1031
+ **When:** editing a template, in an editor with Vue's language tools, or in
1032
+ `vue-tsc`.
1033
+ **Why:** the types of `t` are in `.maizzle/nxgt-mail-i18n.d.ts`, which the
1034
+ plugin writes each time the config loads. Either it has not been written yet
1035
+ — a fresh clone, before any `maizzle prepare`, `serve` or `build` — or your
1036
+ `tsconfig.json` does not include `.maizzle/*.d.ts`. A project that sets
1037
+ Maizzle's `root`, or a Laravel project, has its `.maizzle/` elsewhere: include
1038
+ that one.
1039
+ **Fix:** keep the starter's include and write the file once:
1040
+
1041
+ ```json
1042
+ { "include": ["**/*.vue", ".maizzle/*.d.ts"] }
1043
+ ```
1044
+
1045
+ ```sh
1046
+ bunx maizzle prepare
1047
+ ```
1048
+
1049
+ See [Editor and type checking](guide/editor.md).
1050
+
1051
+ ### The editor flags a key you just added to a catalogue
1052
+
1053
+ `Argument of type '"welcome.footer"' is not assignable to parameter of type
1054
+ 'keyof TemplateMessages'`, for a key that is in `locales/en.json`.
1055
+
1056
+ **When:** right after adding a key, before the config loads again.
1057
+ **Why:** the file lists the keys of the catalogues as they were when the
1058
+ config last loaded.
1059
+ **Fix:** save the catalogue under `maizzle serve`, which reloads the config,
1060
+ or run `bunx maizzle prepare`.
1061
+
1062
+ ### Biome reports `parse` errors in a template as soon as you edit it
1063
+
1064
+ `Expected a property, a shorthand property, a getter, a setter, or a method but
1065
+ instead found '{ t('verifyEmail.title')'`, `type assertion are a TypeScript
1066
+ only feature`, or `This class property name should be in camelCase` on a
1067
+ component's tag — in the editor only; `biome check` reports nothing.
1068
+
1069
+ **When:** typing in a template that has no `<script>`, with Biome 2.5 as the
1070
+ editor's linter.
1071
+ **Why:** Biome's language server reads only the `<script>` of a `.vue` file
1072
+ when it opens it, but re-reads a file without one as JavaScript from the first
1073
+ change on. The template is fine; the editor's Biome is not reading it as Vue.
1074
+ **Fix:** let Biome parse Vue templates — add this key to your existing
1075
+ `biome.json` — then run **Biome: Restart** in the editor:
1076
+
1077
+ ```json
1078
+ { "html": { "experimentalFullSupportEnabled": true } }
1079
+ ```
1080
+
1081
+ Biome then lints the templates too. A rule that cannot see a slot's text,
1082
+ such as `useAnchorContent` on `<a><slot /></a>`, is silenced on its element:
1083
+
1084
+ ```vue
1085
+ <!-- biome-ignore lint/a11y/useAnchorContent: the link's text is the slot. -->
1086
+ <a :href="href"><slot /></a>
1087
+ ```
1088
+
1089
+ ### A bug in `@nxgt/mail-i18n` itself
1090
+
1091
+ A refusal of a catalogue or a template this page says is valid, a locale
1092
+ built with another locale's text, or a manifest that does not list what the
1093
+ build wrote, is a bug in this package. Open an issue on
1094
+ [`softistx/nxgt-mail`](https://github.com/softistx/nxgt-mail/issues) with the
1095
+ message, the package version, the Maizzle version, and the smallest
1096
+ catalogue and template that reproduce it — never a real address or link.