From 1d161f723e9f6b01142218721f607be15d51460b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timur=20Jasi=C5=84ski?= Date: Tue, 7 Jul 2026 15:13:15 +0200 Subject: [PATCH] Add content internationalization --- .changeset/content-i18n.md | 5 + docs/src/content/navigation.yaml | 5 + docs/src/content/pages/i18n.mdoc | 173 +++++++++++++ packages/keystatic/src/api/read-local.ts | 44 ++-- packages/keystatic/src/app/CollectionPage.tsx | 17 +- packages/keystatic/src/app/ItemPage.tsx | 45 +++- packages/keystatic/src/app/SingletonPage.tsx | 18 +- packages/keystatic/src/app/create-item.tsx | 30 ++- packages/keystatic/src/app/path-utils.test.ts | 234 ++++++++++++++++++ packages/keystatic/src/app/path-utils.ts | 103 +++++++- .../src/app/shell/content-locale.tsx | 83 +++++++ packages/keystatic/src/app/shell/data.tsx | 22 +- packages/keystatic/src/app/shell/index.tsx | 34 +-- .../keystatic/src/app/shell/sidebar/index.tsx | 3 + .../src/app/shell/sidebar/locale-switcher.tsx | 35 +++ packages/keystatic/src/app/ui.tsx | 2 + .../keystatic/src/app/useSlugsInCollection.ts | 7 +- packages/keystatic/src/app/utils.ts | 13 +- packages/keystatic/src/config.tsx | 9 + packages/keystatic/src/index.ts | 1 + packages/keystatic/src/reader/generic.ts | 20 +- packages/keystatic/src/reader/github.ts | 9 +- packages/keystatic/src/reader/index.ts | 10 +- packages/keystatic/test/reader-i18n.test.tsx | 152 ++++++++++++ 24 files changed, 986 insertions(+), 88 deletions(-) create mode 100644 .changeset/content-i18n.md create mode 100644 docs/src/content/pages/i18n.mdoc create mode 100644 packages/keystatic/src/app/path-utils.test.ts create mode 100644 packages/keystatic/src/app/shell/content-locale.tsx create mode 100644 packages/keystatic/src/app/shell/sidebar/locale-switcher.tsx create mode 100644 packages/keystatic/test/reader-i18n.test.tsx diff --git a/.changeset/content-i18n.md b/.changeset/content-i18n.md new file mode 100644 index 000000000..77901a56a --- /dev/null +++ b/.changeset/content-i18n.md @@ -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. diff --git a/docs/src/content/navigation.yaml b/docs/src/content/navigation.yaml index 3f803b4fc..eea853b46 100644 --- a/docs/src/content/navigation.yaml +++ b/docs/src/content/navigation.yaml @@ -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 diff --git a/docs/src/content/pages/i18n.mdoc b/docs/src/content/pages/i18n.mdoc new file mode 100644 index 000000000..3316012f7 --- /dev/null +++ b/docs/src/content/pages/i18n.mdoc @@ -0,0 +1,173 @@ +--- +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. +- `defaultLocale` — the language selected by default. Must be one of the keys in `locales`. + +### 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 +``` + +{% 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 %} + +--- + +## 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. diff --git a/packages/keystatic/src/api/read-local.ts b/packages/keystatic/src/api/read-local.ts index 9e3ff65a6..e10a9c8c1 100644 --- a/packages/keystatic/src/api/read-local.ts +++ b/packages/keystatic/src/api/read-local.ts @@ -2,8 +2,10 @@ import fs from 'fs/promises'; import path from 'path'; import { getCollectionPath, + getContentLocales, getSingletonFormat, getSingletonPath, + isLocalized, } from '../app/path-utils'; import { updateTreeWithChanges, blobSha } from '../app/trees'; import { Config } from '../config'; @@ -109,17 +111,25 @@ 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' } - ) - ); + for (const locale of localesFor(collectionConfig)) { + allowedDirectories.push( + ...getDirectoriesForTreeKey( + fields.object(collectionConfig.schema), + getCollectionPath(config, collection, locale), + undefined, + { data: 'yaml', contentField: undefined, dataLocation: 'index' } + ) + ); + } if (collectionConfig.template) { allowedDirectories.push(collectionConfig.template); } @@ -127,14 +137,16 @@ export function getAllowedDirectories(config: Config) { 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)]; } diff --git a/packages/keystatic/src/app/CollectionPage.tsx b/packages/keystatic/src/app/CollectionPage.tsx index 2d086dd03..0f57d6881 100644 --- a/packages/keystatic/src/app/CollectionPage.tsx +++ b/packages/keystatic/src/app/CollectionPage.tsx @@ -42,6 +42,7 @@ import { sortBy } from './collection-sort'; import l10nMessages from './l10n'; import { useRouter } from './router'; import { EmptyState } from './shell/empty-state'; +import { useActiveLocale } from './shell/content-locale'; import { useTree, TreeData, @@ -218,11 +219,12 @@ function CollectionPageHeader(props: { type CollectionPageContentProps = CollectionPageProps & { searchTerm: string }; function CollectionPageContent(props: CollectionPageContentProps) { const trees = useTree(); + const locale = useActiveLocale(); const tree = trees.merged.kind === 'loaded' ? trees.merged.data.current.entries.get( - getCollectionPath(props.config, props.collection) + getCollectionPath(props.config, props.collection, locale) ) : null; @@ -293,6 +295,7 @@ function CollectionTable( const repoInfo = useRepoInfo(); const currentBranch = useCurrentBranch(); + const locale = useActiveLocale(); let isLocalMode = isLocalConfig(props.config); let router = useRouter(); let [sortDescriptor, setSortDescriptor] = useState({ @@ -311,13 +314,15 @@ function CollectionTable( getEntriesInCollectionWithTreeKey( props.config, props.collection, - props.trees.default.tree + props.trees.default.tree, + locale ).map(x => [x.slug, x.key]) ); return getEntriesInCollectionWithTreeKey( props.config, props.collection, - props.trees.current.tree + props.trees.current.tree, + locale ).map(entry => { return { name: entry.slug, @@ -329,7 +334,7 @@ function CollectionTable( sha: entry.sha, }; }); - }, [props.collection, props.config, props.trees]); + }, [props.collection, props.config, props.trees, locale]); const mainFiles = useData( useCallback(async () => { @@ -346,7 +351,8 @@ function CollectionTable( getCollectionItemPath( props.config, props.collection, - entry.name + entry.name, + locale ), formatInfo ), @@ -405,6 +411,7 @@ function CollectionTable( entriesWithStatus, baseCommit, repoInfo, + locale, ]) ); diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx index 7da56b782..501f8319d 100644 --- a/packages/keystatic/src/app/ItemPage.tsx +++ b/packages/keystatic/src/app/ItemPage.tsx @@ -59,6 +59,7 @@ import { useRouter } from './router'; import { HeaderBreadcrumbs } from './shell/HeaderBreadcrumbs'; import { useYjsIfAvailable } from './shell/collab'; import { useConfig } from './shell/context'; +import { useActiveLocale } from './shell/content-locale'; import { useBaseCommit, useCurrentBranch, useRepoInfo } from './shell/data'; import { PageBody, PageHeader, PageRoot } from './shell/page'; import { useSlugFieldInfo } from './slugs'; @@ -108,7 +109,10 @@ const storedValSchema = s.type({ savedAt: s.date(), slug: s.string(), beforeTreeKey: s.string(), - files: s.map(s.string(), s.instance(Uint8Array)), + files: s.map( + s.string(), + s.define('Uint8Array', v => v instanceof Uint8Array) + ), }); function ItemPageInner( @@ -136,7 +140,13 @@ function ItemPageInner( const router = useRouter(); const baseCommit = useBaseCommit(); - const currentBasePath = getCollectionItemPath(config, collection, itemSlug); + const locale = useActiveLocale(); + const currentBasePath = getCollectionItemPath( + config, + collection, + itemSlug, + locale + ); const formatInfo = getCollectionFormat(config, collection); const currentBranch = useCurrentBranch(); const repoInfo = useRepoInfo(); @@ -422,7 +432,13 @@ function LocalItemPage( const slug = getSlugFromState(collectionConfig, state); const formatInfo = getCollectionFormat(config, collection); - const futureBasePath = getCollectionItemPath(config, collection, slug); + const locale = useActiveLocale(); + const futureBasePath = getCollectionItemPath( + config, + collection, + slug, + locale + ); const [updateResult, _update, resetUpdateItem] = useUpsertItem({ state, initialFiles, @@ -505,7 +521,13 @@ function CollabItemPage(props: ItemPageProps & { map: Y.Map }) { slugField: collectionConfig.slugField, }); - const futureBasePath = getCollectionItemPath(config, collection, slug); + const locale = useActiveLocale(); + const futureBasePath = getCollectionItemPath( + config, + collection, + slug, + locale + ); const [updateResult, _update, resetUpdateItem] = useUpsertItem({ state, initialFiles, @@ -844,6 +866,7 @@ type ItemPageWrapperProps = { function ItemPageOuterWrapper(props: ItemPageWrapperProps) { const collectionConfig = props.config.collections?.[props.collection]; if (!collectionConfig) notFound(); + const locale = useActiveLocale(); const format = useMemo( () => getCollectionFormat(props.config, props.collection), [props.config, props.collection] @@ -868,7 +891,8 @@ function ItemPageOuterWrapper(props: ItemPageWrapperProps) { dirpath: getCollectionItemPath( props.config, props.collection, - stored.slug + stored.slug, + locale ), format: getCollectionFormat(props.config, props.collection), schema: collectionConfig.schema, @@ -882,7 +906,13 @@ function ItemPageOuterWrapper(props: ItemPageWrapperProps) { treeKey: stored.beforeTreeKey, }; } catch {} - }, [collectionConfig, props.collection, props.config, props.itemSlug]) + }, [ + collectionConfig, + props.collection, + props.config, + props.itemSlug, + locale, + ]) ); const itemData = useItemData({ @@ -890,7 +920,8 @@ function ItemPageOuterWrapper(props: ItemPageWrapperProps) { dirpath: getCollectionItemPath( props.config, props.collection, - props.itemSlug + props.itemSlug, + locale ), schema: collectionConfig.schema, format, diff --git a/packages/keystatic/src/app/SingletonPage.tsx b/packages/keystatic/src/app/SingletonPage.tsx index e64eef51e..05699e25b 100644 --- a/packages/keystatic/src/app/SingletonPage.tsx +++ b/packages/keystatic/src/app/SingletonPage.tsx @@ -33,6 +33,7 @@ import { import { CreateBranchDuringUpdateDialog } from './ItemPage'; import { PageBody, PageHeader, PageRoot } from './shell/page'; +import { useActiveLocale } from './shell/content-locale'; import { useBaseCommit, useCurrentBranch, useRepoInfo } from './shell/data'; import { useHasChanged } from './useHasChanged'; import { parseEntry, useItemData } from './useItemData'; @@ -101,7 +102,8 @@ function SingletonPageInner( const isGitHub = isGitHubConfig(props.config) || isCloudConfig(props.config); const formatInfo = getSingletonFormat(props.config, props.singleton); const singletonExists = !!props.initialState; - const singletonPath = getSingletonPath(props.config, props.singleton); + const locale = useActiveLocale(); + const singletonPath = getSingletonPath(props.config, props.singleton, locale); const viewHref = isGitHub && singletonExists && repoInfo @@ -353,7 +355,8 @@ function LocalSingletonPage( const { singleton, initialFiles, initialState, localTreeKey, config, draft } = props; const { schema, singletonConfig } = useSingleton(props.singleton); - const singletonPath = getSingletonPath(config, singleton); + const locale = useActiveLocale(); + const singletonPath = getSingletonPath(config, singleton, locale); const [{ state, localTreeKey: localTreeKeyInState }, setState] = useState( () => ({ @@ -466,7 +469,8 @@ function CollabSingletonPage( ) { const { singleton, initialFiles, initialState, localTreeKey, config } = props; const { schema, singletonConfig } = useSingleton(props.singleton); - const singletonPath = getSingletonPath(config, singleton); + const locale = useActiveLocale(); + const singletonPath = getSingletonPath(config, singleton, locale); const state = useYJsValue(schema, props.map) as Record; const previewProps = usePreviewPropsFromY( @@ -522,7 +526,10 @@ const storedValSchema = s.type({ version: s.literal(1), savedAt: s.date(), beforeTreeKey: s.optional(s.string()), - files: s.map(s.string(), s.instance(Uint8Array)), + files: s.map( + s.string(), + s.define('Uint8Array', v => v instanceof Uint8Array) + ), }); function SingletonPageWrapper(props: { singleton: string; config: Config }) { @@ -540,7 +547,8 @@ function SingletonPageWrapper(props: { singleton: string; config: Config }) { [props.config, props.singleton] ); - const dirpath = getSingletonPath(props.config, props.singleton); + const locale = useActiveLocale(); + const dirpath = getSingletonPath(props.config, props.singleton, locale); const draftData = useData( useCallback(async () => { diff --git a/packages/keystatic/src/app/create-item.tsx b/packages/keystatic/src/app/create-item.tsx index 0c2a9d8a8..087e0d37a 100644 --- a/packages/keystatic/src/app/create-item.tsx +++ b/packages/keystatic/src/app/create-item.tsx @@ -31,6 +31,7 @@ import { useRouter } from './router'; import { HeaderBreadcrumbs } from './shell/HeaderBreadcrumbs'; import { useYjsIfAvailable } from './shell/collab'; import { useConfig } from './shell/context'; +import { useActiveLocale } from './shell/content-locale'; import { useSlugFieldInfo } from './slugs'; import { LOADING, useData } from './useData'; import { serializeEntryToFiles, useUpsertItem } from './updating'; @@ -72,6 +73,7 @@ function CreateItemWrapper(props: { const collectionConfig = props.config.collections?.[props.collection]; if (!collectionConfig) notFound(); + const locale = useActiveLocale(); const format = useMemo( () => getCollectionFormat(props.config, props.collection), [props.config, props.collection] @@ -91,7 +93,8 @@ function CreateItemWrapper(props: { dirpath: getCollectionItemPath( props.config, props.collection, - stored.slug + stored.slug, + locale ), format, schema: collectionConfig.schema, @@ -106,6 +109,7 @@ function CreateItemWrapper(props: { format, props.collection, props.config, + locale, ]) ); @@ -128,7 +132,8 @@ function CreateItemWrapper(props: { : getCollectionItemPath( props.config, props.collection, - duplicateSlug ?? '' + duplicateSlug ?? '', + locale ), schema: collectionConfig.schema, format, @@ -254,7 +259,10 @@ const storedValSchema = s.type({ version: s.literal(1), savedAt: s.date(), slug: s.string(), - files: s.map(s.string(), s.instance(Uint8Array)), + files: s.map( + s.string(), + s.define('Uint8Array', v => v instanceof Uint8Array) + ), }); function CreateItemLocal(props: { @@ -279,7 +287,13 @@ function CreateItemLocal(props: { const formatInfo = getCollectionFormat(props.config, props.collection); - const basePath = getCollectionItemPath(props.config, props.collection, slug); + const locale = useActiveLocale(); + const basePath = getCollectionItemPath( + props.config, + props.collection, + slug, + locale + ); const [createResult, _createItem, resetCreateItemState] = useUpsertItem({ state, basePath, @@ -372,7 +386,13 @@ function CreateItemCollab(props: { const formatInfo = getCollectionFormat(props.config, props.collection); - const basePath = getCollectionItemPath(props.config, props.collection, slug); + const locale = useActiveLocale(); + const basePath = getCollectionItemPath( + props.config, + props.collection, + slug, + locale + ); const [createResult, _createItem, resetCreateItemState] = useUpsertItem({ state, basePath, diff --git a/packages/keystatic/src/app/path-utils.test.ts b/packages/keystatic/src/app/path-utils.test.ts new file mode 100644 index 000000000..1432fad34 --- /dev/null +++ b/packages/keystatic/src/app/path-utils.test.ts @@ -0,0 +1,234 @@ +import { expect, test, describe } from '@jest/globals'; + +import { config, collection, singleton, fields } from '../index'; +import { Config } from '../config'; +import { + assertValidI18nConfig, + getCollectionItemPath, + getCollectionPath, + getContentLocales, + getSingletonPath, + getSlugGlobForCollection, + isLocalized, + substituteLocale, +} from './path-utils'; +import { resolveInitialLocale } from './shell/content-locale'; + +const i18nConfig = config({ + storage: { kind: 'local' }, + i18n: { + locales: { en: 'English', fr: 'Français' }, + defaultLocale: 'en', + }, + collections: { + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts/{locale}/*', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + tags: collection({ + label: 'Tags', + path: 'content/tags/*', + slugField: 'name', + schema: { name: fields.text({ label: 'Name' }) }, + }), + }, + singletons: { + homepage: singleton({ + label: 'Homepage', + localized: true, + path: 'content/homepage/{locale}', + schema: { headline: fields.text({ label: 'Headline' }) }, + }), + settings: singleton({ + label: 'Settings', + path: 'content/settings', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, +}) as Config; + +describe('substituteLocale', () => { + test('leaves a tokenless path unchanged', () => { + expect(substituteLocale('content/posts', 'en')).toBe('content/posts'); + expect(substituteLocale('content/posts', undefined)).toBe('content/posts'); + }); + test('replaces the token with the locale', () => { + expect(substituteLocale('content/{locale}/posts', 'en')).toBe( + 'content/en/posts' + ); + expect(substituteLocale('content/posts/{locale}', 'fr')).toBe( + 'content/posts/fr' + ); + }); + test('throws when the token is present but no locale is given', () => { + expect(() => + substituteLocale('content/{locale}/posts', undefined) + ).toThrowErrorMatchingInlineSnapshot( + `"Path "content/{locale}/posts" contains the {locale} token but no locale was provided. Pass a locale (e.g. \`createReader(dir, config, { locale })\`) or set config.i18n."` + ); + }); +}); + +describe('locale-aware path resolution', () => { + test('getCollectionPath substitutes the active locale', () => { + expect(getCollectionPath(i18nConfig, 'posts', 'en')).toBe( + 'content/posts/en' + ); + expect(getCollectionPath(i18nConfig, 'posts', 'fr')).toBe( + 'content/posts/fr' + ); + }); + test('getCollectionItemPath substitutes the active locale', () => { + expect(getCollectionItemPath(i18nConfig, 'posts', 'my-post', 'en')).toBe( + 'content/posts/en/my-post' + ); + }); + test('getSingletonPath substitutes the active locale', () => { + expect(getSingletonPath(i18nConfig, 'homepage', 'fr')).toBe( + 'content/homepage/fr' + ); + }); + test('non-localized entries ignore the locale', () => { + expect(getCollectionPath(i18nConfig, 'tags', 'en')).toBe('content/tags'); + expect(getCollectionPath(i18nConfig, 'tags', undefined)).toBe( + 'content/tags' + ); + expect(getSingletonPath(i18nConfig, 'settings', 'en')).toBe( + 'content/settings' + ); + }); + test('a localized path without a locale throws', () => { + expect(() => getCollectionPath(i18nConfig, 'posts')).toThrow( + /\{locale\} token/ + ); + expect(() => getSingletonPath(i18nConfig, 'homepage')).toThrow( + /\{locale\} token/ + ); + }); +}); + +describe('locale is orthogonal to glob and format', () => { + test('getSlugGlobForCollection is unaffected by the token', () => { + expect(getSlugGlobForCollection(i18nConfig, 'posts')).toBe('*'); + const doubleGlob = config({ + storage: { kind: 'local' }, + i18n: { locales: { en: 'English' }, defaultLocale: 'en' }, + collections: { + docs: collection({ + label: 'Docs', + localized: true, + path: 'content/{locale}/docs/**', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, + }) as Config; + expect(getSlugGlobForCollection(doubleGlob, 'docs')).toBe('**'); + }); +}); + +describe('getContentLocales / isLocalized', () => { + test('getContentLocales returns declared locale codes', () => { + expect(getContentLocales(i18nConfig)).toEqual(['en', 'fr']); + expect( + getContentLocales(config({ storage: { kind: 'local' } }) as Config) + ).toEqual([]); + }); + test('isLocalized reflects the flag', () => { + expect(isLocalized(i18nConfig.collections!.posts)).toBe(true); + expect(isLocalized(i18nConfig.collections!.tags)).toBe(false); + expect(isLocalized(undefined)).toBe(false); + }); +}); + +describe('assertValidI18nConfig', () => { + test('accepts a valid config', () => { + expect(() => assertValidI18nConfig(i18nConfig)).not.toThrow(); + }); + test('accepts a config with no i18n', () => { + expect(() => + assertValidI18nConfig(config({ storage: { kind: 'local' } }) as Config) + ).not.toThrow(); + }); + + test('(a) rejects a {locale} token without config.i18n', () => { + const bad = config({ + storage: { kind: 'local' }, + collections: { + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts/{locale}/*', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, + }) as Config; + expect(() => assertValidI18nConfig(bad)).toThrow(/config.i18n is not set/); + }); + + test('(b) rejects localized: true without a {locale} token', () => { + const bad = config({ + storage: { kind: 'local' }, + i18n: { locales: { en: 'English' }, defaultLocale: 'en' }, + collections: { + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts/*', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, + }) as Config; + expect(() => assertValidI18nConfig(bad)).toThrow( + /does not contain the \{locale\} token/ + ); + }); + + test('(c) rejects a {locale} token without localized: true', () => { + const bad = config({ + storage: { kind: 'local' }, + i18n: { locales: { en: 'English' }, defaultLocale: 'en' }, + collections: { + posts: collection({ + label: 'Posts', + path: 'content/posts/{locale}/*', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, + }) as Config; + expect(() => assertValidI18nConfig(bad)).toThrow(/is not marked localized/); + }); + + test('(d) rejects a defaultLocale not present in locales', () => { + const bad = config({ + storage: { kind: 'local' }, + i18n: { locales: { en: 'English' }, defaultLocale: 'pl' }, + }) as Config; + expect(() => assertValidI18nConfig(bad)).toThrow( + /defaultLocale "pl" is not one of/ + ); + }); +}); + +describe('resolveInitialLocale', () => { + const i18n = { + locales: { en: 'English', fr: 'Français' }, + defaultLocale: 'en', + }; + test('returns undefined when there is no i18n', () => { + expect(resolveInitialLocale('en', undefined)).toBeUndefined(); + }); + test('returns a stored locale when it is still valid', () => { + expect(resolveInitialLocale('fr', i18n)).toBe('fr'); + }); + test('falls back to the default when stored is missing or invalid', () => { + expect(resolveInitialLocale(null, i18n)).toBe('en'); + expect(resolveInitialLocale('de', i18n)).toBe('en'); + }); +}); diff --git a/packages/keystatic/src/app/path-utils.ts b/packages/keystatic/src/app/path-utils.ts index 97fb22caa..427d16c7c 100644 --- a/packages/keystatic/src/app/path-utils.ts +++ b/packages/keystatic/src/app/path-utils.ts @@ -6,6 +6,20 @@ export function fixPath(path: string) { return path.replace(/^\.?\/+/, '').replace(/\/*$/, ''); } +export const LOCALE_TOKEN = '{locale}'; + +export function substituteLocale(path: string, locale: string | undefined) { + if (!path.includes(LOCALE_TOKEN)) { + return path; + } + if (locale === undefined) { + throw new Error( + `Path "${path}" contains the ${LOCALE_TOKEN} token but no locale was provided. Pass a locale (e.g. \`createReader(dir, config, { locale })\`) or set config.i18n.` + ); + } + return path.split(LOCALE_TOKEN).join(locale); +} + const collectionPath = /\/\*\*?(?:$|\/)/; function getConfiguredCollectionPath(config: Config, collection: string) { @@ -19,10 +33,14 @@ function getConfiguredCollectionPath(config: Config, collection: string) { return path; } -export function getCollectionPath(config: Config, collection: string) { +export function getCollectionPath( + config: Config, + collection: string, + locale?: string +) { const configuredPath = getConfiguredCollectionPath(config, collection); const path = fixPath(configuredPath.replace(/\*\*?.*$/, '')); - return path; + return substituteLocale(path, locale); } export function getCollectionFormat(config: Config, collection: string) { @@ -36,11 +54,12 @@ export function getSingletonFormat(config: Config, singleton: string) { export function getCollectionItemPath( config: Config, collection: string, - slug: string + slug: string, + locale?: string ) { - const basePath = getCollectionPath(config, collection); + const basePath = getCollectionPath(config, collection, locale); const suffix = getCollectionItemSlugSuffix(config, collection); - return `${basePath}/${slug}${suffix}`; + return substituteLocale(`${basePath}/${slug}${suffix}`, locale); } export function getEntryDataFilepath(dir: string, formatInfo: FormatInfo) { @@ -66,7 +85,11 @@ export function getCollectionItemSlugSuffix( return path ? `/${path}` : ''; } -export function getSingletonPath(config: Config, singleton: string) { +export function getSingletonPath( + config: Config, + singleton: string, + locale?: string +) { if (config.singletons![singleton].path?.includes('*')) { throw new Error( `Singleton paths cannot include * but ${singleton} has ${ @@ -74,7 +97,10 @@ export function getSingletonPath(config: Config, singleton: string) { }` ); } - return fixPath(config.singletons![singleton].path ?? singleton); + return substituteLocale( + fixPath(config.singletons![singleton].path ?? singleton), + locale + ); } export function getDataFileExtension(formatInfo: FormatInfo) { @@ -212,3 +238,66 @@ export function getPathPrefix(storage: Config['storage']) { } return fixPath(storage.pathPrefix) + '/'; } + +export function getContentLocales(config: Config): string[] { + return Object.keys(config.i18n?.locales ?? {}); +} + +export function isLocalized( + entryConfig: { localized?: boolean } | undefined +): boolean { + return entryConfig?.localized === true; +} + +export function assertValidI18nConfig(config: Config): void { + const i18n = config.i18n; + if (i18n) { + const localeCodes = Object.keys(i18n.locales ?? {}); + if (localeCodes.length === 0) { + throw new Error(`config.i18n.locales must contain at least one locale`); + } + if (!localeCodes.includes(i18n.defaultLocale)) { + throw new Error( + `config.i18n.defaultLocale "${ + i18n.defaultLocale + }" is not one of config.i18n.locales (${localeCodes.join(', ')})` + ); + } + } + + const checkEntry = ( + type: 'Collection' | 'Singleton', + key: string, + entry: { path?: string; localized?: boolean } + ) => { + const hasToken = !!entry.path?.includes(LOCALE_TOKEN); + const localized = isLocalized(entry); + if (hasToken && !i18n) { + throw new Error( + `${type} "${key}" uses the ${LOCALE_TOKEN} token in its path but config.i18n is not set` + ); + } + if (localized && !i18n) { + throw new Error( + `${type} "${key}" is marked localized but config.i18n is not set` + ); + } + if (localized && !hasToken) { + throw new Error( + `${type} "${key}" is marked localized but its path does not contain the ${LOCALE_TOKEN} token` + ); + } + if (hasToken && !localized) { + throw new Error( + `${type} "${key}" uses the ${LOCALE_TOKEN} token in its path but is not marked localized` + ); + } + }; + + for (const [key, collection] of Object.entries(config.collections ?? {})) { + checkEntry('Collection', key, collection); + } + for (const [key, singleton] of Object.entries(config.singletons ?? {})) { + checkEntry('Singleton', key, singleton); + } +} diff --git a/packages/keystatic/src/app/shell/content-locale.tsx b/packages/keystatic/src/app/shell/content-locale.tsx new file mode 100644 index 000000000..ef55957fd --- /dev/null +++ b/packages/keystatic/src/app/shell/content-locale.tsx @@ -0,0 +1,83 @@ +import { ReactNode, createContext, useContext, useMemo, useState } from 'react'; + +import { I18nConfig } from '../../config'; +import { useConfig } from './context'; + +const STORAGE_KEY = 'keystatic-content-locale'; + +export type ContentLocale = { code: string; label: string }; + +type ContentLocaleContextType = { + locale: string | undefined; + locales: ContentLocale[]; + setLocale: (code: string) => void; +}; + +const ContentLocaleContext = createContext({ + locale: undefined, + locales: [], + setLocale: () => {}, +}); + +export function resolveInitialLocale( + stored: string | null | undefined, + i18n: I18nConfig | undefined +): string | undefined { + if (!i18n) { + return undefined; + } + if (stored != null && Object.keys(i18n.locales).includes(stored)) { + return stored; + } + return i18n.defaultLocale; +} + +// only for initializing the provider, for consumption use `useContentLocale()` +export function ContentLocaleProvider(props: { children: ReactNode }) { + const config = useConfig(); + const i18n = config.i18n; + + const [locale, setLocaleValue] = useState(() => { + let stored: string | null = null; + try { + stored = localStorage.getItem(STORAGE_KEY); + } catch {} + return resolveInitialLocale(stored, i18n); + }); + + const value = useMemo(() => { + if (!i18n) { + return { locale: undefined, locales: [], setLocale: () => {} }; + } + const locales: ContentLocale[] = Object.entries(i18n.locales).map( + ([code, label]) => ({ code, label }) + ); + return { + locale, + locales, + setLocale: (code: string) => { + if (!Object.keys(i18n.locales).includes(code)) { + return; + } + try { + localStorage.setItem(STORAGE_KEY, code); + } catch {} + setLocaleValue(code); + }, + }; + }, [i18n, locale]); + + return ( + + {props.children} + + ); +} + +export function useContentLocale() { + return useContext(ContentLocaleContext); +} + +export function useActiveLocale(): string | undefined { + return useContext(ContentLocaleContext).locale; +} diff --git a/packages/keystatic/src/app/shell/data.tsx b/packages/keystatic/src/app/shell/data.tsx index d19ac523e..6af1d4b34 100644 --- a/packages/keystatic/src/app/shell/data.tsx +++ b/packages/keystatic/src/app/shell/data.tsx @@ -13,6 +13,7 @@ import { } from 'react'; import { CombinedError, useQuery, UseQueryState } from 'urql'; import { getSingletonPath } from '../path-utils'; +import { useActiveLocale } from './content-locale'; import { getTreeNodeAtPath, treeEntriesToTreeNodes, @@ -74,6 +75,7 @@ export function LocalAppShellProvider(props: { config: LocalConfig; children: ReactNode; }) { + const locale = useActiveLocale(); const [currentTreeSha, setCurrentTreeSha] = useState('initial'); const tree = useData( @@ -106,8 +108,8 @@ export function LocalAppShellProvider(props: { singletons: new Set(), }; } - return getChangedData(props.config, allTreeData.scoped.merged.data); - }, [allTreeData, props.config]); + return getChangedData(props.config, allTreeData.scoped.merged.data, locale); + }, [allTreeData, props.config, locale]); return ( @@ -275,6 +277,7 @@ export function GitHubAppShellProvider(props: { children: ReactNode; }) { const router = useRouter(); + const locale = useActiveLocale(); const { data, error } = useContext(GitHubAppShellDataContext)!; let repo: | FragmentData @@ -362,8 +365,8 @@ export function GitHubAppShellProvider(props: { singletons: new Set(), }; } - return getChangedData(props.config, allTreeData.scoped.merged.data); - }, [allTreeData, props.config]); + return getChangedData(props.config, allTreeData.scoped.merged.data, locale); + }, [allTreeData, props.config, locale]); useEffect(() => { if (error?.response?.status === 401) { @@ -732,7 +735,8 @@ function useGitHubTreeData(sha: string | null, config: Config) { function getChangedData( config: Config, - trees: { current: TreeData; default: TreeData } + trees: { current: TreeData; default: TreeData }, + locale?: string ) { return { collections: new Map( @@ -741,14 +745,16 @@ function getChangedData( getEntriesInCollectionWithTreeKey( config, collection, - trees.current.tree + trees.current.tree, + locale ).map(x => [x.slug, x.key]) ); const defaultBranch = new Map( getEntriesInCollectionWithTreeKey( config, collection, - trees.default.tree + trees.default.tree, + locale ).map(x => [x.slug, x.key]) ); @@ -775,7 +781,7 @@ function getChangedData( ), singletons: new Set( Object.keys(config.singletons ?? {}).filter(singleton => { - const singletonPath = getSingletonPath(config, singleton); + const singletonPath = getSingletonPath(config, singleton, locale); return ( getTreeNodeAtPath(trees.current.tree, singletonPath)?.entry.sha !== getTreeNodeAtPath(trees.default.tree, singletonPath)?.entry.sha diff --git a/packages/keystatic/src/app/shell/index.tsx b/packages/keystatic/src/app/shell/index.tsx index dabfce36e..1cc78ee5f 100644 --- a/packages/keystatic/src/app/shell/index.tsx +++ b/packages/keystatic/src/app/shell/index.tsx @@ -7,6 +7,7 @@ import { Config } from '../../config'; import { isGitHubConfig, isLocalConfig } from '../utils'; import { AppStateContext, ConfigContext } from './context'; +import { ContentLocaleProvider } from './content-locale'; import { GitHubAppShellProvider, AppShellErrorContext, @@ -65,19 +66,18 @@ export const AppShell = (props: { ); const inner = ( - - - - - {content} - - - - + + + + {content} + + + ); + let withData: ReactNode; if (isGitHubConfig(props.config) || props.config.storage.kind === 'cloud') { - return ( + withData = ( ); - } - if (isLocalConfig(props.config)) { - return ( + } else if (isLocalConfig(props.config)) { + withData = ( {inner} ); + } else { + return null; } - return null; + + return ( + + {withData} + + ); }; diff --git a/packages/keystatic/src/app/shell/sidebar/index.tsx b/packages/keystatic/src/app/shell/sidebar/index.tsx index 01be17d1c..048b81626 100644 --- a/packages/keystatic/src/app/shell/sidebar/index.tsx +++ b/packages/keystatic/src/app/shell/sidebar/index.tsx @@ -38,6 +38,7 @@ import { pluralize } from '../../pluralize'; import { useBrand } from '../common'; import { SIDE_PANEL_ID } from '../constants'; import { GitMenu, ThemeMenu, UserActions } from './components'; +import { SidebarLocaleSwitcher } from './locale-switcher'; import { BranchPicker } from '../../branch-selection'; import { useAppState, useConfig } from '../context'; @@ -81,6 +82,7 @@ export function SidebarPanel() { + @@ -226,6 +228,7 @@ export function SidebarDialog() { > + diff --git a/packages/keystatic/src/app/shell/sidebar/locale-switcher.tsx b/packages/keystatic/src/app/shell/sidebar/locale-switcher.tsx new file mode 100644 index 000000000..7975f05c3 --- /dev/null +++ b/packages/keystatic/src/app/shell/sidebar/locale-switcher.tsx @@ -0,0 +1,35 @@ +import { Picker, Item } from '@keystar/ui/picker'; +import { HStack } from '@keystar/ui/layout'; +import { Text } from '@keystar/ui/typography'; + +import { useContentLocale } from '../content-locale'; + +export function SidebarLocaleSwitcher() { + const { locale, locales, setLocale } = useContentLocale(); + + if (locale === undefined || locales.length === 0) { + return null; + } + + return ( + + { + if (typeof key === 'string') { + setLocale(key); + } + }} + flex + > + {item => ( + + {item.label} + + )} + + + ); +} diff --git a/packages/keystatic/src/app/ui.tsx b/packages/keystatic/src/app/ui.tsx index 541ef7d96..fd775b324 100644 --- a/packages/keystatic/src/app/ui.tsx +++ b/packages/keystatic/src/app/ui.tsx @@ -43,6 +43,7 @@ import { import { KeystaticCloudAuthCallback } from './cloud-auth-callback'; import { getAuth } from './auth'; import { assertValidRepoConfig } from './repo-config'; +import { assertValidI18nConfig } from './path-utils'; import { NotFoundBoundary, notFound } from './not-found'; function parseParamsWithoutBranch(params: string[]) { @@ -296,6 +297,7 @@ export function Keystatic(props: { if (props.config.storage.kind === 'github') { assertValidRepoConfig(props.config.storage.repo); } + assertValidI18nConfig(props.config); // The loopback redirect is only needed if the storage uses OAuth callbacks. const Wrapper = diff --git a/packages/keystatic/src/app/useSlugsInCollection.ts b/packages/keystatic/src/app/useSlugsInCollection.ts index 66650929e..91ac4d862 100644 --- a/packages/keystatic/src/app/useSlugsInCollection.ts +++ b/packages/keystatic/src/app/useSlugsInCollection.ts @@ -1,10 +1,12 @@ import { useMemo } from 'react'; import { useConfig } from './shell/context'; +import { useActiveLocale } from './shell/content-locale'; import { useTree } from './shell/data'; import { getEntriesInCollectionWithTreeKey } from './utils'; export function useSlugsInCollection(collection: string) { const config = useConfig(); + const locale = useActiveLocale(); const tree = useTree().current; return useMemo(() => { @@ -12,7 +14,8 @@ export function useSlugsInCollection(collection: string) { return getEntriesInCollectionWithTreeKey( config, collection, - loadedTree + loadedTree, + locale ).map(x => x.slug); - }, [config, tree, collection]); + }, [config, tree, collection, locale]); } diff --git a/packages/keystatic/src/app/utils.ts b/packages/keystatic/src/app/utils.ts index 4572113a8..efc08f4bc 100644 --- a/packages/keystatic/src/app/utils.ts +++ b/packages/keystatic/src/app/utils.ts @@ -95,14 +95,15 @@ export function getSlugFromState( export function getEntriesInCollectionWithTreeKey( config: Config, collection: string, - rootTree: Map + rootTree: Map, + locale?: string ): { key: string; slug: string; sha: string }[] { const collectionConfig = config.collections![collection]; const schema = object(collectionConfig.schema); const formatInfo = getCollectionFormat(config, collection); const extension = getDataFileExtension(formatInfo); const glob = getSlugGlobForCollection(config, collection); - const collectionPath = getCollectionPath(config, collection); + const collectionPath = getCollectionPath(config, collection, locale); const directory: Map = getTreeNodeAtPath(rootTree, collectionPath)?.children ?? new Map(); const entries: { key: string; slug: string; sha: string }[] = []; @@ -126,7 +127,7 @@ export function getEntriesInCollectionWithTreeKey( if (formatInfo.dataLocation === 'index') { const actualEntry = getTreeNodeAtPath( rootTree, - getCollectionItemPath(config, collection, key) + getCollectionItemPath(config, collection, key, locale) ); if (!actualEntry?.children?.has('index' + extension)) continue; entries.push({ @@ -144,14 +145,14 @@ export function getEntriesInCollectionWithTreeKey( if (suffix) { const newEntry = getTreeNodeAtPath( rootTree, - getCollectionItemPath(config, collection, key) + extension + getCollectionItemPath(config, collection, key, locale) + extension ); if (!newEntry || newEntry.children) continue; entries.push({ key: getTreeKey( [ entry.entry.path, - getCollectionItemPath(config, collection, key), + getCollectionItemPath(config, collection, key, locale), ...directoriesUsedInSchema.map(x => `${x}/${key}`), ], rootTree @@ -166,7 +167,7 @@ export function getEntriesInCollectionWithTreeKey( key: getTreeKey( [ entry.entry.path, - getCollectionItemPath(config, collection, slug), + getCollectionItemPath(config, collection, slug, locale), ...directoriesUsedInSchema.map(x => `${x}/${slug}`), ], rootTree diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 4c95368f3..75f1df030 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -17,12 +17,19 @@ export type Format = }; export type EntryLayout = 'content' | 'form'; export type Glob = '*' | '**'; + +export type I18nConfig = { + locales: Record; + defaultLocale: string; +}; + export type Collection< Schema extends Record, SlugField extends string, > = { label: string; path?: `${string}/${Glob}` | `${string}/${Glob}/${string}`; + localized?: boolean; entryLayout?: EntryLayout; format?: Format; previewUrl?: string; @@ -36,6 +43,7 @@ export type Collection< export type Singleton> = { label: string; path?: string; + localized?: boolean; entryLayout?: EntryLayout; format?: Format; previewUrl?: string; @@ -44,6 +52,7 @@ export type Singleton> = { type CommonConfig = { locale?: Locale; + i18n?: I18nConfig; cloud?: { project: string }; ui?: UserInterface; }; diff --git a/packages/keystatic/src/index.ts b/packages/keystatic/src/index.ts index 6e1a5ac81..db0077f90 100644 --- a/packages/keystatic/src/index.ts +++ b/packages/keystatic/src/index.ts @@ -14,6 +14,7 @@ export type { Format, GitHubConfig, Glob, + I18nConfig, LocalConfig, Singleton, } from './config'; diff --git a/packages/keystatic/src/reader/generic.ts b/packages/keystatic/src/reader/generic.ts index 9d6d4ba39..d833c0b2d 100644 --- a/packages/keystatic/src/reader/generic.ts +++ b/packages/keystatic/src/reader/generic.ts @@ -259,10 +259,10 @@ const listCollection = cache(async function listCollection( export function collectionReader( collection: string, config: Config, - fsReader: MinimalFs + fsReader: MinimalFs, + locale?: string ): CollectionReader { const formatInfo = getCollectionFormat(config, collection); - const collectionPath = getCollectionPath(config, collection); const collectionConfig = config.collections![collection]; const schema = fields.object(collectionConfig.schema); const glob = getSlugGlobForCollection(config, collection); @@ -272,7 +272,7 @@ export function collectionReader( readItem( schema, formatInfo, - getCollectionItemPath(config, collection, slug), + getCollectionItemPath(config, collection, slug, locale), args[0]?.resolveLinkedFiles, `"${slug}" in collection "${collection}"`, fsReader, @@ -282,7 +282,13 @@ export function collectionReader( ); const list = () => - listCollection(collectionPath, glob, formatInfo, extension, fsReader); + listCollection( + getCollectionPath(config, collection, locale), + glob, + formatInfo, + extension, + fsReader + ); return { read, @@ -403,16 +409,16 @@ const readItem = cache(async function readItem( export function singletonReader( singleton: string, config: Config, - fsReader: MinimalFs + fsReader: MinimalFs, + locale?: string ): SingletonReader { const formatInfo = getSingletonFormat(config, singleton); - const singletonPath = getSingletonPath(config, singleton); const schema = fields.object(config.singletons![singleton].schema); const read: SingletonReader['read'] = (...args) => readItem( schema, formatInfo, - singletonPath, + getSingletonPath(config, singleton, locale), args[0]?.resolveLinkedFiles, `singleton "${singleton}"`, fsReader, diff --git a/packages/keystatic/src/reader/github.ts b/packages/keystatic/src/reader/github.ts index 38ae187da..ee95c0394 100644 --- a/packages/keystatic/src/reader/github.ts +++ b/packages/keystatic/src/reader/github.ts @@ -11,7 +11,7 @@ import { treeEntriesToTreeNodes, } from '../app/trees'; import { cache } from '#react-cache-in-react-server'; -import { fixPath } from '../app/path-utils'; +import { assertValidI18nConfig, fixPath } from '../app/path-utils'; export type { Entry, EntryWithResolvedLinkedFiles } from './generic'; @@ -38,8 +38,11 @@ export function createGitHubReader< pathPrefix?: string; ref?: string; token?: string; + locale?: string; } ): Reader { + assertValidI18nConfig(config as Config); + const locale = opts.locale; const ref = opts.ref ?? 'HEAD'; const pathPrefix = opts.pathPrefix ? fixPath(opts.pathPrefix) + '/' : ''; const getTree = cache(async function loadTree() { @@ -96,13 +99,13 @@ export function createGitHubReader< collections: Object.fromEntries( Object.keys(config.collections || {}).map(key => [ key, - collectionReader(key, config as Config, fs), + collectionReader(key, config as Config, fs, locale), ]) ) as any, singletons: Object.fromEntries( Object.keys(config.singletons || {}).map(key => [ key, - singletonReader(key, config as Config, fs), + singletonReader(key, config as Config, fs, locale), ]) ) as any, config, diff --git a/packages/keystatic/src/reader/index.ts b/packages/keystatic/src/reader/index.ts index 3347496bc..19526db21 100644 --- a/packages/keystatic/src/reader/index.ts +++ b/packages/keystatic/src/reader/index.ts @@ -1,6 +1,7 @@ import nodePath from 'node:path'; import nodeFs from 'node:fs/promises'; import { Collection, ComponentSchema, Config, Singleton } from '..'; +import { assertValidI18nConfig } from '../app/path-utils'; import { BaseReader, MinimalFs, @@ -30,8 +31,11 @@ export function createReader< }, >( repoPath: string, - config: Config + config: Config, + opts?: { locale?: string } ): Reader { + assertValidI18nConfig(config as Config); + const locale = opts?.locale; const fs: MinimalFs = { async fileExists(path) { try { @@ -75,13 +79,13 @@ export function createReader< collections: Object.fromEntries( Object.keys(config.collections || {}).map(key => [ key, - collectionReader(key, config as Config, fs), + collectionReader(key, config as Config, fs, locale), ]) ) as any, singletons: Object.fromEntries( Object.keys(config.singletons || {}).map(key => [ key, - singletonReader(key, config as Config, fs), + singletonReader(key, config as Config, fs, locale), ]) ) as any, repoPath, diff --git a/packages/keystatic/test/reader-i18n.test.tsx b/packages/keystatic/test/reader-i18n.test.tsx new file mode 100644 index 000000000..5085edda2 --- /dev/null +++ b/packages/keystatic/test/reader-i18n.test.tsx @@ -0,0 +1,152 @@ +/** @jest-environment node */ +import { expect, test, describe } from '@jest/globals'; +import { config, collection, singleton, fields } from '../src'; +import { createReader } from '../src/reader'; +import { getAllowedDirectories } from '../src/api/read-local'; +import { testdir } from './test-utils'; + +const i18nConfig = config({ + storage: { kind: 'local' }, + i18n: { + locales: { en: 'English', fr: 'Français' }, + defaultLocale: 'en', + }, + collections: { + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts/{locale}/*', + slugField: 'title', + schema: { + title: fields.text({ label: 'Title' }), + body: fields.text({ label: 'Body' }), + }, + }), + tags: collection({ + label: 'Tags', + path: 'content/tags/*', + slugField: 'name', + schema: { + name: fields.text({ label: 'Name' }), + value: fields.text({ label: 'Value' }), + }, + }), + }, + singletons: { + homepage: singleton({ + label: 'Homepage', + localized: true, + path: 'content/homepage/{locale}', + schema: { headline: fields.text({ label: 'Headline' }) }, + }), + settings: singleton({ + label: 'Settings', + path: 'content/settings', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, +}); + +function fixture() { + return testdir({ + 'content/posts/en/hello.yaml': 'body: English body\n', + 'content/posts/fr/hello.yaml': 'body: French body\n', + 'content/tags/x.yaml': 'value: shared value\n', + 'content/homepage/en.yaml': 'headline: EN home\n', + 'content/homepage/fr.yaml': 'headline: FR home\n', + 'content/settings.yaml': 'title: shared settings\n', + }); +} + +describe('reader reads the active locale', () => { + test('localized collection reads the matching language', async () => { + const dir = await fixture(); + const en = createReader(dir, i18nConfig, { locale: 'en' }); + const fr = createReader(dir, i18nConfig, { locale: 'fr' }); + + expect(await en.collections.posts.list()).toEqual(['hello']); + expect(await fr.collections.posts.list()).toEqual(['hello']); + + expect(await en.collections.posts.read('hello')).toMatchObject({ + body: 'English body', + }); + expect(await fr.collections.posts.read('hello')).toMatchObject({ + body: 'French body', + }); + }); + + test('localized singleton reads the matching language', async () => { + const dir = await fixture(); + const en = createReader(dir, i18nConfig, { locale: 'en' }); + const fr = createReader(dir, i18nConfig, { locale: 'fr' }); + + expect(await en.singletons.homepage.read()).toMatchObject({ + headline: 'EN home', + }); + expect(await fr.singletons.homepage.read()).toMatchObject({ + headline: 'FR home', + }); + }); + + test('shared collections/singletons are identical across locales', async () => { + const dir = await fixture(); + const en = createReader(dir, i18nConfig, { locale: 'en' }); + const fr = createReader(dir, i18nConfig, { locale: 'fr' }); + + expect(await en.collections.tags.read('x')).toMatchObject({ + value: 'shared value', + }); + expect(await fr.collections.tags.read('x')).toMatchObject({ + value: 'shared value', + }); + expect(await en.singletons.settings.read()).toMatchObject({ + title: 'shared settings', + }); + expect(await fr.singletons.settings.read()).toMatchObject({ + title: 'shared settings', + }); + }); +}); + +describe('reader created without a locale', () => { + test('can read shared content but throws for localized content', async () => { + const dir = await fixture(); + const reader = createReader(dir, i18nConfig); + + expect(await reader.singletons.settings.read()).toMatchObject({ + title: 'shared settings', + }); + + expect(() => reader.collections.posts.list()).toThrow(/locale/); + expect(() => reader.singletons.homepage.read()).toThrow(/locale/); + await expect(reader.collections.posts.all()).rejects.toThrow(/locale/); + }); +}); + +describe('createReader validates i18n config', () => { + test('throws for an invalid config', async () => { + const dir = await fixture(); + const bad = config({ + storage: { kind: 'local' }, + i18n: { locales: { en: 'English' }, defaultLocale: 'pl' }, + }); + expect(() => createReader(dir, bad)).toThrow( + /defaultLocale "pl" is not one of/ + ); + }); +}); + +describe('getAllowedDirectories', () => { + test('allows every locale directory for localized entries', () => { + const dirs = getAllowedDirectories(i18nConfig as any); + expect(dirs).toEqual( + expect.arrayContaining([ + 'content/posts/en', + 'content/posts/fr', + 'content/tags', + 'content/homepage/en', + 'content/homepage/fr', + ]) + ); + }); +});