From eea7be7eb68cce94bcb92107c5a9af23b3b2a9cf Mon Sep 17 00:00:00 2001 From: tihu220 Date: Thu, 17 Sep 2026 01:23:53 +0800 Subject: [PATCH 1/2] feat(collection): add slugFields for multi-segment collection paths Implements #340 using the static, deterministic API proposed in the issue: collections can declare multiple slug fields that map onto the star segments of the collection path in order. collection({ path: 'src/content/blog/*/*', slugFields: ['locale', 'title'], slugField: 'title', schema: { locale: fields.text({ label: 'Locale' }), title: fields.slug({ name: { label: 'Title' } }) }, }) An entry with locale `en` and title slug `my-post` is stored at `src/content/blog/en/my-post`. The entry's slug is the segments joined with a slash (`en/my-post`), which keeps tree listing, routing, the reader and the GitHub/local storage backends deterministic - no callbacks or non-serialisable config. - fully backwards compatible: single `slugField` collections behave exactly as before - config validation: the number of star segments must match `slugFields.length`, every entry must be a slug field and the last entry must be the `slugField` - composite slug validation: secondary slug fields validate as slug segments while the primary field is checked for uniqueness against the full composite slug - covered by new unit tests in `slug-fields.test.ts` --- docs/src/content/pages/collections.mdoc | 19 ++ packages/keystatic/src/app/CollectionPage.tsx | 16 +- packages/keystatic/src/app/ItemPage.tsx | 30 ++- packages/keystatic/src/app/SingletonPage.tsx | 4 +- packages/keystatic/src/app/create-item.tsx | 28 ++- packages/keystatic/src/app/duplicate-slug.ts | 5 +- packages/keystatic/src/app/entry-clipboard.ts | 16 +- packages/keystatic/src/app/path-utils.ts | 62 +++++- .../keystatic/src/app/slug-fields.test.ts | 209 ++++++++++++++++++ packages/keystatic/src/app/slugs.tsx | 10 +- packages/keystatic/src/app/updating.tsx | 6 +- packages/keystatic/src/app/useHasChanged.ts | 10 +- packages/keystatic/src/app/useItemData.ts | 21 +- packages/keystatic/src/app/utils.ts | 52 +++-- packages/keystatic/src/config.tsx | 20 +- packages/keystatic/src/form/api.tsx | 4 +- packages/keystatic/src/form/errors.ts | 53 ++++- .../keystatic/src/form/fields/array/ui.tsx | 4 +- .../keystatic/src/form/fields/slug/index.tsx | 4 +- .../keystatic/src/form/fields/text/index.tsx | 2 +- .../form/fields/text/path-slug-context.tsx | 2 + .../src/form/fields/text/validateText.tsx | 6 +- .../keystatic/src/form/serialize-props.ts | 7 +- packages/keystatic/src/reader/generic.ts | 59 +++-- 24 files changed, 549 insertions(+), 100 deletions(-) create mode 100644 packages/keystatic/src/app/slug-fields.test.ts diff --git a/docs/src/content/pages/collections.mdoc b/docs/src/content/pages/collections.mdoc index 24adc2bed..3372df1c5 100644 --- a/docs/src/content/pages/collections.mdoc +++ b/docs/src/content/pages/collections.mdoc @@ -103,6 +103,25 @@ testimonials: collection({ }), ``` +### Multiple slug fields + +`slugFields` — defines multiple fields to make up the slug of each item. Each entry is mapped to a `*` segment of the collection `path` in order, and the last entry must be the `slugField`. Entries are stored in nested directories, one per slug field. + +```typescript +blog: collection({ + label: 'Blog', + path: 'src/content/blog/*/*', + slugFields: ['locale', 'title'], + schema: { + locale: fields.text({ label: 'Locale' }), + title: fields.slug({ name: { label: 'Title' } }), + }, + slugField: 'title', +}), +``` + +An entry with the locale `en` and the title `hello-world` is stored at `src/content/blog/en/hello-world`. + ### Template `template` — the path to a content file (existing collection entry or "template") to use as a starting point for new entries. diff --git a/packages/keystatic/src/app/CollectionPage.tsx b/packages/keystatic/src/app/CollectionPage.tsx index 1604f34cc..6b53067fd 100644 --- a/packages/keystatic/src/app/CollectionPage.tsx +++ b/packages/keystatic/src/app/CollectionPage.tsx @@ -56,6 +56,7 @@ import { getCollectionPath, getEntriesInCollectionWithTreeKey, getEntryDataFilepath, + getSlugFieldsForCollection, getSlugGlobForCollection, isLocalConfig, } from './utils'; @@ -357,11 +358,16 @@ function CollectionTable( }) ); const glob = getSlugGlobForCollection(props.config, props.collection); + const slugFields = getSlugFieldsForCollection( + props.config, + props.collection + ); const rootSchema = { kind: 'object' as const, fields: collection.schema }; const parsedEntries = new Map>(); for (const [slug, dataFile] of entries) { try { const { loaded } = loadDataFile(dataFile, formatInfo); + const slugSegments = slug.split('/'); const validated = parseProps( rootSchema, loaded, @@ -378,14 +384,18 @@ function CollectionTable( return; } if (path.length === 1 && slug !== undefined) { - if (path[0] === collection.slugField) { + const slugFieldIndex = slugFields.indexOf(path[0] as string); + if (slugFieldIndex !== -1) { if (schema.formKind !== 'slug') { throw new Error( - `Slug field ${collection.slugField} is not a slug field` + `Slug field ${path[0]} is not a slug field` ); } return schema.reader.parseWithSlug(value, { - slug, + slug: + slugFields.length === 1 + ? slug + : slugSegments[slugFieldIndex] ?? '', glob, }); } diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx index f19cc2de7..c8feb3ac6 100644 --- a/packages/keystatic/src/app/ItemPage.tsx +++ b/packages/keystatic/src/app/ItemPage.tsx @@ -72,6 +72,7 @@ import { import { useHasChanged } from './useHasChanged'; import { parseEntry, useItemData } from './useItemData'; import { + collectionSlugFields, getBranchPrefix, getCollection, getCollectionFormat, @@ -192,14 +193,14 @@ function ItemPageInner( const onCopy = useEventCallback(() => { copyEntryToClipboard(props.state, formatInfo, collectionConfig.schema, { - field: collectionConfig.slugField, + fields: collectionSlugFields(collectionConfig), value: getSlugFromState(collectionConfig, props.state), }); }); const onPaste = useEventCallback(async () => { const entry = await getPastedEntry(formatInfo, collectionConfig.schema, { - field: collectionConfig.slugField, + fields: collectionSlugFields(collectionConfig), slug: getSlugFromState(collectionConfig, props.state), }); if (entry) { @@ -417,7 +418,7 @@ function LocalItemPage( initialState, schema, state, - slugField: collectionConfig.slugField, + slugFields: collectionSlugFields(collectionConfig), }); const slug = getSlugFromState(collectionConfig, state); @@ -431,7 +432,7 @@ function LocalItemPage( basePath: futureBasePath, format: formatInfo, currentLocalTreeKey: localTreeKey, - slug: { field: collectionConfig.slugField, value: slug }, + slug: { fields: collectionSlugFields(collectionConfig), value: slug }, }); useEffect(() => { @@ -441,7 +442,10 @@ function LocalItemPage( basePath: futureBasePath, format: getCollectionFormat(config, collection), schema: collectionConfig.schema, - slug: { field: collectionConfig.slugField, value: slug }, + slug: { + fields: collectionSlugFields(collectionConfig), + value: slug, + }, state, }); const files = new Map(serialized.map(x => [x.path, x.contents])); @@ -502,7 +506,7 @@ function CollabItemPage(props: ItemPageProps & { map: Y.Map }) { initialState, schema, state, - slugField: collectionConfig.slugField, + slugFields: collectionSlugFields(collectionConfig), }); const futureBasePath = getCollectionItemPath(config, collection, slug); @@ -514,7 +518,7 @@ function CollabItemPage(props: ItemPageProps & { map: Y.Map }) { basePath: futureBasePath, format: formatInfo, currentLocalTreeKey: localTreeKey, - slug: { field: collectionConfig.slugField, value: slug }, + slug: { fields: collectionSlugFields(collectionConfig), value: slug }, }); const update = useEventCallback(_update); @@ -850,8 +854,11 @@ function ItemPageOuterWrapper(props: ItemPageWrapperProps) { ); const slugInfo = useMemo(() => { - return { slug: props.itemSlug, field: collectionConfig.slugField }; - }, [collectionConfig.slugField, props.itemSlug]); + return { + slug: props.itemSlug, + fields: collectionSlugFields(collectionConfig), + }; + }, [collectionConfig, props.itemSlug]); const draftData = useData( useCallback(async () => { @@ -872,7 +879,10 @@ function ItemPageOuterWrapper(props: ItemPageWrapperProps) { ), format: getCollectionFormat(props.config, props.collection), schema: collectionConfig.schema, - slug: { field: collectionConfig.slugField, slug: stored.slug }, + slug: { + fields: collectionSlugFields(collectionConfig), + slug: stored.slug, + }, }, stored.files ); diff --git a/packages/keystatic/src/app/SingletonPage.tsx b/packages/keystatic/src/app/SingletonPage.tsx index e64eef51e..c01805d37 100644 --- a/packages/keystatic/src/app/SingletonPage.tsx +++ b/packages/keystatic/src/app/SingletonPage.tsx @@ -376,7 +376,7 @@ function LocalSingletonPage( const isCreating = initialState === null; const hasChanged = - useHasChanged({ initialState, state, schema, slugField: undefined }) || + useHasChanged({ initialState, state, schema, slugFields: undefined }) || isCreating; useEffect(() => { @@ -477,7 +477,7 @@ function CollabSingletonPage( const isCreating = initialState === null; const hasChanged = - useHasChanged({ initialState, state, schema, slugField: undefined }) || + useHasChanged({ initialState, state, schema, slugFields: undefined }) || isCreating; const formatInfo = getSingletonFormat(config, singleton); diff --git a/packages/keystatic/src/app/create-item.tsx b/packages/keystatic/src/app/create-item.tsx index 6f8d2417e..39974eafe 100644 --- a/packages/keystatic/src/app/create-item.tsx +++ b/packages/keystatic/src/app/create-item.tsx @@ -38,6 +38,7 @@ import { parseEntry, useItemData } from './useItemData'; import { useHasChanged } from './useHasChanged'; import { useYJsValue } from './useYJsValue'; import { + collectionSlugFields, getCollectionFormat, getCollectionItemPath, getSlugFromState, @@ -95,7 +96,10 @@ function CreateItemWrapper(props: { ), format, schema: collectionConfig.schema, - slug: { field: collectionConfig.slugField, slug: stored.slug }, + slug: { + fields: collectionSlugFields(collectionConfig), + slug: stored.slug, + }, }, stored.files ); @@ -111,10 +115,13 @@ function CreateItemWrapper(props: { const slug = useMemo(() => { if (duplicateSlug) { - return { field: collectionConfig.slugField, slug: duplicateSlug }; + return { + fields: collectionSlugFields(collectionConfig), + slug: duplicateSlug, + }; } if (collectionConfig.template) { - return { field: collectionConfig.slugField, slug: '' }; + return { fields: collectionSlugFields(collectionConfig), slug: '' }; } }, [duplicateSlug, collectionConfig]); @@ -288,7 +295,7 @@ function CreateItemLocal(props: { schema: collectionConfig.schema, format: formatInfo, currentLocalTreeKey: undefined, - slug: { field: collectionConfig.slugField, value: slug }, + slug: { fields: collectionSlugFields(collectionConfig), value: slug }, }); const createItem = useEventCallback(_createItem); @@ -296,7 +303,7 @@ function CreateItemLocal(props: { initialState, schema, state, - slugField: collectionConfig.slugField, + slugFields: collectionSlugFields(collectionConfig), }); const hasCreated = createResult.kind === 'updated' || createResult.kind === 'loading'; @@ -314,7 +321,10 @@ function CreateItemLocal(props: { basePath, format: formatInfo, schema: collectionConfig.schema, - slug: { field: collectionConfig.slugField, value: slug }, + slug: { + fields: collectionSlugFields(collectionConfig), + value: slug, + }, state, }); const files = new Map(serialized.map(x => [x.path, x.contents])); @@ -381,7 +391,7 @@ function CreateItemCollab(props: { schema: collectionConfig.schema, format: formatInfo, currentLocalTreeKey: undefined, - slug: { field: collectionConfig.slugField, value: slug }, + slug: { fields: collectionSlugFields(collectionConfig), value: slug }, }); const createItem = useEventCallback(_createItem); @@ -463,14 +473,14 @@ function CreateItemInner(props: { const onCopy = () => { copyEntryToClipboard(props.state, formatInfo, collectionConfig.schema, { - field: collectionConfig.slugField, + fields: collectionSlugFields(collectionConfig), value: getSlugFromState(collectionConfig, props.state), }); }; const onPaste = async () => { const entry = await getPastedEntry(formatInfo, collectionConfig.schema, { - field: collectionConfig.slugField, + fields: collectionSlugFields(collectionConfig), slug: getSlugFromState(collectionConfig, props.state), }); if (entry) { diff --git a/packages/keystatic/src/app/duplicate-slug.ts b/packages/keystatic/src/app/duplicate-slug.ts index 682c11265..d886c99c6 100644 --- a/packages/keystatic/src/app/duplicate-slug.ts +++ b/packages/keystatic/src/app/duplicate-slug.ts @@ -12,9 +12,8 @@ export function useDuplicateSlug( // but if it fails a user can change it before creating // (e.g. potentially it's not just a text field so appending -copy might not work) const { slugField } = collectionConfig; - const defaultSlugVal = duplicateInitalState[collectionConfig.slugField]; - const slugFieldSchema = - collectionConfig.schema[collectionConfig.slugField]; + const defaultSlugVal = duplicateInitalState[slugField]; + const slugFieldSchema = collectionConfig.schema[slugField]; if ( slugFieldSchema.kind === 'form' && slugFieldSchema.formKind === 'slug' diff --git a/packages/keystatic/src/app/entry-clipboard.ts b/packages/keystatic/src/app/entry-clipboard.ts index 533b99657..7fe6412b5 100644 --- a/packages/keystatic/src/app/entry-clipboard.ts +++ b/packages/keystatic/src/app/entry-clipboard.ts @@ -22,7 +22,7 @@ function parseEntryFromHtml( html: string, format: FormatInfo, schema: Record, - slugField: string | undefined + slugFields: readonly string[] | undefined ) { const parsedHtml = new DOMParser().parseFromString(html, 'text/html'); const pre = parsedHtml.querySelector('pre'); @@ -38,8 +38,8 @@ function parseEntryFromHtml( dirpath: entryInfo.slug ?? 'entry', format, schema, - slug: slugField - ? { field: slugField, slug: entryInfo.slug ?? '' } + slug: slugFields + ? { fields: slugFields, slug: entryInfo.slug ?? '' } : undefined, requireFrontmatter: true, }, @@ -52,7 +52,7 @@ function parseEntryFromPlaintext( bytes: Uint8Array, format: FormatInfo, schema: Record, - slugInfo: { field: string; slug: string } | undefined + slugInfo: { fields: readonly string[]; slug: string } | undefined ) { try { const dirpath = slugInfo?.slug ?? 'entry'; @@ -66,7 +66,7 @@ function parseEntryFromPlaintext( export async function getPastedEntry( format: FormatInfo, schema: Record, - slugInfo: { field: string; slug: string } | undefined + slugInfo: { fields: readonly string[]; slug: string } | undefined ) { let clipboardItems: ClipboardItem[]; try { @@ -88,7 +88,7 @@ export async function getPastedEntry( if (item.types.includes('text/html')) { const html = await item.getType('text/html'); const text = await html.text(); - const entry = parseEntryFromHtml(text, format, schema, slugInfo?.field); + const entry = parseEntryFromHtml(text, format, schema, slugInfo?.fields); if (entry) { return entry; } @@ -114,7 +114,7 @@ function serializeEntryForClipboard( state: Record, format: FormatInfo, schema: Record, - slug: { field: string; value: string } | undefined + slug: { fields: readonly string[]; value: string } | undefined ) { const basePath = slug?.value ?? 'entry'; const files = serializeEntryToFiles({ @@ -146,7 +146,7 @@ export function copyEntryToClipboard( state: Record, format: FormatInfo, schema: Record, - slug: { field: string; value: string } | undefined + slug: { fields: readonly string[]; value: string } | undefined ) { const out = serializeEntryForClipboard(state, format, schema, slug); navigator.clipboard.write([ diff --git a/packages/keystatic/src/app/path-utils.ts b/packages/keystatic/src/app/path-utils.ts index 97fb22caa..d4f7b4772 100644 --- a/packages/keystatic/src/app/path-utils.ts +++ b/packages/keystatic/src/app/path-utils.ts @@ -19,6 +19,64 @@ function getConfiguredCollectionPath(config: Config, collection: string) { return path; } +function countStarSegments(path: string) { + return path.split('/*').length - 1; +} + +export function getSlugFieldsForCollection( + config: Config, + collection: string +): readonly string[] { + const collectionConfig = config.collections![collection]; + const slugFields = collectionConfig.slugFields ?? [ + collectionConfig.slugField, + ]; + if (!slugFields.length) { + throw new Error( + `Collection ${collection} must specify a slugField or a non-empty slugFields array` + ); + } + for (const slugField of slugFields) { + const schema = collectionConfig.schema[slugField]; + if (!schema) { + throw new Error( + `slugField "${slugField}" does not exist in the schema for collection ${collection}` + ); + } + if (schema.kind !== 'form' || schema.formKind !== 'slug') { + throw new Error( + `slugField "${slugField}" in collection ${collection} is not a slug field` + ); + } + } + if ( + collectionConfig.slugFields && + collectionConfig.slugFields[slugFields.length - 1] !== + collectionConfig.slugField + ) { + throw new Error( + `The last entry of slugFields for collection ${collection} must be the slugField ("${collectionConfig.slugField}")` + ); + } + const path = getConfiguredCollectionPath(config, collection); + if (path.includes('**') && slugFields.length > 1) { + throw new Error( + `Collection ${collection} cannot combine slugFields with a ** glob in its path` + ); + } + const starCount = countStarSegments(path); + if (starCount !== slugFields.length) { + throw new Error( + `Collection path for ${collection} must contain exactly ${ + slugFields.length + } * segment${ + slugFields.length === 1 ? '' : 's' + } to match its slug fields but has ${starCount} (${path})` + ); + } + return slugFields; +} + export function getCollectionPath(config: Config, collection: string) { const configuredPath = getConfiguredCollectionPath(config, collection); const path = fixPath(configuredPath.replace(/\*\*?.*$/, '')); @@ -62,7 +120,9 @@ export function getCollectionItemSlugSuffix( collection: string ) { const configuredPath = getConfiguredCollectionPath(config, collection); - const path = fixPath(configuredPath.replace(/^[^*]+\*\*?/, '')); + const path = fixPath( + configuredPath.slice(configuredPath.lastIndexOf('*') + 1) + ); return path ? `/${path}` : ''; } diff --git a/packages/keystatic/src/app/slug-fields.test.ts b/packages/keystatic/src/app/slug-fields.test.ts new file mode 100644 index 000000000..d02a4225f --- /dev/null +++ b/packages/keystatic/src/app/slug-fields.test.ts @@ -0,0 +1,209 @@ +/** @vitest-environment node */ +import { expect, test } from 'vitest'; + +import type { Config } from '../config'; +import { fields } from '../form/api'; +import { collectionReader } from '../reader/generic'; +import type { MinimalFs } from '../reader/generic'; +import { + getCollectionItemPath, + getSlugFieldsForCollection, +} from './path-utils'; +import { getSlugFromState } from './utils'; +import { getEntriesInCollectionWithTreeKey } from './utils'; +import { treeEntriesToTreeNodes } from './trees'; + +function makeConfig(collection: Record): Config { + return { + storage: { kind: 'local' }, + collections: collection, + } as unknown as Config; +} + +const blogCollection = { + label: 'Blog', + path: 'posts/*/*/', + slugField: 'title', + slugFields: ['locale', 'title'], + format: 'yaml', + schema: { + locale: fields.text({ label: 'Locale' }), + title: fields.slug({ name: { label: 'Title' } }), + }, +}; + +test('getSlugFieldsForCollection returns the configured slug fields', () => { + const config = makeConfig({ blog: blogCollection }); + expect(getSlugFieldsForCollection(config, 'blog')).toEqual([ + 'locale', + 'title', + ]); +}); + +test('getSlugFieldsForCollection falls back to slugField', () => { + const config = makeConfig({ + blog: { + label: 'Blog', + path: 'posts/*/', + slugField: 'title', + format: 'yaml', + schema: { title: fields.slug({ name: { label: 'Title' } }) }, + }, + }); + expect(getSlugFieldsForCollection(config, 'blog')).toEqual(['title']); +}); + +test('getSlugFieldsForCollection throws when the star count does not match', () => { + const config = makeConfig({ + blog: { ...blogCollection, path: 'posts/*/' }, + }); + expect(() => getSlugFieldsForCollection(config, 'blog')).toThrowError( + /exactly 2 \* segments/ + ); +}); + +test('getSlugFieldsForCollection throws when a slug field is not a slug field', () => { + const config = makeConfig({ + blog: { + ...blogCollection, + slugFields: ['title', 'count'], + slugField: 'count', + schema: { + title: fields.slug({ name: { label: 'Title' } }), + count: fields.integer({ label: 'Count' }), + }, + }, + }); + expect(() => getSlugFieldsForCollection(config, 'blog')).toThrowError( + /is not a slug field/ + ); +}); + +test('getSlugFieldsForCollection throws when slugField is not the last entry', () => { + const config = makeConfig({ + blog: { ...blogCollection, slugField: 'locale' }, + }); + expect(() => getSlugFieldsForCollection(config, 'blog')).toThrowError( + /must be the slugField/ + ); +}); + +test('getCollectionItemPath maps a composite slug to nested directories', () => { + const config = makeConfig({ blog: blogCollection }); + expect(getCollectionItemPath(config, 'blog', 'en/hello-world')).toBe( + 'posts/en/hello-world' + ); +}); + +test('getSlugFromState joins all slug field segments', () => { + const config = makeConfig({ blog: blogCollection }); + expect( + getSlugFromState(config.collections!.blog as any, { + locale: 'en', + title: { name: 'Hello World', slug: 'hello-world' }, + }) + ).toBe('en/hello-world'); +}); + +const treeEntries = [ + { path: 'posts', mode: '040000', type: 'tree', sha: 't0' }, + { path: 'posts/en', mode: '040000', type: 'tree', sha: 't1' }, + { path: 'posts/en/hello-world', mode: '040000', type: 'tree', sha: 't2' }, + { + path: 'posts/en/hello-world/index.yaml', + mode: '100644', + type: 'blob', + sha: 'a1', + }, + { path: 'posts/en/deep', mode: '040000', type: 'tree', sha: 't5' }, + { path: 'posts/en/deep/nested', mode: '040000', type: 'tree', sha: 't6' }, + { + path: 'posts/en/deep/nested/index.yaml', + mode: '100644', + type: 'blob', + sha: 'a3', + }, + { path: 'posts/fr', mode: '040000', type: 'tree', sha: 't3' }, + { path: 'posts/fr/hello-world', mode: '040000', type: 'tree', sha: 't4' }, + { + path: 'posts/fr/hello-world/index.yaml', + mode: '100644', + type: 'blob', + sha: 'a2', + }, +]; + +test('getEntriesInCollectionWithTreeKey lists entries for multi-slug collections', () => { + const config = makeConfig({ blog: blogCollection }); + const tree = treeEntriesToTreeNodes( + treeEntries.map(x => ({ ...x, url: '' })) + ); + const entries = getEntriesInCollectionWithTreeKey(config, 'blog', tree).map( + x => x.slug + ); + // entries of a `posts/*/*/` collection are exactly two segments deep; + // `posts/en/deep/nested/index.yaml` is three segments and must be ignored + expect(entries.sort()).toEqual(['en/hello-world', 'fr/hello-world']); +}); + +function makeLocalFs(files: Record): MinimalFs { + const encoder = new TextEncoder(); + const toDirEntry = (name: string) => + name.endsWith('/') + ? { name: name.slice(0, -1), kind: 'directory' as const } + : { name, kind: 'file' as const }; + const readdir = (path: string) => { + const prefix = path.endsWith('/') ? path : path + '/'; + const names = new Set(); + for (const file of Object.keys(files)) { + if (!file.startsWith(prefix)) continue; + const rest = file.slice(prefix.length); + const slash = rest.indexOf('/'); + names.add( + toDirEntry(slash === -1 ? rest : rest.slice(0, slash + 1)).name + + (slash === -1 ? '' : '/') + ); + } + return [...names].map(toDirEntry); + }; + return { + readFile: async path => + files[path] === undefined ? null : encoder.encode(files[path]), + readdir: async path => readdir(path), + fileExists: async path => files[path] !== undefined, + }; +} + +test('reader lists and reads multi-slug entries', async () => { + const config = makeConfig({ blog: blogCollection }); + const fsReader = makeLocalFs({ + 'posts/en/hello-world/index.yaml': 'title: ignored\n', + 'posts/fr/hello-world/index.yaml': 'title: ignored\n', + }); + const reader = collectionReader('blog', config, fsReader); + expect(await reader.list()).toEqual(['en/hello-world', 'fr/hello-world']); + const entry = await reader.readOrThrow('en/hello-world'); + // text-as-slug fields read as null, fields.slug reads as the stored name + expect(entry.locale).toBe(null); + expect(entry.title).toBe('ignored'); + expect(await reader.read('zh/hello-world')).toBe(null); +}); + +test('reader still works for single slug collections', async () => { + const config = makeConfig({ + blog: { + label: 'Blog', + path: 'posts/*/', + slugField: 'title', + format: 'yaml', + schema: { title: fields.slug({ name: { label: 'Title' } }) }, + }, + }); + const fsReader = makeLocalFs({ + 'posts/hello-world/index.yaml': 'title: ignored\n', + }); + const reader = collectionReader('blog', config, fsReader); + expect(await reader.list()).toEqual(['hello-world']); + const entry = await reader.readOrThrow('hello-world'); + expect(entry.title).toBe('ignored'); +}); diff --git a/packages/keystatic/src/app/slugs.tsx b/packages/keystatic/src/app/slugs.tsx index 2f829a355..8d673d672 100644 --- a/packages/keystatic/src/app/slugs.tsx +++ b/packages/keystatic/src/app/slugs.tsx @@ -1,5 +1,8 @@ import { useMemo } from 'react'; -import { getSlugGlobForCollection } from './path-utils'; +import { + getSlugFieldsForCollection, + getSlugGlobForCollection, +} from './path-utils'; import { useSlugsInCollection } from './useSlugsInCollection'; import { SlugFieldInfo } from '../form/fields/text/path-slug-context'; import { useConfig } from './shell/context'; @@ -16,9 +19,10 @@ export function useSlugFieldInfo( if (slugToExclude) { slugs.delete(slugToExclude); } - const collectionConfig = config.collections![collection]; + const slugFields = getSlugFieldsForCollection(config, collection); return { - field: collectionConfig.slugField, + field: slugFields[slugFields.length - 1], + fields: slugFields, slugs, glob: getSlugGlobForCollection(config, collection), }; diff --git a/packages/keystatic/src/app/updating.tsx b/packages/keystatic/src/app/updating.tsx index 0234dfef1..422736055 100644 --- a/packages/keystatic/src/app/updating.tsx +++ b/packages/keystatic/src/app/updating.tsx @@ -58,12 +58,12 @@ export function serializeEntryToFiles(args: { schema: Record; format: FormatInfo; state: unknown; - slug: { value: string; field: string } | undefined; + slug: { value: string; fields: readonly string[] } | undefined; }) { let { value: stateWithExtraFilesRemoved, extraFiles } = serializeProps( args.state, fields.object(args.schema), - args.slug?.field, + args.slug?.fields, args.slug?.value, true ); @@ -114,7 +114,7 @@ export function useUpsertItem(args: { format: FormatInfo; currentLocalTreeKey: string | undefined; basePath: string; - slug: { value: string; field: string } | undefined; + slug: { value: string; fields: readonly string[] } | undefined; }) { const [state, setState] = useState< | { kind: 'idle' } diff --git a/packages/keystatic/src/app/useHasChanged.ts b/packages/keystatic/src/app/useHasChanged.ts index 2f6007737..51d6e1c18 100644 --- a/packages/keystatic/src/app/useHasChanged.ts +++ b/packages/keystatic/src/app/useHasChanged.ts @@ -10,20 +10,20 @@ export function useHasChanged(args: { initialState: unknown; state: unknown; schema: ObjectField>; - slugField: string | undefined; + slugFields: readonly string[] | undefined; }) { const serialize = useCallback( async (state: unknown) => { - const slug = args.slugField + const slug = args.slugFields?.length ? getSlugFromState( - { schema: args.schema.fields, slugField: args.slugField }, + { schema: args.schema.fields, slugFields: args.slugFields }, state as Record ) : undefined; const serializedState = serializeProps( state, args.schema, - args.slugField, + args.slugFields, slug, true ); @@ -40,7 +40,7 @@ export function useHasChanged(args: { ), }; }, - [args.schema, args.slugField] + [args.schema, args.slugFields] ); const initialFilesForUpdate = useData( useCallback( diff --git a/packages/keystatic/src/app/useItemData.ts b/packages/keystatic/src/app/useItemData.ts index 48ba8d896..40410f650 100644 --- a/packages/keystatic/src/app/useItemData.ts +++ b/packages/keystatic/src/app/useItemData.ts @@ -49,7 +49,7 @@ export function parseEntry( dirpath: string; format: FormatInfo; schema: Record; - slug: { slug: string; field: string } | undefined; + slug: { slug: string; fields: readonly string[] } | undefined; requireFrontmatter?: boolean; }, files: Map @@ -73,6 +73,7 @@ export function parseEntry( } const usedFiles = new Set([dataFilepath]); const rootSchema = fields.object(args.schema); + const slugSegments = args.slug?.slug.split('/'); let initialState; const getFile = (filepath: string) => { @@ -120,11 +121,19 @@ export function parseEntry( [], [], (schema, value, path, pathWithArrayFieldSlugs) => { - if (path.length === 1 && path[0] === args.slug?.field) { - if (schema.formKind !== 'slug') { - throw new Error(`slugField is not a slug field`); + if (path.length === 1 && args.slug !== undefined) { + const slugFieldIndex = args.slug.fields.indexOf(path[0] as string); + if (slugFieldIndex !== -1) { + if (schema.formKind !== 'slug') { + throw new Error(`slugField is not a slug field`); + } + return schema.parse(value, { + slug: + args.slug.fields.length === 1 + ? args.slug.slug + : slugSegments?.[slugFieldIndex] ?? '', + }); } - return schema.parse(value, { slug: args.slug.slug }); } if (schema.formKind === 'asset') { const suggestedFilenamePrefix = pathWithArrayFieldSlugs.join('/'); @@ -196,7 +205,7 @@ type UseItemDataArgs = { schema: Record; dirpath: string; format: FormatInfo; - slug: { slug: string; field: string } | undefined; + slug: { slug: string; fields: readonly string[] } | undefined; }; function getAllFilesInTree(tree: Map): TreeEntry[] { diff --git a/packages/keystatic/src/app/utils.ts b/packages/keystatic/src/app/utils.ts index fd2a11e15..8ee4fd4d6 100644 --- a/packages/keystatic/src/app/utils.ts +++ b/packages/keystatic/src/app/utils.ts @@ -9,6 +9,7 @@ import { getCollectionItemSlugSuffix, getCollectionPath, getDataFileExtension, + getSlugFieldsForCollection, getSlugGlobForCollection, } from './path-utils'; import { collectDirectoriesUsedInSchema, getTreeKey } from './tree-key'; @@ -77,19 +78,36 @@ export function getRepoUrl(config: { owner: string; name: string }) { return `https://github.com/${getRepoPath(config)}`; } +export function collectionSlugFields(collectionConfig: { + slugField: string; + slugFields?: readonly string[]; +}): readonly string[] { + return collectionConfig.slugFields ?? [collectionConfig.slugField]; +} + export function getSlugFromState( collectionConfig: { - slugField: string; + slugField?: string; + slugFields?: readonly string[]; schema: Record; }, state: Record ) { - const value = state[collectionConfig.slugField]; - const field = collectionConfig.schema[collectionConfig.slugField]; - if (field.kind !== 'form' || field.formKind !== 'slug') { - throw new Error(`slugField is not a slug field`); - } - return field.serializeWithSlug(value).slug; + const slugFields = + collectionConfig.slugFields ?? + (collectionConfig.slugField !== undefined + ? [collectionConfig.slugField] + : []); + return slugFields + .map(slugField => { + const value = state[slugField]; + const field = collectionConfig.schema[slugField]; + if (field.kind !== 'form' || field.formKind !== 'slug') { + throw new Error(`slugField is not a slug field`); + } + return field.serializeWithSlug(value).slug; + }) + .join('/'); } export function getEntriesInCollectionWithTreeKey( @@ -102,6 +120,7 @@ export function getEntriesInCollectionWithTreeKey( const formatInfo = getCollectionFormat(config, collection); const extension = getDataFileExtension(formatInfo); const glob = getSlugGlobForCollection(config, collection); + const slugFieldCount = getSlugFieldsForCollection(config, collection).length; const collectionPath = getCollectionPath(config, collection); const directory: Map = getTreeNodeAtPath(rootTree, collectionPath)?.children ?? new Map(); @@ -109,18 +128,21 @@ export function getEntriesInCollectionWithTreeKey( const directoriesUsedInSchema = [...collectDirectoriesUsedInSchema(schema)]; const suffix = getCollectionItemSlugSuffix(config, collection); const possibleEntries = new Map(directory); - if (glob === '**') { - const handleDirectory = (dir: Map, prefix: string) => { + const maxDepth = glob === '**' ? Infinity : slugFieldCount; + if (maxDepth > 1) { + const handleDirectory = ( + dir: Map, + prefix: string, + depth: number + ) => { for (const [key, entry] of dir) { - if (entry.children) { - possibleEntries.set(`${prefix}${key}`, entry); - handleDirectory(entry.children, `${prefix}${key}/`); - } else { - possibleEntries.set(`${prefix}${key}`, entry); + possibleEntries.set(`${prefix}${key}`, entry); + if (entry.children && depth < maxDepth) { + handleDirectory(entry.children, `${prefix}${key}/`, depth + 1); } } }; - handleDirectory(directory, ''); + handleDirectory(directory, '', 1); } for (const [key, entry] of possibleEntries) { if (formatInfo.dataLocation === 'index') { diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 4c95368f3..81a106daa 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -22,14 +22,31 @@ export type Collection< SlugField extends string, > = { label: string; - path?: `${string}/${Glob}` | `${string}/${Glob}/${string}`; + path?: + | `${string}/${Glob}` + | `${string}/${Glob}/${string}` + | `${string}/${Glob}/${Glob}` + | `${string}/${Glob}/${Glob}/${string}`; entryLayout?: EntryLayout; format?: Format; previewUrl?: string; columns?: string[]; template?: string; parseSlugForSort?: (slug: string) => string | number; + /** + * The slug field for the collection. When `slugFields` is provided, this must + * be the last entry in `slugFields` and it is the segment shown as "the slug" + * in the admin UI. + */ slugField: SlugField; + /** + * Multiple slug fields, mapped to the star segments of `path` in order. + * The entry's slug is the segment values joined with a slash. For example, + * with `slugFields: ['locale', 'slug']` and a path with two star segments, + * an entry with locale `en` and slug `my-post` is stored at + * `content/blog/en/my-post`. + */ + slugFields?: readonly string[]; schema: Schema; }; @@ -192,6 +209,7 @@ export function collection< ? K & string : never; }[keyof Schema][]; + slugFields?: readonly (keyof Schema & string)[]; } ): Collection { return collection; diff --git a/packages/keystatic/src/form/api.tsx b/packages/keystatic/src/form/api.tsx index 4b87714cf..e50f3c430 100644 --- a/packages/keystatic/src/form/api.tsx +++ b/packages/keystatic/src/form/api.tsx @@ -72,7 +72,9 @@ export type SlugFormField< }; validate( value: ParsedValue, - extra: { slugField: { slugs: Set; glob: Glob } } | undefined + extra: + | { slugField: { slugs: Set; glob: Glob; prefix?: string } } + | undefined ): ValidatedValue; reader: { parse(value: FormFieldStoredValue): ReaderValue; diff --git a/packages/keystatic/src/form/errors.ts b/packages/keystatic/src/form/errors.ts index a0d31c1b7..a6a0882ac 100644 --- a/packages/keystatic/src/form/errors.ts +++ b/packages/keystatic/src/form/errors.ts @@ -1,11 +1,51 @@ import { getSlugFromState } from '../app/utils'; -import { ComponentSchema } from './api'; +import { ComponentSchema, ObjectField } from './api'; import { SlugFieldInfo } from './fields/text/path-slug-context'; import { PropValidationError } from './prop-validation-error'; import { ReadonlyPropPath } from './fields/document/DocumentEditor/component-blocks/utils'; import { validateArrayLength } from './validate-array-length'; import { toFormattedFormDataError } from './error-formatting'; +type ValidationSlugFieldInfo = SlugFieldInfo & { prefix?: string }; + +function slugSegmentFromState(schema: ComponentSchema, value: unknown): string { + if (value === undefined || value === null) return ''; + if (schema.kind === 'form' && schema.formKind === 'slug') { + return schema.serializeWithSlug(value).slug; + } + return ''; +} + +function slugFieldInfoForChild( + schema: ObjectField>, + value: any, + key: string, + slugField: ValidationSlugFieldInfo | undefined, + atRoot: boolean +): ValidationSlugFieldInfo | undefined { + if (!slugField) { + return undefined; + } + if (!atRoot || slugField.fields.length === 1) { + return key === slugField.field ? slugField : undefined; + } + // for a multi-slug collection, the last slug field is validated for + // uniqueness against the composite slug built from the other segments + if (key === slugField.field) { + const prefix = slugField.fields + .slice(0, -1) + .map(f => slugSegmentFromState(schema.fields[f], value?.[f])) + .join('/'); + return { ...slugField, prefix }; + } + if (slugField.fields.includes(key)) { + // secondary slug fields only get format validation, their uniqueness is + // enforced via the composite slug on the last slug field + return { ...slugField, slugs: new Set(), prefix: undefined }; + } + return undefined; +} + export function clientSideValidateProp( schema: ComponentSchema, value: any, @@ -23,7 +63,7 @@ export function clientSideValidateProp( function validateValueWithSchema( schema: ComponentSchema, value: any, - slugField: SlugFieldInfo | undefined, + slugField: ValidationSlugFieldInfo | undefined, path: ReadonlyPropPath = [] ): void { switch (schema.kind) { @@ -34,7 +74,11 @@ function validateValueWithSchema( try { if (slugField && path[path.length - 1] === slugField?.field) { schema.validate(value, { - slugField: { slugs: slugField.slugs, glob: slugField.glob }, + slugField: { + slugs: slugField.slugs, + glob: slugField.glob, + prefix: slugField.prefix, + }, }); return; } @@ -61,7 +105,7 @@ function validateValueWithSchema( validateValueWithSchema( childProp, value[key], - key === slugField?.field ? slugField : undefined, + slugFieldInfoForChild(schema, value, key, slugField, !path.length), path.concat(key) ); } catch (err) { @@ -103,6 +147,7 @@ function validateValueWithSchema( ? undefined : { field: slugInfo.slugField, + fields: [slugInfo.slugField], slugs: new Set(slugInfo.slugs.filter((_, i) => idx !== i)), glob: '*', }, diff --git a/packages/keystatic/src/form/fields/array/ui.tsx b/packages/keystatic/src/form/fields/array/ui.tsx index a9209d1bd..1db4acf0c 100644 --- a/packages/keystatic/src/form/fields/array/ui.tsx +++ b/packages/keystatic/src/form/fields/array/ui.tsx @@ -158,7 +158,7 @@ function ArrayFieldAddItemModalContent(props: { getSlugFromState({ schema, slugField }, x as Record) ) ); - return { slugs, field: slugField, glob: '*' as const }; + return { slugs, field: slugField, fields: [slugField], glob: '*' as const }; }, [props.previewProps]); const [value, setValue] = useState(() => @@ -254,7 +254,7 @@ function ArrayEditItemModalContent(props: { getSlugFromState({ schema, slugField }, x as Record) ) ); - return { slugs, field: slugField, glob: '*' as const }; + return { slugs, field: slugField, fields: [slugField], glob: '*' as const }; }, [props.previewProps, props.modalStateIndex]); const { key, ...propsWithoutKey } = diff --git a/packages/keystatic/src/form/fields/slug/index.tsx b/packages/keystatic/src/form/fields/slug/index.tsx index e4dd5673e..1586a33d5 100644 --- a/packages/keystatic/src/form/fields/slug/index.tsx +++ b/packages/keystatic/src/form/fields/slug/index.tsx @@ -103,7 +103,9 @@ export function slug(_args: { { slugField, }: { - slugField: { slugs: Set; glob: Glob } | undefined; + slugField: + | { slugs: Set; glob: Glob; prefix?: string } + | undefined; } = { slugField: undefined } ) { const nameMessage = validateText( diff --git a/packages/keystatic/src/form/fields/text/index.tsx b/packages/keystatic/src/form/fields/text/index.tsx index 157c30e01..b9e4a4757 100644 --- a/packages/keystatic/src/form/fields/text/index.tsx +++ b/packages/keystatic/src/form/fields/text/index.tsx @@ -47,7 +47,7 @@ export function text({ min = Math.max(isRequired ? 1 : 0, min); function validate( value: string, - slugField: { slugs: Set; glob: Glob } | undefined + slugField: { slugs: Set; glob: Glob; prefix?: string } | undefined ) { const message = validateText(value, min, max, label, slugField, pattern); if (message !== undefined) { 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..8cc33a19e 100644 --- a/packages/keystatic/src/form/fields/text/path-slug-context.tsx +++ b/packages/keystatic/src/form/fields/text/path-slug-context.tsx @@ -106,6 +106,8 @@ export function AddToPathProvider(props: { export type SlugFieldInfo = { field: string; + /** all slug fields, in path segment order; `field` is always the last entry */ + fields: readonly string[]; slugs: Set; glob: Glob; }; diff --git a/packages/keystatic/src/form/fields/text/validateText.tsx b/packages/keystatic/src/form/fields/text/validateText.tsx index e50b04a4e..9e3f1c969 100644 --- a/packages/keystatic/src/form/fields/text/validateText.tsx +++ b/packages/keystatic/src/form/fields/text/validateText.tsx @@ -5,7 +5,7 @@ export function validateText( min: number, max: number, fieldLabel: string, - slugInfo: { slugs: Set; glob: Glob } | undefined, + slugInfo: { slugs: Set; glob: Glob; prefix?: string } | undefined, pattern: { regex: RegExp; message?: string } | undefined ) { if (val.length < min) { @@ -48,7 +48,9 @@ export function validateText( if (/^\s|\s$/.test(val)) { return `${fieldLabel} must not start or end with spaces`; } - if (slugInfo.slugs.has(val)) { + if ( + slugInfo.slugs.has(slugInfo.prefix ? `${slugInfo.prefix}/${val}` : val) + ) { return `${fieldLabel} must be unique`; } } diff --git a/packages/keystatic/src/form/serialize-props.ts b/packages/keystatic/src/form/serialize-props.ts index 0b9002e64..521ead3d9 100644 --- a/packages/keystatic/src/form/serialize-props.ts +++ b/packages/keystatic/src/form/serialize-props.ts @@ -7,7 +7,7 @@ export function serializeProps( rootValue: unknown, rootSchema: ComponentSchema, // note you might have a slug without a slug field when serializing props inside a component block or etc. in the editor - slugField: string | undefined, + slugFields: readonly string[] | undefined, slug: string | undefined, shouldSuggestFilenamePrefix: boolean ) { @@ -19,7 +19,10 @@ export function serializeProps( return { value: transformProps(rootSchema, rootValue, { form(schema, value, propPath) { - if (propPath.length === 1 && slugField === propPath[0]) { + if ( + propPath.length === 1 && + slugFields?.includes(propPath[0] as string) + ) { if (schema.formKind !== 'slug') { throw new Error('slugField is a not a slug field'); } diff --git a/packages/keystatic/src/reader/generic.ts b/packages/keystatic/src/reader/generic.ts index 9d6d4ba39..057946857 100644 --- a/packages/keystatic/src/reader/generic.ts +++ b/packages/keystatic/src/reader/generic.ts @@ -16,6 +16,7 @@ import { getEntryDataFilepath, getSingletonFormat, getSingletonPath, + getSlugFieldsForCollection, getSlugGlobForCollection, } from '../app/path-utils'; import { parseProps } from '../form/parse-props'; @@ -217,20 +218,25 @@ async function getAllEntries( const listCollection = cache(async function listCollection( collectionPath: string, glob: Glob, + segments: number, formatInfo: FormatInfo, extension: string, fsReader: MinimalFs ) { - const entries: { entry: DirEntry; name: string }[] = - glob === '*' - ? (await fsReader.readdir(collectionPath)).map(entry => ({ - entry, - name: entry.name, - })) - : (await getAllEntries(`${collectionPath}/`, fsReader)).map(x => ({ - entry: x.entry, - name: x.name.slice(collectionPath.length + 1), - })); + let entries: { entry: DirEntry; name: string }[]; + if (glob === '*' && segments === 1) { + entries = (await fsReader.readdir(collectionPath)).map(entry => ({ + entry, + name: entry.name, + })); + } else { + entries = (await getAllEntries(`${collectionPath}/`, fsReader)) + .map(x => ({ + entry: x.entry, + name: x.name.slice(collectionPath.length + 1), + })) + .filter(x => glob === '**' || x.name.split('/').length === segments); + } return ( await Promise.all( @@ -266,6 +272,7 @@ export function collectionReader( const collectionConfig = config.collections![collection]; const schema = fields.object(collectionConfig.schema); const glob = getSlugGlobForCollection(config, collection); + const slugFields = getSlugFieldsForCollection(config, collection); const extension = getDataFileExtension(formatInfo); const read: CollectionReader['read'] = (slug, ...args) => @@ -277,12 +284,19 @@ export function collectionReader( `"${slug}" in collection "${collection}"`, fsReader, slug, - collectionConfig.slugField, + slugFields, glob ); const list = () => - listCollection(collectionPath, glob, formatInfo, extension, fsReader); + listCollection( + collectionPath, + glob, + slugFields.length, + formatInfo, + extension, + fsReader + ); return { read, @@ -320,12 +334,14 @@ const readItem = cache(async function readItem( resolveLinkedFiles: boolean | undefined, debugReference: string, fsReader: MinimalFs, - ...slugInfo: [slug: undefined] | [slug: string, field: string, glob: Glob] + ...slugInfo: + | [slug: undefined] + | [slug: string, fields: readonly string[], glob: Glob] ) { if (typeof slugInfo[0] === 'string') { if (slugInfo[0].includes('\\')) return null; const split = slugInfo[0].split('/'); - if (slugInfo[2] === '*' && split.length !== 1) return null; + if (slugInfo[2] === '*' && split.length !== slugInfo[1].length) return null; if (split.includes('..') || split.includes('.')) return null; } const dataFile = await fsReader.readFile( @@ -366,12 +382,19 @@ const readItem = cache(async function readItem( }; } if (path.length === 1 && slugInfo[0] !== undefined) { - const [slug, slugField, glob] = slugInfo; - if (path[0] === slugField) { + const [slug, slugFields, glob] = slugInfo; + const slugFieldIndex = slugFields.indexOf(path[0] as string); + if (slugFieldIndex !== -1) { if (schema.formKind !== 'slug') { - throw new Error(`Slug field ${slugInfo[1]} is not a slug field`); + throw new Error(`Slug field ${path[0]} is not a slug field`); } - return schema.reader.parseWithSlug(value, { slug, glob }); + return schema.reader.parseWithSlug(value, { + slug: + slugFields.length === 1 + ? slug + : slug.split('/')[slugFieldIndex] ?? '', + glob, + }); } } return schema.reader.parse(value); From 588447c171925541bbd32e06aa85c02247e14c9a Mon Sep 17 00:00:00 2001 From: tihu220 Date: Thu, 17 Sep 2026 01:47:34 +0800 Subject: [PATCH 2/2] chore: add changeset for slugFields --- .changeset/lucky-pugs-learn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lucky-pugs-learn.md diff --git a/.changeset/lucky-pugs-learn.md b/.changeset/lucky-pugs-learn.md new file mode 100644 index 000000000..a93d2ce3a --- /dev/null +++ b/.changeset/lucky-pugs-learn.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': minor +--- + +Add `slugFields` option to collections for multi-segment collection paths. Each entry maps onto a `*` segment of the collection `path` in order, enabling entry paths such as `src/content/blog/en/my-post` while keeping slugs static and deterministic.