Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ revalidateTag("products", { revalidate: 3600 });

# Documentation

- Don't use big comments. Remember that code is self-documenting.
- Don't write big comments. Remember that code is self-documenting.
- If should be simple. if docs requires image to better understand, add comment:

```markdown
Expand Down
5 changes: 2 additions & 3 deletions apps/api/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/// <reference types="next-intl" />

import blog from "@vitnode/blog/locales/en.json" with { type: "json" };
import core from "@vitnode/core/locales/en.json" with { type: "json" };
import core from "@vitnode/core/locales/api/en.json" with { type: "json" };

declare module "next-intl" {
interface AppConfig {
Messages: typeof core & typeof blog;
Messages: typeof core;
}
}
6 changes: 5 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
"start": "node dist/index.js",
"drizzle-kit": "drizzle-kit",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
"lint:fix": "eslint . --fix",
"i18n:create": "vitnode i18n:create",
"i18n:check": "vitnode i18n:check",
"i18n:delete": "vitnode i18n:delete",
"i18n:update": "vitnode i18n:update"
},
"dependencies": {
"@hono/zod-openapi": "^1.5.1",
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { VitNodeI18nConfig } from "@vitnode/core/lib/i18n/types";

/**
* Shared by `vitnode.config.ts` (web) and `vitnode.api.config.ts` (API) so the
* site and its emails agree on which languages exist. Packages ship their own
* languages - only what this app adds or reworks needs a file here.
*/
export const i18n = {
defaultLocale: "en",
locales: [],
messages: {},
Comment thread
aXenDeveloper marked this conversation as resolved.
} satisfies VitNodeI18nConfig;
4 changes: 2 additions & 2 deletions apps/docs/content/docs/dev/email/templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ We implemented [TailwindCSS](https://react.email/docs/components/tailwind) in th

VitNode supports internationalization (I18n) in email templates thanks to [next-intl](https://react.email/docs/guides/internationalization/next-intl).

Strings live with the plugin that owns them, and the locale the email renders in comes from the recipient - see [Server-side i18n](/docs/dev/i18n/server).
Strings live with the plugin that owns them, and the locale the email renders in comes from the recipient - see [Server-side i18n](/docs/dev/i18n/server). Because emails render on the server, the keys they use belong in the plugin's **server** tree, `src/locales/api/`, registered with `buildApiPlugin` - not the frontend tree the UI uses:

```json title="plugins/blog/src/locales/en.json"
```json title="plugins/blog/src/locales/api/en.json"
{
"@vitnode/blog": {
"title": "Blog"
Expand Down
129 changes: 128 additions & 1 deletion apps/docs/content/docs/dev/i18n/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,51 @@ On top of that, the default locale is merged _underneath_ the language being ren

## Adding one

The CLI does the whole thing. It asks for the language's code and name, scaffolds an override file for every installed package, and wires the locale into your `i18n` config:

```bash
vitnode i18n:create
```

```text
[VitNode] Add a language. Press Ctrl+C to abort.

? Locale code (e.g. pl, de, pt-BR): de
? Language name (e.g. Polski, Deutsch): Deutsch

created src/locales/@vitnode/core/de.json
created src/locales/@vitnode/blog/de.json
updated src/i18n.ts

[VitNode] Deutsch (de) added. Translate the files above, then run vitnode i18n:check.
```

Pass both inline to skip the prompts - handy in scripts and CI: `vitnode i18n:create de Deutsch`.

<Callout title="What it changes" type="info">
Two things: one override file per installed package under
`src/locales/{pluginId}/{locale}.json`, each seeded with that package's English
strings so they are right there to translate in place, and your `i18n` config -
the `{ code, name }` entry in `locales` plus a `messages` block pointing at
those files. An app with no `i18n` config yet - an API-only app, say - gets a
fresh `src/i18n.ts` instead, and the command prints the one line to import it
into your config.
</Callout>

<Callout title="It seeds only what the app renders" type="info">
The seed matches the app's shape. An **API-only** app is seeded with each
package's server strings alone - the emails, a few keys - not the admin UI it
never renders. A **frontend** app gets the UI strings, and a **single app**
(frontend and API together) gets both. A package that ships nothing for the
app's shape - a plugin with no emails in an API-only app - is skipped rather
than seeded with an empty file. See [Server-side](/docs/dev/i18n/server) for
the two trees.
</Callout>

Fill in the files it lists and you are done - the rest of this page is what `create` does under the hood, for when you would rather wire a language up by hand or the command meets an unusual config it will not edit blindly.

### By hand

<Steps>

<Step>
Expand Down Expand Up @@ -107,12 +152,94 @@ export const vitNodeApiConfig = buildApiConfig({
vitnode i18n:check
```

It lists every key the new language is still missing, flags keys that no longer exist in the default locale, and - most usefully - catches a locale file you created but forgot to wire into `i18n.messages`. Add `--ci` to make it fail a pipeline.
It lists every key the new language is still missing and flags keys that no longer exist in the default locale. It **fails outright** - a non-zero exit on its own - when a declared language is missing a package's locale file, or when a file you created was never wired into `i18n.messages`. Those are hard errors, since both leave strings silently untranslated at runtime. Missing individual _keys_ are only warnings; add `--ci` to make those fail a pipeline too.

</Step>

</Steps>

## Keeping them in sync

Packages gain and lose keys as they evolve. `i18n:update` reconciles every translation file you have against the current English source in one pass:

```bash
vitnode i18n:update
```

For each of your override files it:

- **adds** keys English has but the file is missing, seeded with the English string so they are ready to translate in place;
- **removes** keys the file still carries that English no longer defines;
- **keeps** every existing translation untouched - it never overwrites a string you have already translated.

```text
[VitNode] Syncing 2 translation file(s) against en.
updated src/locales/@vitnode/core/pl.json +75 -7
+ core.search.admin.reindex
- core.search.admin.engine
... and 80 more

[VitNode] 2 file(s) updated: +75 added, -7 removed. Translate the added keys, then run vitnode i18n:check.
```

Like the other commands it is scoped: an API-only app is synced against each package's server strings alone, a single app against both trees. A file whose package ships nothing for the app's shape is left untouched rather than emptied.

<Callout title="Added keys arrive in English" type="info">
A freshly added key holds the English text until you translate it, so
`i18n:check` will report it as present. Run `i18n:update` to pull in new
strings, then work through the ones it added.
</Callout>

## Removing one

To drop a language, hand `i18n:delete` its code. It deletes that locale's override files, prunes the folders they leave empty, and unwires the `{ code, name }` and `messages` entries from your config:

```bash
vitnode i18n:delete pl
```

Run it without a code to be asked for one. When interactive it lists everything it will remove and waits for a `y` before touching anything.

<Callout title="English and the default locale stay put" type="warn">
`i18n:delete` refuses to remove `en` - the built-in fallback every translation
degrades to - or whatever your `defaultLocale` is. Point `defaultLocale` at
another language first if you really mean to retire the current one. To take a
language out of circulation without deleting it - English included - **disable**
it instead (below).
</Callout>

## Disabling one

Deleting a language throws its files away. When you only want to take a language
out of circulation - keep the translations on disk, just stop offering it - set
`enabled: false` on its `locales` entry instead:

```ts title="src/i18n.ts"
export const i18n = {
defaultLocale: "en",
locales: [
{ code: "en", name: "English" },
{ code: "pl", name: "Polski", enabled: false }, // [!code ++]
],
// ...messages unchanged
} satisfies VitNodeI18nConfig;
```

A disabled locale is no longer **selectable**: it drops out of the language
pickers built on it (the multi-language form field) and search stops indexing
documents for it. Everything else stays put - its `src/locales` files remain on
disk, its row and translated words in the database are untouched, and it still
resolves at runtime as a fallback. Flip `enabled` back to `true` (or drop the
field) and it returns exactly as it was.

<Callout title="You can disable the default locale too" type="info">
Unlike deletion, disabling works on any locale, `en` and your `defaultLocale`
included - handy for a site that runs in another language but keeps English as
the technical fallback. The default locale still merges underneath every other
language whether or not it is enabled, so disabling it only hides it from the
pickers; it never leaves strings untranslated.
</Callout>

## The API side

An API-only app needs no `i18n` block at all. The languages the installed packages ship are picked up on their own, and `defaultLocale` is `en`:
Expand Down
31 changes: 26 additions & 5 deletions apps/docs/content/docs/dev/i18n/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ return c.json({ message: t("core.global.save") });
plugin and its translations are available on the server immediately.
</Callout>

The server loads a different, smaller set of strings than the frontend. Packages ship two trees - the frontend's UI copy in `src/locales/`, and the server's strings (emails) in `src/locales/api/` - and the API only ever loads the second. An API-only app never pulls in the admin UI's messages.

## Which locale a request gets

`resolveLocale` walks four options in order and takes the first one your app actually lists in `i18n.locales`:
Expand Down Expand Up @@ -144,20 +146,22 @@ export default function WelcomeEmail({ i18n }: DefaultTemplateEmailProps) {

## Shipping translations with a plugin

A plugin owns its languages. Drop the JSON in `src/locales/`, list it in a barrel, and register the barrel in both configs:
A plugin owns its languages, and it splits them the same way the framework does: frontend strings in `src/locales/`, server strings (emails) in `src/locales/api/`. Each tree gets a barrel and is registered with the matching config - the frontend tree with `buildPlugin` in `config.tsx`, the server tree with `buildApiPlugin` in `config.api.ts`.

Most plugins render nothing server-side, so they ship only the frontend tree and register `messages` in `config.tsx` alone. Add the `api/` tree only when your plugin sends email:

```ts title="plugins/{your_plugin}/src/locales/index.ts"
```ts title="plugins/{your_plugin}/src/locales/api/index.ts"
import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types";

const messages: LocaleMessagesMap = {
en: () => import("./en.json", { with: { type: "json" } }),
en: async () => await import("./en.json", { with: { type: "json" } }),
};

export default messages;
```

```ts title="plugins/{your_plugin}/src/config.api.ts"
import messages from "./locales"; // [!code ++]
import messages from "./locales/api"; // [!code ++]

export const yourApiPlugin = () =>
buildApiPlugin({
Expand All @@ -167,7 +171,24 @@ export const yourApiPlugin = () =>
});
```

Do the same with `buildPlugin` in `config.tsx` for the frontend. Adding a language later is a new file plus one line in the barrel - apps pick it up on their next install, and can translate your plugin without forking it by dropping a file in their own `src/locales/{your_plugin}/`.
Adding a language later is a new file plus one line in the barrel - apps pick it up on their next install, and can translate your plugin without forking it by dropping a file in their own `src/locales/{your_plugin}/`.

## Typing the keys

`t()` autocompletes from a `global.d.ts` that augments next-intl's `Messages`. Import the tree an app actually uses: an API-only app types against the server tree alone, a frontend-only app against the frontend tree, and a single app - one that serves the frontend and runs the API together - against both.

```ts title="global.d.ts (single app)"
/// <reference types="next-intl" />

import coreApi from "@vitnode/core/locales/api/en.json" with { type: "json" };
import core from "@vitnode/core/locales/en.json" with { type: "json" };

declare module "next-intl" {
interface AppConfig {
Messages: typeof core & typeof coreApi; // [!code highlight]
}
}
```

<Callout title="Keys must be namespaced" type="warn">
Everything a plugin ships belongs under its own namespace, or it will collide
Expand Down
11 changes: 9 additions & 2 deletions apps/docs/content/docs/dev/plugins/create.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,20 @@ VitNode is i18n-first: render user-facing text through translations rather than
import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types";

const messages: LocaleMessagesMap = {
en: () => import("./en.json", { with: { type: "json" } }),
en: async () => await import("./en.json", { with: { type: "json" } }),
};

export default messages;
```

Register that barrel in `config.tsx` (`buildPlugin`) and `config.api.ts` (`buildApiPlugin`) with `messages`, and you're done - the strings show up on the frontend and in emails.
Register that barrel in `config.tsx` (`buildPlugin`) with `messages`, and your strings show up on the frontend.

<Callout title="Server strings live apart" type="info">
If your plugin sends email, its server strings go in a second tree,
`src/locales/api/`, registered with `buildApiPlugin` in `config.api.ts`. The
API loads only that tree, so an API-only app never pulls in your UI copy. See
[Server-side](/docs/dev/i18n/server).
</Callout>

Keys have to sit under your plugin's namespace, or they collide with core. The details are in [Namespaces](/docs/dev/i18n/namespaces) and [Server-side](/docs/dev/i18n/server).

Expand Down
4 changes: 4 additions & 0 deletions apps/docs/content/docs/ui/auto-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,10 @@ only when more than one language is enabled) that switches which language you
are editing; it does **not** change the app-wide locale, only the value inside
that field.

"Enabled" here means every locale in your [`i18n.locales`](/docs/dev/i18n) config
that is not marked [`enabled: false`](/docs/dev/i18n#disabling-one) - a disabled
language is not offered in the select.

The value is stored as an array matching the
[`core_languages_words`](/docs/dev/working-with-users/roles) table - one
`{ languageCode, value }` entry per language. Declare the field with the
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/// <reference types="next-intl" />

import coreApi from "@vitnode/core/locales/api/en.json" with { type: "json" };
import blog from "@vitnode/blog/locales/en.json" with { type: "json" };
import core from "@vitnode/core/locales/en.json" with { type: "json" };

declare module "next-intl" {
interface AppConfig {
Messages: typeof core & typeof blog;
Messages: typeof core & typeof coreApi & typeof blog;
}
}
1 change: 1 addition & 0 deletions apps/docs/migrations/0020_lazy_logan.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "core_languages" DROP COLUMN "enabled";
Loading