Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/content-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystatic/core': patch
---

Add content internationalization. Set `i18n` on the config with your `locales` and `defaultLocale`, then mark a collection or singleton as `localized` and include a `{locale}` token in its `path`. The Admin UI shows a language switcher above the navigation that filters entries to the selected locale, and `createReader` accepts a `{ locale }` option for reading localized content.
5 changes: 5 additions & 0 deletions .changeset/prefix-default-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystatic/core': patch
---

Add `i18n.prefixDefaultLocale`. Set it to `false` to store the default language without a `{locale}` directory, matching how Astro and Starlight lay out content for a root locale.
5 changes: 5 additions & 0 deletions .changeset/template-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystatic/core': patch
---

A collection's `template` resolves the `{locale}` token, so a template can either be shared across every language or live alongside each language's content.
5 changes: 5 additions & 0 deletions docs/src/content/navigation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ navGroups:
discriminant: page
value: path-wildcard
status: default
- label: Internationalization
link:
discriminant: page
value: i18n
status: new
- label: Local mode
link:
discriminant: page
Expand Down
224 changes: 224 additions & 0 deletions docs/src/content/pages/i18n.mdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
---
title: Internationalization
summary: >-
Declare a collection or singleton once and author its content across multiple
languages, with a language switcher in the Admin UI.
---
When your site ships in more than one language, you'll want each collection or singleton to have a separate version of its content per language. Keystatic's `i18n` option lets you declare a collection or singleton **once** and mark it as `localized` β€” Keystatic then stores one copy per language and adds a language switcher at the top of the Admin UI.

{% aside icon="☝️" %}
This is about localizing your **content**. It's a different thing from the [`locale` option](/docs/configuration), which sets the language of the Keystatic Admin UI itself (buttons, labels, etc.).
{% /aside %}

Keystatic uses **document-level** localization: each language version is a separate file (or folder) on disk, and the Admin UI shows one language at a time. Pick a language from the switcher, and only that language's entries are shown below.

## Example

Add a top-level `i18n` option, mark a collection or singleton as `localized`, and put a `{locale}` token in its `path` where the language should go:

```tsx
// keystatic.config.ts
import { config, collection, singleton, fields } from '@keystatic/core';

export default config({
storage: { kind: 'local' },
i18n: {
locales: { en: 'English', pl: 'Polski', de: 'Deutsch' },
defaultLocale: 'en',
},
collections: {
posts: collection({
label: 'Posts',
localized: true,
path: 'content/posts/{locale}/*',
slugField: 'title',
schema: {
title: fields.slug({ name: { label: 'Title' } }),
content: fields.markdoc({ label: 'Content' }),
},
}),
},
singletons: {
homepage: singleton({
label: 'Homepage',
localized: true,
path: 'content/homepage/{locale}',
schema: {
headline: fields.text({ label: 'Headline' }),
},
}),
// Not localized β€” shared across every language.
logos: singleton({
label: 'Logos',
path: 'content/logos',
schema: {
items: fields.array(fields.text({ label: 'Logo' })),
},
}),
},
});
```

With three locales, this produces one directory per language on disk:

```sh
content
β”œβ”€β”€ posts
β”‚ β”œβ”€β”€ en
β”‚ β”‚ └── my-post.mdoc
β”‚ β”œβ”€β”€ pl
β”‚ β”‚ └── my-post.mdoc
β”‚ └── de
β”‚ └── my-post.mdoc
β”œβ”€β”€ homepage
β”‚ β”œβ”€β”€ en.yaml
β”‚ β”œβ”€β”€ pl.yaml
β”‚ └── de.yaml
└── logos.yaml
```

Notice that `posts` and `homepage` each appear as a **single** entry in the Admin UI navigation β€” not one per language. The switcher decides which language you're editing. `logos` has no `{locale}` token and isn't `localized`, so it stays a single shared entry visible in every language.

---

## Options

### i18n

`i18n` β€” a top-level config option that turns on content localization.

```tsx
i18n: {
locales: { en: 'English', pl: 'Polski', de: 'Deutsch' },
defaultLocale: 'en',
},
```

- `locales` β€” a map of locale code to the label shown in the language switcher. Each code becomes a directory name, so it has to be a single path segment.
- `defaultLocale` β€” the language selected by default. Must be one of the keys in `locales`.
- `prefixDefaultLocale` β€” whether the default language gets a directory of its own. Defaults to `true`.

### localized

`localized` β€” set to `true` on a [collection](/docs/collections) or [singleton](/docs/singletons) to give it a separate version of its content per language.

A `localized` entry **must** include a `{locale}` token in its `path`, and `i18n` must be set on the config.

### The `{locale}` path token

`{locale}` β€” a placeholder in the `path` string that Keystatic replaces with the active language. It can appear anywhere in the path, so you're free to match whatever folder structure you already use:

```tsx
path: 'content/posts/{locale}/*' // content/posts/en/my-post.mdoc
path: 'i18n/{locale}/posts/*' // i18n/en/posts/my-post.mdoc
path: 'content/homepage/{locale}' // content/homepage/en.yaml
```

A collection's [`template`](/docs/collections) resolves the token too, so new entries can start from a skeleton written in the language you're editing. Leave the token out and every language starts from the same one:

```tsx
template: 'content/posts/{locale}/_template' // one skeleton per language
template: 'content/_templates/post' // shared
```

{% aside icon="⚑️" %}
Everything up to the `path` [wildcard](/docs/path-wildcard) is a fixed prefix, so a `{locale}` token there is just an ordinary folder. That means you can adopt this feature on an **existing** multi-language project without moving any files β€” as long as your folders already sit where the token resolves to.
{% /aside %}

### prefixDefaultLocale

By default every language resolves the `{locale}` token to a folder of its own, including the default one. With `prefixDefaultLocale: false` the token segment is instead **removed** for the default language, leaving its content at the root while the other languages keep their folders:

```tsx
i18n: {
locales: { en: 'English', pl: 'Polski', de: 'Deutsch' },
defaultLocale: 'en',
prefixDefaultLocale: false,
},
```

This applies to **every localized entry β€” collections and singletons alike**. Wherever the `{locale}` token sits in a `path`, the default language simply doesn't get that segment:

```sh
content
β”œβ”€β”€ posts # collection: path: 'content/posts/{locale}/*'
β”‚ β”œβ”€β”€ my-post.mdoc # English, the default
β”‚ β”œβ”€β”€ pl
β”‚ β”‚ └── my-post.mdoc
β”‚ └── de
β”‚ └── my-post.mdoc
β”œβ”€β”€ homepage.yaml # singleton: path: 'content/homepage/{locale}'
└── homepage # English lands beside the other languages' folder
β”œβ”€β”€ pl.yaml
└── de.yaml
```

Note what that means for a singleton: the default language's file sits **next to** the folder holding the other languages, rather than inside it. A file and a folder can share a base name, so `homepage.yaml` and `homepage/` coexist quite happily.

This is the shape Astro produces for [`prefixDefaultLocale: false`](https://docs.astro.build/en/guides/internationalization/#prefixdefaultlocale-false), where Markdown in `src/pages` is routed by file path, and the shape Starlight expects for a **root locale**. Pointing Keystatic at either means the default language has no folder of its own:

```tsx
path: 'src/content/docs/{locale}/**' // src/content/docs/index.mdoc, src/content/docs/fr/index.mdoc
path: 'src/pages/{locale}/*' // src/pages/about.md, src/pages/fr/about.md
```

Because the segment is removed rather than replaced, the `{locale}` token has to be a whole path segment β€” `posts-{locale}` has nothing to remove β€” and it can't be the only one, since that would leave the default language with an empty path.

{% aside icon="πŸ‘€" %}
Locale codes become **reserved** at the root of a localized collection. With `content/posts/{locale}/**`, a `pl` folder always holds Polish content, so an English entry can't take a slug starting with `pl/` β€” the Admin UI rejects it. Astro and Starlight reserve those names the same way. Collections using the `*` [wildcard](/docs/path-wildcard) are unaffected, because their slugs can't contain `/`.
{% /aside %}

---

## The language switcher

When `i18n` is set, a language switcher appears at the top of the Admin UI sidebar, above the collection and singleton list.

- Picking a language re-points every `localized` collection and singleton to that language's content.
- Shared (non-localized) entries stay visible in every language.
- Your choice is remembered in the browser, so you land back on the same language next time.

{% aside icon="πŸ‘€" %}
Switching language keeps you on the same entry `slug`. If that entry doesn't exist yet in the language you switch to, you'll see a "not found" state β€” create it to start that translation.
{% /aside %}

---

## Reading localized content

The [Reader API](/docs/reader-api) reads one language at a time. Pass a `locale` when you create the reader:

```tsx
import { createReader } from '@keystatic/core/reader';
import config from '../keystatic.config';

const reader = createReader(process.cwd(), config, { locale: 'en' });

const posts = await reader.collections.posts.all();
const homepage = await reader.singletons.homepage.read();
```

The GitHub reader takes the same option:

```tsx
import { createGitHubReader } from '@keystatic/core/reader/github';

const reader = createGitHubReader(config, {
repo: 'my-org/my-repo',
token: process.env.GITHUB_TOKEN,
locale: 'en',
});
```

To read every language, create one reader per locale β€” for example, loop over `Object.keys(config.i18n.locales)`. Shared collections and singletons ignore the `locale` and return the same content for every reader.

---

## Folder vs. file layout

The `{locale}` token follows the same trailing-slash rule as any other `path`:

- `path: 'content/homepage/{locale}'` β€” one **file** per language: `content/homepage/en.yaml`.
- `path: 'content/homepage/{locale}/'` β€” one **folder** per language: `content/homepage/en/index.yaml`.

See the [Path wildcard](/docs/path-wildcard) page for more on how paths map to files.
4 changes: 3 additions & 1 deletion packages/keystatic/src/api/api-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ function getIsPathValid(config: Config) {
return (filepath: string) =>
!filepath.includes('\\') &&
filepath.split('/').every(x => x !== '.' && x !== '..') &&
allowedDirectories.some(x => filepath.startsWith(x));
allowedDirectories.some(
x => filepath === x || filepath.startsWith(`${x}/`)
);
}

async function blob(
Expand Down
50 changes: 32 additions & 18 deletions packages/keystatic/src/api/read-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import fs from 'fs/promises';
import path from 'path';
import {
getCollectionPath,
getCollectionTemplatePath,
getContentLocales,
getSingletonFormat,
getSingletonPath,
isLocalized,
} from '../app/path-utils';
import { updateTreeWithChanges, blobSha } from '../app/trees';
import { Config } from '../config';
Expand Down Expand Up @@ -109,32 +112,43 @@ export async function readToDirEntries(baseDir: string) {

export function getAllowedDirectories(config: Config) {
const allowedDirectories: string[] = [];
const contentLocales = getContentLocales(config);
const localesFor = (entry: {
localized?: boolean;
}): (string | undefined)[] =>
isLocalized(entry) && contentLocales.length ? contentLocales : [undefined];

for (const [collection, collectionConfig] of Object.entries(
config.collections ?? {}
)) {
allowedDirectories.push(
...getDirectoriesForTreeKey(
fields.object(collectionConfig.schema),
getCollectionPath(config, collection),
undefined,
{ data: 'yaml', contentField: undefined, dataLocation: 'index' }
)
);
if (collectionConfig.template) {
allowedDirectories.push(collectionConfig.template);
for (const locale of localesFor(collectionConfig)) {
allowedDirectories.push(
...getDirectoriesForTreeKey(
fields.object(collectionConfig.schema),
getCollectionPath(config, collection, locale),
undefined,
{ data: 'yaml', contentField: undefined, dataLocation: 'index' }
)
);
const template = getCollectionTemplatePath(config, collection, locale);
if (template !== undefined) {
allowedDirectories.push(template);
}
}
}
for (const [singleton, singletonConfig] of Object.entries(
config.singletons ?? {}
)) {
allowedDirectories.push(
...getDirectoriesForTreeKey(
fields.object(singletonConfig.schema),
getSingletonPath(config, singleton),
undefined,
getSingletonFormat(config, singleton)
)
);
for (const locale of localesFor(singletonConfig)) {
allowedDirectories.push(
...getDirectoriesForTreeKey(
fields.object(singletonConfig.schema),
getSingletonPath(config, singleton, locale),
undefined,
getSingletonFormat(config, singleton)
)
);
}
}
return [...new Set(allowedDirectories)];
}
Loading