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 apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "api",
"version": "2.0.0-canary.9",
"version": "2.0.0-canary.12",
"private": true,
"type": "module",
"scripts": {
Expand Down
14 changes: 8 additions & 6 deletions apps/web/content/docs/dev/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ of those modules, and that is the part worth knowing:
| `src/plugin-routes.gen.ts` | the plugin's `src/routes.ts` | per route, behind `lazy()` |
| `src/admin-nav.gen.ts` | the plugin's `admin/nav` | with the AdminCP shell |
| `src/content-registry.gen.ts` | the plugin's `admin/content` | behind a dynamic `import()` in `src/router.tsx` |
| `src/package-messages.gen.ts` | the factory's `localeFiles` | per request, one locale at a time |

One literal import per configured plugin, written at build time. So a content
type's editing screen - a Tiptap field, a form layout, a table cell - arrives
Expand All @@ -99,12 +100,13 @@ whole editing stack rides along with every public page. See
[Plugin routes](/docs/dev/plugins/routes) and
[Plugin frontend modules](/docs/dev/content-engine/plugin-registration).

<Callout type="info" title="Also register its locale files">
<Callout type="info" title="Where its locale files come from">
The `messages` a factory carries is the plugin's own locale barrel, which loads
its JSON with `import('./en.json', { with: { type: 'json' } })` - a specifier
no bundler follows. VitNode reads translations from
`src/locales/packages.ts` instead, so add a line there per language the plugin
ships. [Languages & Localization](/docs/dev/i18n) has the detail.
no bundler follows. The factory's `localeFiles` is the same list spelled as
package subpaths, and that is what your app's translations are loaded from:
your build writes the loaders into `src/package-messages.gen.ts` for you.
[Languages & Localization](/docs/dev/i18n) has the detail.
</Callout>

<Callout type="warn" title="Build-time cost">
Expand All @@ -127,7 +129,7 @@ import '@tanstack/react-start/server-only'
import { buildServerConfig } from '@vitnode/core/vitnode.config'

import { appMessages } from '@/locales/app'
import { packageMessages } from '@/locales/packages'
import { packageMessages } from '@/package-messages.gen'
import { vitNodeConfig } from '@/vitnode.config'

export const vitNodeServerConfig = buildServerConfig({
Expand All @@ -145,7 +147,7 @@ the document shell read. Hand the whole thing to the loader:
export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig)
```

`packageMessages` is one line per language a package ships, and `messages` is
`packageMessages` is generated from the plugins you configured, and `messages` is
where you reword a string a package translates differently to how you want it.
[Languages & Localization](/docs/dev/i18n) covers both.

Expand Down
21 changes: 18 additions & 3 deletions apps/web/content/docs/dev/fetcher.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,31 @@ choose a transport.
### Define it in your plugin

Keep this in one plugin file. Features import `notesApi`; they never set up a
module reference themselves.
module reference themselves. `create-vitnode-app --plugin` writes this file for
you—the shape below is what it generates.

```ts title="plugins/site-notes/src/api/client.ts"
import type { notesModule } from "../api/notes.module"
import type { ApiClient } from "@vitnode/core/tanstack/fetcher"

import { createApiClient } from "@vitnode/core/tanstack/fetcher"

export const notesApi = createApiClient<typeof notesModule>("@acme/site-notes")
import type { notesModule } from "./modules/notes/notes.module"

export const notesApi: ApiClient<typeof notesModule> =
createApiClient<typeof notesModule>("@acme/site-notes")
```

<Callout type="warn" title="Both halves of that line earn their keep">
`import type` is what keeps Hono and your handlers out of the browser bundle—a
value import would ship the whole API to every visitor, and nothing would fail
to compile.

The `ApiClient` annotation is what keeps your plugin's `.d.ts` small. Without
it, declaration emit resolves the client's type in full and writes every route
the module serves into your build output: 200KB for a single route, and every
app that installs the plugin type-checks it.
</Callout>

</Step>

<Step>
Expand Down
73 changes: 56 additions & 17 deletions apps/web/content/docs/dev/i18n/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,16 @@ Because `buildConfig` keeps those codes as literal types, `'de'` is now part of

A `() => import('./de.json')` reads a file out of a package's build output, so it is the one part of i18n that must never reach a browser. Two files own it, and both are registered through the **server-only** config:

| File | Holds |
| :------------------------ | :--------------------------------------------------- |
| `src/locales/packages.ts` | one loader per language each installed package ships |
| `src/locales/app.ts` | your own rewordings, merged last |
| File | Holds | Who writes it |
| :---------------------------- | :--------------------------------------------------- | :------------ |
| `src/package-messages.gen.ts` | one loader per language each installed package ships | your build |
| `src/locales/app.ts` | your own rewordings, merged last | you |

```ts title="apps/web/src/vitnode.server.config.ts"
export const vitNodeServerConfig = buildServerConfig({
config: vitNodeConfig, // the locale list above
messages: appMessages, // src/locales/app.ts
packageMessages, // src/locales/packages.ts
packageMessages, // src/package-messages.gen.ts
})
```

Expand All @@ -86,20 +86,39 @@ export const vitNodeServerConfig = buildServerConfig({
writes to the right file for you.
</Callout>

Adding a language to a package that ships it needs one line in `src/locales/packages.ts`:
### The generated half

```ts title="apps/web/src/locales/packages.ts"
[CORE.pluginId]: {
en: async () => await import('@vitnode/core/locales/en.json'),
de: async () => await import('@vitnode/core/locales/de.json'), // [!code ++]
},
Registering a plugin is the whole step. Every VitNode build reads the `plugins` in your `vitnode.config.ts`, takes the `localeFiles` each factory declares, and writes `src/package-messages.gen.ts` - core's own languages plus one block per plugin:

```ts title="apps/web/src/package-messages.gen.ts"
export const packageMessages: Record<string, LocaleMessagesMap> = {
'@vitnode/core': {
en: async () => await import('@vitnode/core/locales/en.json'),
},
'@acme/blog': {
en: async () => await import('@acme/blog/locales/en.json'),
},
}
```

Don't edit it - it is rewritten on every `dev` and `build`, which is why it sits in your `.gitignore`. Every specifier is a literal because that is the only kind a bundler can resolve: `import(pkg + '/locales/' + locale + '.json')` resolves to nothing. Every loader stays dynamic, so a language's JSON is a chunk of its own and the server loads only the locale a request asked for.

<Callout type="info" title="Packages ship English">
`@vitnode/core` and the plugins in this repository ship `en` and nothing else.
Every other language is the install's own, which is what the next section is
for - and it is why a language you add is a file in **your** app rather than a
pull request against a package.
</Callout>

---

## Overriding Strings

To customize existing text from core or a third-party plugin, add an override file in `apps/web/src/locales/{pluginId}/{locale}.json` and register it in `src/locales/app.ts`:
Your own translations live in `apps/web/src/locales/{pluginId}/{locale}.json`, registered in `src/locales/app.ts`. The same file does both jobs: a whole language a package does not ship, and a reword of a string it does.

One file per package per language, holding that package's web **and** email strings together - an app keeps them in one tree where a package ships two, so the copy in an email cannot drift from the copy on the page.

To reword something core already says:

```json title="apps/web/src/locales/@vitnode/core/en.json"
{
Expand All @@ -122,15 +141,35 @@ export const appMessages: AppMessagesMap = {

Because your app overrides are merged last, only the keys you specify are overwritten. Everything else continues to fall back to the package defaults.

A whole language looks exactly the same, because it is the same mechanism - this repository's own Polish is a pair of files nobody's `node_modules` contains:

```ts title="apps/web/src/locales/app.ts"
export const appMessages: AppMessagesMap = {
pl: {
'@vitnode/blog': async () => await import('./@vitnode/blog/pl.json'),
'@vitnode/core': async () => await import('./@vitnode/core/pl.json'),
},
}
```

If your app also serves the API, register the same map there so emails speak the language too - `i18n.messages` in `vitnode.api.config.ts`:

```ts title="apps/web/src/vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
i18n: { ...vitNodeConfig.i18n, messages: appMessages }, // [!code highlight]
// ...
})
```

---

## Translation Architecture

| Source | Role | Order |
| :------------------- | :------------------------------------------------- | :---------------------- |
| `@vitnode/core` | Base strings for auth, admin shells, and dialogs | Base layer |
| **Plugins** | Domain strings declared in `plugins/*/src/locales` | Second layer |
| **Host Application** | Custom overrides in `apps/web/src/locales` | Highest priority (wins) |
| Source | Role | Order |
| :------------------- | :---------------------------------------------------------- | :---------------------- |
| `@vitnode/core` | Base strings for auth, admin shells, and dialogs | Base layer |
| **Plugins** | Domain strings declared in `plugins/*/src/locales` | Second layer |
| **Host Application** | Your own languages and rewordings in `apps/web/src/locales` | Highest priority (wins) |

Missing keys automatically fall back to `defaultLocale` (`en`), preventing raw key paths from displaying in production.

Expand Down
22 changes: 20 additions & 2 deletions apps/web/content/docs/dev/i18n/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,25 @@ export default function WelcomeEmail({ i18n }: DefaultTemplateEmailProps) {

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:
Most plugins render nothing server-side, so they ship only the frontend tree. That one is registered twice, and the two halves are not interchangeable:

```ts title="plugins/{your_plugin}/src/config.tsx"
import messages from './locales'

export const yourPlugin = () =>
buildPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
localeFiles: {
// [!code ++:2]
en: '@acme/your-plugin/locales/en.json',
},
messages,
})
```

`messages` is your own barrel, whose `import('./en.json')` is relative to your build output - right for anything running inside your package, and a specifier no host bundler can follow. `localeFiles` is the same list written as the subpaths your `package.json` exports, and it is what an app's translations are actually loaded from: the app's build turns it into literal imports in `src/package-messages.gen.ts`. Ship both, and keep them in step.

Add the `api/` tree only when your plugin sends email:

```ts title="plugins/{your_plugin}/src/locales/api/index.ts"
import type { LocaleMessagesMap } from '@vitnode/core/lib/i18n/types'
Expand All @@ -172,7 +190,7 @@ export const yourApiPlugin = () =>
})
```

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 each list that names it - 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

Expand Down
10 changes: 10 additions & 0 deletions apps/web/content/docs/dev/plugins/api/modules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ Start with [a plugin](/docs/dev/plugins/create), not a host endpoint. A module
groups the plugin's Hono routes under one URL prefix and gives OpenAPI a tidy
place to describe them.

<Callout type="info" title="A generated plugin already has one">
`create-vitnode-app --plugin` writes a `hello` module, its `config.api.ts` and
a page that calls it. Read on for what each piece does—then rename them, or add
a second module beside them.
</Callout>

{/* Image prompt: Dark-theme API ownership diagram. A Site notes plugin contains a Hono route, notes module, and config.api file; the app API configuration composes the plugin once. Show resulting GET endpoint, 1600x900. */}

<Steps>
Expand Down Expand Up @@ -71,6 +77,10 @@ export const siteNotesApiPlugin = () =>
})
```

This file, and not `config.tsx`: the API config reaches your handlers, database
and secrets, while `config.tsx` is read by the browser build. A module
registered in the wrong one is shipped to every visitor.

</Step>
<Step>

Expand Down
118 changes: 0 additions & 118 deletions apps/web/content/docs/dev/plugins/breadcrumbs.mdx

This file was deleted.

Loading
Loading