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/.changeset/prefix-default-locale.md b/.changeset/prefix-default-locale.md new file mode 100644 index 000000000..0cd518d74 --- /dev/null +++ b/.changeset/prefix-default-locale.md @@ -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. diff --git a/.changeset/template-locale.md b/.changeset/template-locale.md new file mode 100644 index 000000000..491023d1a --- /dev/null +++ b/.changeset/template-locale.md @@ -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. 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..5ef5f5357 --- /dev/null +++ b/docs/src/content/pages/i18n.mdoc @@ -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. diff --git a/packages/keystatic/src/api/api-node.ts b/packages/keystatic/src/api/api-node.ts index a1b9d65cc..af3b5c107 100644 --- a/packages/keystatic/src/api/api-node.ts +++ b/packages/keystatic/src/api/api-node.ts @@ -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( diff --git a/packages/keystatic/src/api/read-local.ts b/packages/keystatic/src/api/read-local.ts index 9e3ff65a6..997fd60b2 100644 --- a/packages/keystatic/src/api/read-local.ts +++ b/packages/keystatic/src/api/read-local.ts @@ -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'; @@ -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)]; } diff --git a/packages/keystatic/src/app/CollectionPage.tsx b/packages/keystatic/src/app/CollectionPage.tsx index 1604f34cc..b74f65a61 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 f19cc2de7..257c9d28f 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 6f8d2417e..5be540834 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'; @@ -40,6 +41,7 @@ import { useYJsValue } from './useYJsValue'; import { getCollectionFormat, getCollectionItemPath, + getCollectionTemplatePath, getSlugFromState, isGitHubConfig, useShowRestoredDraftMessage, @@ -72,6 +74,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 +94,8 @@ function CreateItemWrapper(props: { dirpath: getCollectionItemPath( props.config, props.collection, - stored.slug + stored.slug, + locale ), format, schema: collectionConfig.schema, @@ -106,6 +110,7 @@ function CreateItemWrapper(props: { format, props.collection, props.config, + locale, ]) ); @@ -120,15 +125,22 @@ function CreateItemWrapper(props: { const isFromTemplate = !!duplicateSlug || !!collectionConfig.template; + const templatePath = getCollectionTemplatePath( + props.config, + props.collection, + locale + ); + const itemData = useItemData({ config: props.config, dirpath: - collectionConfig.template && !duplicateSlug - ? collectionConfig.template + templatePath && !duplicateSlug + ? templatePath : getCollectionItemPath( props.config, props.collection, - duplicateSlug ?? '' + duplicateSlug ?? '', + locale ), schema: collectionConfig.schema, format, @@ -254,7 +266,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 +294,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 +393,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..1e00ac402 --- /dev/null +++ b/packages/keystatic/src/app/path-utils.test.ts @@ -0,0 +1,667 @@ +import { expect, test, describe } from '@jest/globals'; + +import { config, collection, singleton, fields } from '../index'; +import { Config } from '../config'; +import { + assertValidI18nConfig, + getCollectionItemPath, + getCollectionPath, + getCollectionTemplatePath, + getContentLocales, + getLocaleDirsToSkip, + getSingletonPath, + getSlugGlobForCollection, + isLocalized, + singletonDirHoldsOtherLocales, + 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'); + }); +}); + +const unprefixedConfig = config({ + storage: { kind: 'local' }, + i18n: { + locales: { en: 'English', fr: 'Français', de: 'Deutsch' }, + defaultLocale: 'en', + prefixDefaultLocale: false, + }, + collections: { + docs: collection({ + label: 'Docs', + localized: true, + path: 'src/content/docs/{locale}/**', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts/{locale}/*', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + guides: collection({ + label: 'Guides', + localized: true, + path: 'content/{locale}/guides/**', + 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('prefixDefaultLocale: false', () => { + const i18n = unprefixedConfig.i18n; + test('the default locale loses the token segment', () => { + expect(substituteLocale('content/posts/{locale}', 'en', i18n)).toBe( + 'content/posts' + ); + expect(substituteLocale('content/{locale}/posts', 'en', i18n)).toBe( + 'content/posts' + ); + }); + test('other locales keep their directory', () => { + expect(substituteLocale('content/posts/{locale}', 'fr', i18n)).toBe( + 'content/posts/fr' + ); + expect(substituteLocale('content/{locale}/posts', 'de', i18n)).toBe( + 'content/de/posts' + ); + }); + test('the default locale keeps its directory when the flag is unset or true', () => { + expect(substituteLocale('content/posts/{locale}', 'en')).toBe( + 'content/posts/en' + ); + expect( + substituteLocale('content/posts/{locale}', 'en', { + locales: { en: 'English' }, + defaultLocale: 'en', + prefixDefaultLocale: true, + }) + ).toBe('content/posts/en'); + }); + test('collection and singleton paths resolve to the unprefixed default', () => { + expect(getCollectionPath(unprefixedConfig, 'docs', 'en')).toBe( + 'src/content/docs' + ); + expect(getCollectionPath(unprefixedConfig, 'docs', 'fr')).toBe( + 'src/content/docs/fr' + ); + expect(getSingletonPath(unprefixedConfig, 'homepage', 'en')).toBe( + 'content/homepage' + ); + expect(getSingletonPath(unprefixedConfig, 'homepage', 'fr')).toBe( + 'content/homepage/fr' + ); + }); + test('shared entries are untouched', () => { + expect(getCollectionPath(unprefixedConfig, 'tags', 'en')).toBe( + 'content/tags' + ); + expect(getSingletonPath(unprefixedConfig, 'settings', 'en')).toBe( + 'content/settings' + ); + }); +}); + +describe('the locale never resolves against a slug', () => { + test('a slug that looks like the token stays inside the collection', () => { + expect( + getCollectionItemPath(unprefixedConfig, 'posts', '{locale}', 'en') + ).toBe('content/posts/{locale}'); + expect(getCollectionItemPath(i18nConfig, 'posts', '{locale}', 'en')).toBe( + 'content/posts/en/{locale}' + ); + }); + test('a slug that looks like the token does not throw without i18n', () => { + const noI18n = config({ + storage: { kind: 'local' }, + collections: { + tags: collection({ + label: 'Tags', + path: 'content/tags/*', + slugField: 'name', + schema: { name: fields.text({ label: 'Name' }) }, + }), + }, + }) as Config; + expect(getCollectionItemPath(noI18n, 'tags', '{locale}')).toBe( + 'content/tags/{locale}' + ); + }); +}); + +describe('getLocaleDirsToSkip', () => { + test('skips the other locales when their directories nest inside the default', () => { + expect(getLocaleDirsToSkip(unprefixedConfig, 'docs', 'en')).toEqual( + new Set(['fr', 'de']) + ); + }); + test('skips nothing for the locales that have their own directory', () => { + expect(getLocaleDirsToSkip(unprefixedConfig, 'docs', 'fr')).toEqual( + new Set() + ); + }); + test('skips nothing when the locale directories are siblings', () => { + expect(getLocaleDirsToSkip(unprefixedConfig, 'guides', 'en')).toEqual( + new Set() + ); + }); + test('skips nothing for a `*` collection, where a slug cannot reach into a directory', () => { + expect(getLocaleDirsToSkip(unprefixedConfig, 'posts', 'en')).toEqual( + new Set() + ); + }); + test('skips nothing when the default locale is prefixed', () => { + expect(getLocaleDirsToSkip(i18nConfig, 'posts', 'en')).toEqual(new Set()); + }); + test('skips nothing for a shared collection', () => { + expect(getLocaleDirsToSkip(unprefixedConfig, 'tags', 'en')).toEqual( + new Set() + ); + }); +}); + +describe('singletonDirHoldsOtherLocales', () => { + test('is true for the unprefixed default', () => { + expect( + singletonDirHoldsOtherLocales(unprefixedConfig, 'homepage', 'en') + ).toBe(true); + }); + test('is false for the other locales and for shared singletons', () => { + expect( + singletonDirHoldsOtherLocales(unprefixedConfig, 'homepage', 'fr') + ).toBe(false); + expect( + singletonDirHoldsOtherLocales(unprefixedConfig, 'settings', 'en') + ).toBe(false); + }); + test('is false when the default locale is prefixed', () => { + expect(singletonDirHoldsOtherLocales(i18nConfig, 'homepage', 'en')).toBe( + false + ); + }); +}); + +describe('assertValidI18nConfig with prefixDefaultLocale: false', () => { + test('accepts the valid config', () => { + expect(() => assertValidI18nConfig(unprefixedConfig)).not.toThrow(); + }); + test('rejects a token that is not a whole path segment', () => { + const bad = config({ + storage: { kind: 'local' }, + i18n: { + locales: { en: 'English', fr: 'Français' }, + defaultLocale: 'en', + prefixDefaultLocale: false, + }, + 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( + /must be a whole path segment/ + ); + }); + test('rejects a path that would be empty for the default locale', () => { + const bad = config({ + storage: { kind: 'local' }, + i18n: { + locales: { en: 'English', fr: 'Français' }, + defaultLocale: 'en', + prefixDefaultLocale: false, + }, + collections: { + posts: collection({ + label: 'Posts', + localized: true, + path: '{locale}/*', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, + }) as Config; + expect(() => assertValidI18nConfig(bad)).toThrow(/empty path/); + }); + test('rejects a singleton that would be empty for the default locale', () => { + const bad = config({ + storage: { kind: 'local' }, + i18n: { + locales: { en: 'English', fr: 'Français' }, + defaultLocale: 'en', + prefixDefaultLocale: false, + }, + singletons: { + homepage: singleton({ + label: 'Homepage', + localized: true, + path: '{locale}', + schema: { headline: fields.text({ label: 'Headline' }) }, + }), + }, + }) as Config; + expect(() => assertValidI18nConfig(bad)).toThrow(/empty path/); + }); + test('a partial-segment token is still allowed when the default locale is prefixed', () => { + const ok = config({ + storage: { kind: 'local' }, + i18n: { locales: { en: 'English' }, defaultLocale: 'en' }, + collections: { + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts-{locale}/*', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, + }) as Config; + expect(() => assertValidI18nConfig(ok)).not.toThrow(); + }); +}); + +describe('locale codes must be usable as a directory name', () => { + const withCode = (code: string) => + config({ + storage: { kind: 'local' }, + i18n: { locales: { en: 'English', [code]: 'Bad' }, defaultLocale: 'en' }, + }) as Config; + test('rejects codes that are not a single path segment', () => { + expect(() => assertValidI18nConfig(withCode('a/b'))).toThrow( + /single path segment/ + ); + expect(() => assertValidI18nConfig(withCode('..'))).toThrow( + /single path segment/ + ); + expect(() => assertValidI18nConfig(withCode(''))).toThrow( + /single path segment/ + ); + }); +}); + +const templateConfig = config({ + storage: { kind: 'local' }, + i18n: { + locales: { en: 'English', fr: 'Français' }, + defaultLocale: 'en', + }, + collections: { + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts/{locale}/*', + template: 'content/posts/{locale}/_template', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + shared: collection({ + label: 'Shared', + localized: true, + path: 'content/shared/{locale}/*', + template: 'content/_templates/shared', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + plain: collection({ + label: 'Plain', + path: 'content/plain/*', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, +}) as Config; + +describe('getCollectionTemplatePath', () => { + test('resolves the locale in a template path', () => { + expect(getCollectionTemplatePath(templateConfig, 'posts', 'en')).toBe( + 'content/posts/en/_template' + ); + expect(getCollectionTemplatePath(templateConfig, 'posts', 'fr')).toBe( + 'content/posts/fr/_template' + ); + }); + test('leaves a template without the token shared across locales', () => { + expect(getCollectionTemplatePath(templateConfig, 'shared', 'en')).toBe( + 'content/_templates/shared' + ); + expect(getCollectionTemplatePath(templateConfig, 'shared', 'fr')).toBe( + 'content/_templates/shared' + ); + }); + test('is undefined when the collection has no template', () => { + expect(getCollectionTemplatePath(templateConfig, 'plain', 'en')).toBe( + undefined + ); + }); + test('drops the segment for the unprefixed default locale', () => { + const unprefixed = config({ + storage: { kind: 'local' }, + i18n: { + locales: { en: 'English', fr: 'Français' }, + defaultLocale: 'en', + prefixDefaultLocale: false, + }, + collections: { + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts/{locale}/*', + template: 'content/posts/{locale}/_template', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, + }) as Config; + expect(getCollectionTemplatePath(unprefixed, 'posts', 'en')).toBe( + 'content/posts/_template' + ); + expect(getCollectionTemplatePath(unprefixed, 'posts', 'fr')).toBe( + 'content/posts/fr/_template' + ); + }); +}); + +describe('assertValidI18nConfig checks templates', () => { + test('accepts a localized template and a shared one', () => { + expect(() => assertValidI18nConfig(templateConfig)).not.toThrow(); + }); + test('rejects the token in a template when i18n is not set', () => { + const bad = config({ + storage: { kind: 'local' }, + collections: { + posts: collection({ + label: 'Posts', + path: 'content/posts/*', + template: 'content/posts/{locale}/_template', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, + }) as Config; + expect(() => assertValidI18nConfig(bad)).toThrow( + /template but config.i18n is not set/ + ); + }); + test('rejects the token in a template when the collection is not localized', () => { + const bad = config({ + storage: { kind: 'local' }, + i18n: { locales: { en: 'English' }, defaultLocale: 'en' }, + collections: { + posts: collection({ + label: 'Posts', + path: 'content/posts/*', + template: 'content/posts/{locale}/_template', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, + }) as Config; + expect(() => assertValidI18nConfig(bad)).toThrow(/is not marked localized/); + }); + test('rejects a partial-segment token in a template when the default locale is unprefixed', () => { + const bad = config({ + storage: { kind: 'local' }, + i18n: { + locales: { en: 'English', fr: 'Français' }, + defaultLocale: 'en', + prefixDefaultLocale: false, + }, + collections: { + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts/{locale}/*', + template: 'content/posts/tpl-{locale}', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, + }) as Config; + expect(() => assertValidI18nConfig(bad)).toThrow( + /must be a whole path segment/ + ); + }); +}); diff --git a/packages/keystatic/src/app/path-utils.ts b/packages/keystatic/src/app/path-utils.ts index 97fb22caa..58549bede 100644 --- a/packages/keystatic/src/app/path-utils.ts +++ b/packages/keystatic/src/app/path-utils.ts @@ -1,4 +1,4 @@ -import { Config, DataFormat, Glob } from '../config'; +import { Config, DataFormat, Glob, I18nConfig } from '../config'; import { ComponentSchema } from '../form/api'; import { memoize } from './memoize'; @@ -6,6 +6,32 @@ export function fixPath(path: string) { return path.replace(/^\.?\/+/, '').replace(/\/*$/, ''); } +export const LOCALE_TOKEN = '{locale}'; + +export function substituteLocale( + path: string, + locale: string | undefined, + i18n?: I18nConfig +) { + 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.` + ); + } + if (i18n?.prefixDefaultLocale === false && locale === i18n.defaultLocale) { + return fixPath( + path + .split('/') + .filter(x => x !== LOCALE_TOKEN) + .join('/') + ); + } + return path.split(LOCALE_TOKEN).join(locale); +} + const collectionPath = /\/\*\*?(?:$|\/)/; function getConfiguredCollectionPath(config: Config, collection: string) { @@ -19,10 +45,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, config.i18n); } export function getCollectionFormat(config: Config, collection: string) { @@ -36,10 +66,11 @@ 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 suffix = getCollectionItemSlugSuffix(config, collection); + const basePath = getCollectionPath(config, collection, locale); + const suffix = getCollectionItemSlugSuffix(config, collection, locale); return `${basePath}/${slug}${suffix}`; } @@ -59,14 +90,34 @@ export function getSlugGlobForCollection( export function getCollectionItemSlugSuffix( config: Config, - collection: string + collection: string, + locale?: string ) { const configuredPath = getConfiguredCollectionPath(config, collection); - const path = fixPath(configuredPath.replace(/^[^*]+\*\*?/, '')); + const path = substituteLocale( + fixPath(configuredPath.replace(/^[^*]+\*\*?/, '')), + locale, + config.i18n + ); return path ? `/${path}` : ''; } -export function getSingletonPath(config: Config, singleton: string) { +export function getCollectionTemplatePath( + config: Config, + collection: string, + locale?: string +) { + const template = config.collections![collection].template; + return template === undefined + ? undefined + : substituteLocale(template, locale, config.i18n); +} + +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 +125,11 @@ export function getSingletonPath(config: Config, singleton: string) { }` ); } - return fixPath(config.singletons![singleton].path ?? singleton); + return substituteLocale( + fixPath(config.singletons![singleton].path ?? singleton), + locale, + config.i18n + ); } export function getDataFileExtension(formatInfo: FormatInfo) { @@ -212,3 +267,173 @@ 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 singletonDirHoldsOtherLocales( + config: Config, + singleton: string, + locale: string | undefined +): boolean { + const i18n = config.i18n; + if ( + !i18n || + i18n.prefixDefaultLocale !== false || + locale !== i18n.defaultLocale || + !isLocalized(config.singletons![singleton]) + ) { + return false; + } + const defaultPath = getSingletonPath(config, singleton, i18n.defaultLocale); + return Object.keys(i18n.locales).some( + code => + code !== i18n.defaultLocale && + getSingletonPath(config, singleton, code).startsWith(`${defaultPath}/`) + ); +} + +export function getLocaleDirsToSkip( + config: Config, + collection: string, + locale: string | undefined +): Set { + const i18n = config.i18n; + const skip = new Set(); + if ( + !i18n || + i18n.prefixDefaultLocale !== false || + locale !== i18n.defaultLocale || + !isLocalized(config.collections![collection]) || + getSlugGlobForCollection(config, collection) !== '**' + ) { + return skip; + } + const defaultPath = getCollectionPath(config, collection, i18n.defaultLocale); + for (const code of Object.keys(i18n.locales)) { + if (code === i18n.defaultLocale) continue; + const localePath = getCollectionPath(config, collection, code); + if (localePath.startsWith(`${defaultPath}/`)) { + skip.add(localePath.slice(defaultPath.length + 1).split('/')[0]); + } + } + return skip; +} + +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(', ')})` + ); + } + for (const code of localeCodes) { + if ( + code === '' || + code === '.' || + code === '..' || + code.includes('/') || + code.includes('\\') + ) { + throw new Error( + `config.i18n.locales contains the locale code "${code}" but locale codes must be a single path segment` + ); + } + } + } + + 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` + ); + } + if (hasToken && i18n?.prefixDefaultLocale === false) { + const prefix = + type === 'Collection' + ? fixPath(entry.path!.replace(/\*\*?.*$/, '')) + : fixPath(entry.path!); + checkTokenIsWholeSegment(`${type} "${key}"`, prefix); + } + }; + + const checkTokenIsWholeSegment = (label: string, path: string) => { + const segments = path.split('/'); + if (segments.some(x => x !== LOCALE_TOKEN && x.includes(LOCALE_TOKEN))) { + throw new Error( + `${label} uses the ${LOCALE_TOKEN} token inside a path segment, which cannot be removed when config.i18n.prefixDefaultLocale is false. The token must be a whole path segment.` + ); + } + if (segments.every(x => x === LOCALE_TOKEN)) { + throw new Error( + `${label} would have an empty path for the default locale when config.i18n.prefixDefaultLocale is false. Put the ${LOCALE_TOKEN} token below a directory.` + ); + } + }; + + const checkTemplate = ( + key: string, + entry: { template?: string; localized?: boolean } + ) => { + if (!entry.template?.includes(LOCALE_TOKEN)) return; + if (!i18n) { + throw new Error( + `Collection "${key}" uses the ${LOCALE_TOKEN} token in its template but config.i18n is not set` + ); + } + if (!isLocalized(entry)) { + throw new Error( + `Collection "${key}" uses the ${LOCALE_TOKEN} token in its template but is not marked localized` + ); + } + if (i18n.prefixDefaultLocale === false) { + checkTokenIsWholeSegment( + `Collection "${key}" template`, + fixPath(entry.template) + ); + } + }; + + for (const [key, collection] of Object.entries(config.collections ?? {})) { + checkEntry('Collection', key, collection); + checkTemplate(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..44d4c50b3 100644 --- a/packages/keystatic/src/app/shell/data.tsx +++ b/packages/keystatic/src/app/shell/data.tsx @@ -12,9 +12,9 @@ import { useState, } from 'react'; import { CombinedError, useQuery, UseQueryState } from 'urql'; -import { getSingletonPath } from '../path-utils'; +import { useActiveLocale } from './content-locale'; +import { getSingletonPathsForTreeKey, getTreeKey } from '../tree-key'; import { - getTreeNodeAtPath, treeEntriesToTreeNodes, TreeEntry, TreeNode, @@ -74,6 +74,7 @@ export function LocalAppShellProvider(props: { config: LocalConfig; children: ReactNode; }) { + const locale = useActiveLocale(); const [currentTreeSha, setCurrentTreeSha] = useState('initial'); const tree = useData( @@ -106,8 +107,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 +276,7 @@ export function GitHubAppShellProvider(props: { children: ReactNode; }) { const router = useRouter(); + const locale = useActiveLocale(); const { data, error } = useContext(GitHubAppShellDataContext)!; let repo: | FragmentData @@ -362,8 +364,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 +734,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 +744,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,10 +780,10 @@ function getChangedData( ), singletons: new Set( Object.keys(config.singletons ?? {}).filter(singleton => { - const singletonPath = getSingletonPath(config, singleton); + const paths = getSingletonPathsForTreeKey(config, singleton, locale); return ( - getTreeNodeAtPath(trees.current.tree, singletonPath)?.entry.sha !== - getTreeNodeAtPath(trees.default.tree, singletonPath)?.entry.sha + getTreeKey(paths, trees.current.tree) !== + getTreeKey(paths, trees.default.tree) ); }) ), 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 20d2b3380..7502351ee 100644 --- a/packages/keystatic/src/app/shell/sidebar/index.tsx +++ b/packages/keystatic/src/app/shell/sidebar/index.tsx @@ -39,6 +39,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'; @@ -82,6 +83,7 @@ export function SidebarPanel() { + @@ -227,6 +229,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/slugs.tsx b/packages/keystatic/src/app/slugs.tsx index 2f829a355..d33795204 100644 --- a/packages/keystatic/src/app/slugs.tsx +++ b/packages/keystatic/src/app/slugs.tsx @@ -1,8 +1,9 @@ import { useMemo } from 'react'; -import { getSlugGlobForCollection } from './path-utils'; +import { getLocaleDirsToSkip, getSlugGlobForCollection } from './path-utils'; import { useSlugsInCollection } from './useSlugsInCollection'; import { SlugFieldInfo } from '../form/fields/text/path-slug-context'; import { useConfig } from './shell/context'; +import { useActiveLocale } from './shell/content-locale'; export function useSlugFieldInfo( collection: string, @@ -10,6 +11,7 @@ export function useSlugFieldInfo( ): SlugFieldInfo { const config = useConfig(); const allSlugs = useSlugsInCollection(collection); + const locale = useActiveLocale(); return useMemo((): SlugFieldInfo => { const slugs = new Set(allSlugs); @@ -21,6 +23,7 @@ export function useSlugFieldInfo( field: collectionConfig.slugField, slugs, glob: getSlugGlobForCollection(config, collection), + reservedLocaleDirs: getLocaleDirsToSkip(config, collection, locale), }; - }, [allSlugs, collection, config, slugToExclude]); + }, [allSlugs, collection, config, locale, slugToExclude]); } diff --git a/packages/keystatic/src/app/tree-key.tsx b/packages/keystatic/src/app/tree-key.tsx index 6c7322883..4f9f7aa1d 100644 --- a/packages/keystatic/src/app/tree-key.tsx +++ b/packages/keystatic/src/app/tree-key.tsx @@ -1,7 +1,16 @@ import { assertNever } from 'emery'; -import { ComponentSchema } from '..'; -import { fixPath, FormatInfo, getDataFileExtension } from './path-utils'; +import { ComponentSchema, Config } from '..'; +import { object } from '../form/fields/object'; +import { + fixPath, + FormatInfo, + getDataFileExtension, + getEntryDataFilepath, + getSingletonFormat, + getSingletonPath, + singletonDirHoldsOtherLocales, +} from './path-utils'; import { getTreeNodeAtPath, TreeNode } from './trees'; function collectDirectoriesUsedInSchemaInner( @@ -81,6 +90,25 @@ export function getDirectoriesForTreeKey( return directories; } +export function getSingletonPathsForTreeKey( + config: Config, + singleton: string, + locale: string | undefined +) { + const singletonPath = getSingletonPath(config, singleton, locale); + const format = getSingletonFormat(config, singleton); + const paths = [getEntryDataFilepath(singletonPath, format)]; + if (!singletonDirHoldsOtherLocales(config, singleton, locale)) { + paths.push(singletonPath); + } + paths.push( + ...collectDirectoriesUsedInSchema( + object(config.singletons![singleton].schema) + ) + ); + return paths; +} + export function getTreeKey(directories: string[], tree: Map) { return directories.map(d => getTreeNodeAtPath(tree, d)?.entry.sha).join('-'); } 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 fd2a11e15..0ff8f1bc8 100644 --- a/packages/keystatic/src/app/utils.ts +++ b/packages/keystatic/src/app/utils.ts @@ -7,6 +7,7 @@ import { getCollectionFormat, getCollectionItemPath, getCollectionItemSlugSuffix, + getLocaleDirsToSkip, getCollectionPath, getDataFileExtension, getSlugGlobForCollection, @@ -95,19 +96,24 @@ 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 directory: Map = + const collectionPath = getCollectionPath(config, collection, locale); + const localeDirsToSkip = getLocaleDirsToSkip(config, collection, locale); + const allChildren: Map = getTreeNodeAtPath(rootTree, collectionPath)?.children ?? new Map(); + const directory = localeDirsToSkip.size + ? new Map([...allChildren].filter(([key]) => !localeDirsToSkip.has(key))) + : allChildren; const entries: { key: string; slug: string; sha: string }[] = []; const directoriesUsedInSchema = [...collectDirectoriesUsedInSchema(schema)]; - const suffix = getCollectionItemSlugSuffix(config, collection); + const suffix = getCollectionItemSlugSuffix(config, collection, locale); const possibleEntries = new Map(directory); if (glob === '**') { const handleDirectory = (dir: Map, prefix: string) => { @@ -126,7 +132,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 +150,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 +172,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..1bda73634 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -17,12 +17,20 @@ export type Format = }; export type EntryLayout = 'content' | 'form'; export type Glob = '*' | '**'; + +export type I18nConfig = { + locales: Record; + defaultLocale: string; + prefixDefaultLocale?: boolean; +}; + 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 +44,7 @@ export type Collection< export type Singleton> = { label: string; path?: string; + localized?: boolean; entryLayout?: EntryLayout; format?: Format; previewUrl?: string; @@ -44,6 +53,7 @@ export type Singleton> = { type CommonConfig = { locale?: Locale; + i18n?: I18nConfig; cloud?: { project: string }; ui?: UserInterface; }; diff --git a/packages/keystatic/src/form/fields/text/path-slug-context.tsx b/packages/keystatic/src/form/fields/text/path-slug-context.tsx index ec5907536..d92746e27 100644 --- a/packages/keystatic/src/form/fields/text/path-slug-context.tsx +++ b/packages/keystatic/src/form/fields/text/path-slug-context.tsx @@ -108,6 +108,7 @@ export type SlugFieldInfo = { field: string; slugs: Set; glob: Glob; + reservedLocaleDirs?: Set; }; export const SlugFieldContext = createContext( diff --git a/packages/keystatic/src/form/fields/text/validateText.tsx b/packages/keystatic/src/form/fields/text/validateText.tsx index e50b04a4e..64b6417e8 100644 --- a/packages/keystatic/src/form/fields/text/validateText.tsx +++ b/packages/keystatic/src/form/fields/text/validateText.tsx @@ -5,7 +5,9 @@ export function validateText( min: number, max: number, fieldLabel: string, - slugInfo: { slugs: Set; glob: Glob } | undefined, + slugInfo: + | { slugs: Set; glob: Glob; reservedLocaleDirs?: Set } + | undefined, pattern: { regex: RegExp; message?: string } | undefined ) { if (val.length < min) { @@ -48,6 +50,10 @@ export function validateText( if (/^\s|\s$/.test(val)) { return `${fieldLabel} must not start or end with spaces`; } + const localeDir = val.split('/')[0]; + if (slugInfo.reservedLocaleDirs?.has(localeDir)) { + return `${fieldLabel} must not start with "${localeDir}" because that's where another language's content is stored`; + } if (slugInfo.slugs.has(val)) { return `${fieldLabel} must be unique`; } 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..de3de62fd 100644 --- a/packages/keystatic/src/reader/generic.ts +++ b/packages/keystatic/src/reader/generic.ts @@ -14,6 +14,7 @@ import { getCollectionPath, getDataFileExtension, getEntryDataFilepath, + getLocaleDirsToSkip, getSingletonFormat, getSingletonPath, getSlugGlobForCollection, @@ -198,11 +199,13 @@ export type MinimalFs = { async function getAllEntries( parent: string, - fsReader: MinimalFs + fsReader: MinimalFs, + skip?: Set ): Promise<{ entry: DirEntry; name: string }[]> { return ( await Promise.all( (await fsReader.readdir(parent)).map(async dirent => { + if (skip?.has(dirent.name)) return []; const name = `${parent}${dirent.name}`; const entry = { entry: dirent, name }; if (dirent.kind === 'directory') { @@ -219,15 +222,18 @@ const listCollection = cache(async function listCollection( glob: Glob, formatInfo: FormatInfo, extension: string, - fsReader: MinimalFs + fsReader: MinimalFs, + // a comma separated string rather than a Set so that `cache` can key on it + localeDirsToSkip: string ) { + const skip = new Set(localeDirsToSkip ? localeDirsToSkip.split(',') : []); const entries: { entry: DirEntry; name: string }[] = glob === '*' ? (await fsReader.readdir(collectionPath)).map(entry => ({ entry, name: entry.name, })) - : (await getAllEntries(`${collectionPath}/`, fsReader)).map(x => ({ + : (await getAllEntries(`${collectionPath}/`, fsReader, skip)).map(x => ({ entry: x.entry, name: x.name.slice(collectionPath.length + 1), })); @@ -259,31 +265,42 @@ 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); const extension = getDataFileExtension(formatInfo); + const localeDirsToSkip = getLocaleDirsToSkip(config, collection, locale); + const localeDirsToSkipKey = [...localeDirsToSkip].sort().join(','); const read: CollectionReader['read'] = (slug, ...args) => - readItem( - schema, + localeDirsToSkip.has(slug.split('/')[0]) + ? Promise.resolve(null) + : readItem( + schema, + formatInfo, + getCollectionItemPath(config, collection, slug, locale), + args[0]?.resolveLinkedFiles, + `"${slug}" in collection "${collection}"`, + fsReader, + slug, + collectionConfig.slugField, + glob + ); + + const list = () => + listCollection( + getCollectionPath(config, collection, locale), + glob, formatInfo, - getCollectionItemPath(config, collection, slug), - args[0]?.resolveLinkedFiles, - `"${slug}" in collection "${collection}"`, + extension, fsReader, - slug, - collectionConfig.slugField, - glob + localeDirsToSkipKey ); - const list = () => - listCollection(collectionPath, glob, formatInfo, extension, fsReader); - return { read, readOrThrow: async (...args) => { @@ -403,16 +420,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..7d753db40 --- /dev/null +++ b/packages/keystatic/test/reader-i18n.test.tsx @@ -0,0 +1,361 @@ +/** @jest-environment node */ +import { expect, test, describe } from '@jest/globals'; +import { config, collection, singleton, fields } from '../src'; +import { createReader } from '../src/reader'; +import { getAllowedDirectories, readToDirEntries } from '../src/api/read-local'; +import { getEntriesInCollectionWithTreeKey } from '../src/app/utils'; +import { treeEntriesToTreeNodes } from '../src/app/trees'; +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', + ]) + ); + }); +}); + +const unprefixedConfig = config({ + storage: { kind: 'local' }, + i18n: { + locales: { en: 'English', fr: 'Français' }, + defaultLocale: 'en', + prefixDefaultLocale: false, + }, + collections: { + docs: collection({ + label: 'Docs', + localized: true, + path: 'src/content/docs/{locale}/**', + slugField: 'title', + schema: { + title: fields.text({ label: 'Title' }), + body: fields.text({ label: 'Body' }), + }, + }), + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts/{locale}/*', + slugField: 'title', + schema: { + title: fields.text({ label: 'Title' }), + body: fields.text({ label: 'Body' }), + }, + }), + }, + singletons: { + homepage: singleton({ + label: 'Homepage', + localized: true, + path: 'content/homepage/{locale}', + schema: { headline: fields.text({ label: 'Headline' }) }, + }), + }, +}); + +function unprefixedFixture() { + return testdir({ + 'src/content/docs/index.yaml': 'body: EN index\n', + 'src/content/docs/guides/intro.yaml': 'body: EN intro\n', + 'src/content/docs/fr/index.yaml': 'body: FR index\n', + 'src/content/docs/fr/guides/intro.yaml': 'body: FR intro\n', + 'content/posts/hello.yaml': 'body: EN body\n', + 'content/posts/fr/hello.yaml': 'body: FR body\n', + 'content/homepage.yaml': 'headline: EN home\n', + 'content/homepage/fr.yaml': 'headline: FR home\n', + }); +} + +describe('prefixDefaultLocale: false', () => { + test('the default locale reads from the collection root', async () => { + const dir = await unprefixedFixture(); + const en = createReader(dir, unprefixedConfig, { locale: 'en' }); + + expect((await en.collections.docs.list()).sort()).toEqual([ + 'guides/intro', + 'index', + ]); + expect(await en.collections.docs.read('index')).toMatchObject({ + body: 'EN index', + }); + }); + + test('a nested `**` listing does not pick up the other locales', async () => { + const dir = await unprefixedFixture(); + const en = createReader(dir, unprefixedConfig, { locale: 'en' }); + + const slugs = await en.collections.docs.list(); + expect(slugs).not.toContain('fr/index'); + expect(slugs).not.toContain('fr/guides/intro'); + }); + + test('the other locales read from their own directory', async () => { + const dir = await unprefixedFixture(); + const fr = createReader(dir, unprefixedConfig, { locale: 'fr' }); + + expect((await fr.collections.docs.list()).sort()).toEqual([ + 'guides/intro', + 'index', + ]); + expect(await fr.collections.docs.read('guides/intro')).toMatchObject({ + body: 'FR intro', + }); + }); + + test('reading across a locale directory returns null rather than the other language', async () => { + const dir = await unprefixedFixture(); + const en = createReader(dir, unprefixedConfig, { locale: 'en' }); + + expect(await en.collections.docs.read('fr/index')).toBe(null); + expect(await en.collections.docs.read('fr/guides/intro')).toBe(null); + }); + + test('a `*` collection keeps the default at the root', async () => { + const dir = await unprefixedFixture(); + const en = createReader(dir, unprefixedConfig, { locale: 'en' }); + const fr = createReader(dir, unprefixedConfig, { locale: 'fr' }); + + expect(await en.collections.posts.list()).toEqual(['hello']); + expect(await en.collections.posts.read('hello')).toMatchObject({ + body: 'EN body', + }); + expect(await fr.collections.posts.read('hello')).toMatchObject({ + body: 'FR body', + }); + }); + + test('singletons resolve to an unprefixed file for the default locale', async () => { + const dir = await unprefixedFixture(); + const en = createReader(dir, unprefixedConfig, { locale: 'en' }); + const fr = createReader(dir, unprefixedConfig, { locale: 'fr' }); + + expect(await en.singletons.homepage.read()).toMatchObject({ + headline: 'EN home', + }); + expect(await fr.singletons.homepage.read()).toMatchObject({ + headline: 'FR home', + }); + }); + + test('getAllowedDirectories covers the unprefixed default and every other locale', () => { + const dirs = getAllowedDirectories(unprefixedConfig as any); + expect(dirs).toContain('src/content/docs'); + expect(dirs).toContain('src/content/docs/fr'); + expect(dirs).not.toContain(''); + }); +}); + +describe('the Admin UI listing matches the reader', () => { + test('a `**` collection hides the other locales from the default', async () => { + const dir = await unprefixedFixture(); + const tree = treeEntriesToTreeNodes(await readToDirEntries(dir)); + + const en = getEntriesInCollectionWithTreeKey( + unprefixedConfig as any, + 'docs', + tree, + 'en' + ).map(x => x.slug); + const fr = getEntriesInCollectionWithTreeKey( + unprefixedConfig as any, + 'docs', + tree, + 'fr' + ).map(x => x.slug); + + expect(en.sort()).toEqual(['guides/intro', 'index']); + expect(fr.sort()).toEqual(['guides/intro', 'index']); + expect(en.filter(x => x.startsWith('fr/'))).toEqual([]); + }); + + test('the default locale still sees a `*` collection at the root', async () => { + const dir = await unprefixedFixture(); + const tree = treeEntriesToTreeNodes(await readToDirEntries(dir)); + + expect( + getEntriesInCollectionWithTreeKey( + unprefixedConfig as any, + 'posts', + tree, + 'en' + ).map(x => x.slug) + ).toEqual(['hello']); + }); +}); + +const templateConfig = config({ + storage: { kind: 'local' }, + i18n: { locales: { en: 'English', fr: 'Français' }, defaultLocale: 'en' }, + collections: { + posts: collection({ + label: 'Posts', + localized: true, + path: 'content/posts/{locale}/*', + template: 'content/posts/{locale}/_template', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + shared: collection({ + label: 'Shared', + localized: true, + path: 'content/shared/{locale}/*', + template: 'content/_templates/shared', + slugField: 'title', + schema: { title: fields.text({ label: 'Title' }) }, + }), + }, +}); + +describe('templates resolve the locale', () => { + test('a localized template is allowed for every locale, never as a raw token', () => { + const dirs = getAllowedDirectories(templateConfig as any); + expect(dirs).toContain('content/posts/en/_template'); + expect(dirs).toContain('content/posts/fr/_template'); + expect(dirs).not.toContain('content/posts/{locale}/_template'); + }); + test('a template without the token is shared across locales', () => { + const dirs = getAllowedDirectories(templateConfig as any); + expect(dirs.filter(x => x === 'content/_templates/shared')).toEqual([ + 'content/_templates/shared', + ]); + }); +});