@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,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.