From c922f3016be988553024a54038f999a4fc56c39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Kindler?= Date: Thu, 8 Oct 2026 19:17:18 +0200 Subject: [PATCH 1/2] feat(collection): add slugFields for multi-segment collection paths Resolves Thinkmill/keystatic#340 --- .changeset/great-lobsters-march.md | 5 + docs/src/content/pages/collections.mdoc | 19 + packages/keystatic/src/app/CollectionPage.tsx | 16 +- packages/keystatic/src/app/ItemPage.tsx | 27 +- packages/keystatic/src/app/SingletonPage.tsx | 4 +- packages/keystatic/src/app/create-item.tsx | 25 +- packages/keystatic/src/app/entry-clipboard.ts | 16 +- packages/keystatic/src/app/entry-form.tsx | 29 +- packages/keystatic/src/app/path-utils.ts | 59 ++- .../keystatic/src/app/slug-fields.test.ts | 356 ++++++++++++++++++ packages/keystatic/src/app/slugs.tsx | 10 +- packages/keystatic/src/app/updating.tsx | 6 +- packages/keystatic/src/app/useHasChanged.ts | 14 +- packages/keystatic/src/app/useItemData.ts | 21 +- packages/keystatic/src/app/utils.ts | 46 ++- packages/keystatic/src/config.tsx | 71 ++++ packages/keystatic/src/form/api.tsx | 6 +- packages/keystatic/src/form/errors.ts | 50 ++- .../keystatic/src/form/fields/array/ui.tsx | 4 +- .../keystatic/src/form/fields/slug/index.tsx | 5 +- .../keystatic/src/form/fields/slug/ui.tsx | 17 +- .../keystatic/src/form/fields/text/index.tsx | 8 +- .../form/fields/text/path-slug-context.tsx | 7 + .../keystatic/src/form/fields/text/ui.tsx | 17 +- .../src/form/fields/text/validateText.tsx | 19 +- .../keystatic/src/form/serialize-props.ts | 7 +- packages/keystatic/src/reader/generic.ts | 47 ++- 27 files changed, 804 insertions(+), 107 deletions(-) create mode 100644 .changeset/great-lobsters-march.md create mode 100644 packages/keystatic/src/app/slug-fields.test.ts diff --git a/.changeset/great-lobsters-march.md b/.changeset/great-lobsters-march.md new file mode 100644 index 000000000..a1ba5e1d6 --- /dev/null +++ b/.changeset/great-lobsters-march.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 — e.g. `slugFields: ['locale', 'title']` with `path: 'content/blog/*/*'` stores an entry with locale `en` and slug `my-post` at `content/blog/en/my-post` — while keeping slugs static and deterministic. The number of `*` segments and the `slugFields` entries are also checked against each other at the type level. diff --git a/docs/src/content/pages/collections.mdoc b/docs/src/content/pages/collections.mdoc index 24adc2bed..da5a1c1b4 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 that make up the slug of each item. Each entry maps onto 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 slug `my-post` is stored at `src/content/blog/en/my-post`, and its slug is `en/my-post`. Slugs stay static and deterministic: the location of every entry is always known from the config, which keeps collections working over the GitHub API. + ### 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..01d5a52ae 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,7 @@ 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 +503,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 +515,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 +851,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 +876,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..e53f0a36d 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,7 @@ 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 +388,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 +470,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/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/entry-form.tsx b/packages/keystatic/src/app/entry-form.tsx index 43461de1f..d37709802 100644 --- a/packages/keystatic/src/app/entry-form.tsx +++ b/packages/keystatic/src/app/entry-form.tsx @@ -4,7 +4,7 @@ import { SplitPanePrimary, SplitPaneSecondary, } from '@keystar/ui/split-view'; -import { ReactNode, createContext, useContext } from 'react'; +import { ReactNode, createContext, useContext, useMemo } from 'react'; import { ReadonlyPropPath } from '../form/fields/document/DocumentEditor/component-blocks/utils'; import { @@ -82,6 +82,29 @@ export function FormForEntry({ slugField: SlugFieldInfo | undefined; }) { const isAboveMobile = useContentPanelQuery({ above: 'mobile' }); + // for collections with multiple slug fields, the uniqueness of the last + // slug field is checked against the composite slug, so the segments from + // the other slug fields are needed from the current form state + const slugFieldWithPrefix = useMemo((): SlugFieldInfo | undefined => { + if (!slugField || slugField.fields.length === 1) return slugField; + const prefix = slugField.fields + .slice(0, -1) + .map(key => { + const fieldSchema = props.schema.fields[key]; + const fieldValue = (props.fields[key] as { value: unknown } | undefined) + ?.value; + if ( + fieldSchema?.kind === 'form' && + fieldSchema.formKind === 'slug' && + fieldValue !== undefined + ) { + return fieldSchema.serializeWithSlug(fieldValue).slug; + } + return ''; + }) + .join('/'); + return { ...slugField, prefix }; + }, [slugField, props.schema, props.fields]); if (entryLayout === 'content' && formatInfo.contentField && isAboveMobile) { const { contentField } = formatInfo; @@ -107,7 +130,7 @@ export function FormForEntry({ } return ( - + diff --git a/packages/keystatic/src/app/path-utils.ts b/packages/keystatic/src/app/path-utils.ts index 97fb22caa..3e8e12187 100644 --- a/packages/keystatic/src/app/path-utils.ts +++ b/packages/keystatic/src/app/path-utils.ts @@ -19,6 +19,61 @@ function getConfiguredCollectionPath(config: Config, collection: string) { return path; } +export function getSlugFieldsForCollection( + config: Config, + collection: string +): readonly string[] { + const collectionConfig = config.collections![collection]; + const slugFields = collectionConfig.slugFields ?? [ + collectionConfig.slugField, + ]; + if (slugFields.length === 0) { + throw new Error( + `The "${collection}" collection must specify a slugField or a non-empty slugFields array` + ); + } + for (const slugField of slugFields) { + const field = collectionConfig.schema[slugField]; + if (!field) { + throw new Error( + `The slugField "${slugField}" does not exist in the schema for the "${collection}" collection` + ); + } + if (field.kind !== 'form' || field.formKind !== 'slug') { + throw new Error( + `The slugField "${slugField}" in the "${collection}" collection is not a slug field` + ); + } + } + if ( + collectionConfig.slugFields !== undefined && + slugFields[slugFields.length - 1] !== collectionConfig.slugField + ) { + throw new Error( + `The last entry in slugFields for the "${collection}" collection must be the slugField ("${collectionConfig.slugField}")` + ); + } + const path = getConfiguredCollectionPath(config, collection); + const globSegments = path + .split('/') + .filter(segment => segment === '*' || segment === '**'); + if (globSegments.includes('**') && slugFields.length > 1) { + throw new Error( + `The "${collection}" collection cannot use multiple slugFields with a ** segment in its path` + ); + } + if (globSegments.length !== slugFields.length) { + throw new Error( + `The path for the "${collection}" collection must contain exactly ${ + slugFields.length + } * segment${ + slugFields.length === 1 ? '' : 's' + } to match its slug fields but it has ${globSegments.length} (${path})` + ); + } + return slugFields; +} + export function getCollectionPath(config: Config, collection: string) { const configuredPath = getConfiguredCollectionPath(config, collection); const path = fixPath(configuredPath.replace(/\*\*?.*$/, '')); @@ -62,7 +117,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..207e193d8 --- /dev/null +++ b/packages/keystatic/src/app/slug-fields.test.ts @@ -0,0 +1,356 @@ +/** @vitest-environment node */ +import { expect, test } from 'vitest'; + +import { collection } from '../config'; +import type { Config } from '../config'; +import { fields } from '../form/api'; +import { clientSideValidateProp } from '../form/errors'; +import { collectionReader } from '../reader/generic'; +import type { MinimalFs } from '../reader/generic'; +import { + getCollectionItemPath, + getCollectionItemSlugSuffix, + getSlugFieldsForCollection, +} from './path-utils'; +import { + getEntriesInCollectionWithTreeKey, + getSlugFromState, +} from './utils'; +import { treeEntriesToTreeNodes } from './trees'; + +function makeConfig(collections: Record): Config { + return { + storage: { kind: 'local' }, + collections, + } 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 the 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: ['count', 'title'], + schema: { + count: fields.integer({ label: 'Count' }), + title: fields.slug({ name: { label: 'Title' } }), + }, + }, + }); + expect(() => getSlugFieldsForCollection(config, 'blog')).toThrowError( + /is not a slug field/ + ); +}); + +test('getSlugFieldsForCollection throws when the slugField is not the last entry', () => { + const config = makeConfig({ + blog: { ...blogCollection, slugField: 'locale' }, + }); + expect(() => getSlugFieldsForCollection(config, 'blog')).toThrowError( + /must be the slugField/ + ); +}); + +test('getSlugFieldsForCollection throws when combining slugFields with a ** segment', () => { + const config = makeConfig({ + blog: { ...blogCollection, path: 'posts/*/**' }, + }); + expect(() => getSlugFieldsForCollection(config, 'blog')).toThrowError( + /\*\* segment/ + ); +}); + +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('getCollectionItemSlugSuffix uses the part after the last glob', () => { + const config = makeConfig({ + blog: { ...blogCollection, path: 'posts/*/*/index.mdoc' }, + }); + expect(getCollectionItemSlugSuffix(config, 'blog')).toBe('/index.mdoc'); + const simple = makeConfig({ + blog: { ...blogCollection, path: 'posts/*/' }, + }); + expect(getCollectionItemSlugSuffix(simple, 'blog')).toBe(''); +}); + +test('getSlugFromState joins all slug field segments', () => { + expect( + getSlugFromState(blogCollection 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 at the exact segment depth', () => { + 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']); +}); + +test('getEntriesInCollectionWithTreeKey is unchanged for single slug collections', () => { + const config = makeConfig({ + blog: { + label: 'Blog', + path: 'posts/*/', + slugField: 'title', + format: 'yaml', + schema: { title: fields.slug({ name: { label: 'Title' } }) }, + }, + }); + const tree = treeEntriesToTreeNodes( + [ + { path: 'posts', mode: '040000', type: 'tree', sha: 't0' }, + { path: 'posts/hello-world', mode: '040000', type: 'tree', sha: 't1' }, + { + path: 'posts/hello-world/index.yaml', + mode: '100644', + type: 'blob', + sha: 'a1', + }, + ].map(x => ({ ...x, url: '' })) + ); + const entries = getEntriesInCollectionWithTreeKey(config, 'blog', tree).map( + x => x.slug + ); + expect(entries).toEqual(['hello-world']); +}); + +function makeLocalFs(files: Record): MinimalFs { + const encoder = new TextEncoder(); + 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(slash === -1 ? rest : rest.slice(0, slash)); + } + return [...names].map(name => ({ + name, + kind: ( + Object.keys(files).some(f => f.startsWith(`${prefix}${name}/`)) + ? 'directory' + : 'file' + ) as 'directory' | 'file', + })); + }; + 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'); + // a slug with the wrong segment count does not match the collection + expect(await reader.read('hello-world')).toBe(null); + expect(await reader.read('en/deep/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'); +}); + +test('validation scopes uniqueness to the composite slug', () => { + const schema = fields.object(blogCollection.schema); + const slugFieldInfo = { + field: 'title', + fields: ['locale', 'title'], + slugs: new Set(['en/hello-world']), + glob: '*' as const, + }; + // the same slug in the same locale is a duplicate + expect( + clientSideValidateProp( + schema, + { locale: 'en', title: { name: 'Hello World', slug: 'hello-world' } }, + slugFieldInfo + ) + ).toBe(false); + // the same slug in another locale is fine + expect( + clientSideValidateProp( + schema, + { locale: 'fr', title: { name: 'Hello World', slug: 'hello-world' } }, + slugFieldInfo + ) + ).toBe(true); +}); + +test('slug field segments must not contain slashes', () => { + const schema = fields.object(blogCollection.schema); + const slugFieldInfo = { + field: 'title', + fields: ['locale', 'title'], + slugs: new Set(), + glob: '*' as const, + }; + expect( + clientSideValidateProp( + schema, + { locale: 'en/us', title: { name: 'Hello World', slug: 'hello-world' } }, + slugFieldInfo + ) + ).toBe(false); +}); + +test('slugFields are checked against the path at the type level', () => { + const typeAssertions = () => { + collection({ + label: 'Blog', + path: 'posts/*/*/', + slugField: 'title', + slugFields: ['locale', 'title'], + schema: blogCollection.schema, + }); + collection({ + label: 'Blog', + path: 'posts/*/', + slugField: 'title', + // @ts-expect-error - one slug field per * segment in path + slugFields: ['locale', 'title'], + schema: blogCollection.schema, + }); + collection({ + label: 'Blog', + path: 'posts/*/*/', + slugField: 'locale', + // @ts-expect-error - the last entry of slugFields must be the slugField + slugFields: ['locale', 'title'], + schema: blogCollection.schema, + }); + collection({ + label: 'Blog', + path: 'posts/*/*/', + slugField: 'title', + slugFields: [ + // @ts-expect-error - slugFields entries must be slug fields + 'count', + 'title', + ], + schema: { + ...blogCollection.schema, + count: fields.integer({ label: 'Count' }), + }, + }); + collection({ + label: 'Blog', + path: 'posts/*/**', + slugField: 'title', + // @ts-expect-error - multiple slugFields cannot be used with a ** segment + slugFields: ['locale', 'title'], + schema: blogCollection.schema, + }); + }; + void typeAssertions; +}); 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..3777fd7b7 100644 --- a/packages/keystatic/src/app/useHasChanged.ts +++ b/packages/keystatic/src/app/useHasChanged.ts @@ -10,20 +10,24 @@ 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, + slugField: args.slugFields[args.slugFields.length - 1], + slugFields: args.slugFields, + }, state as Record ) : undefined; const serializedState = serializeProps( state, args.schema, - args.slugField, + args.slugFields, slug, true ); @@ -40,7 +44,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..f5883f995 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,31 @@ 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; + 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; + return collectionSlugFields(collectionConfig) + .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( @@ -109,20 +122,25 @@ 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 slugFieldCount = getSlugFieldsForCollection(config, collection).length; + 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 (glob === '*' && key.split('/').length !== slugFieldCount) continue; if (formatInfo.dataLocation === 'index') { const actualEntry = getTreeNodeAtPath( rootTree, diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 4c95368f3..7e849ce87 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -17,6 +17,19 @@ export type Format = }; export type EntryLayout = 'content' | 'form'; export type Glob = '*' | '**'; + +type GlobSegmentsInPath< + Path extends string, + Segments extends readonly Glob[] = [], +> = Path extends `${infer Segment}/${infer Rest}` + ? GlobSegmentsInPath< + Rest, + Segment extends Glob ? readonly [...Segments, Segment] : Segments + > + : Path extends Glob + ? readonly [...Segments, Path] + : Segments; + export type Collection< Schema extends Record, SlugField extends string, @@ -30,6 +43,14 @@ export type Collection< template?: string; parseSlugForSort?: (slug: string) => string | number; slugField: SlugField; + /** + * Multiple slug fields, mapped onto the `*` segments of `path` in order. + * The last entry must be the `slugField`. An entry's slug is the segment + * values joined with a slash, so with `slugFields: ['locale', 'title']` and + * two star segments in `path`, an entry with the locale `en` and the + * slug `my-post` is stored at `content/blog/en/my-post`. + */ + slugFields?: readonly string[]; schema: Schema; }; @@ -179,6 +200,31 @@ export function collection< ? K : never; }[keyof Schema], + const Path extends Collection['path'] = undefined, + const SlugFields extends + | readonly [ + { + [K in keyof Schema]: Schema[K] extends SlugFormField< + any, + any, + any, + any + > + ? K & string + : never; + }[keyof Schema], + ...{ + [K in keyof Schema]: Schema[K] extends SlugFormField< + any, + any, + any, + any + > + ? K & string + : never; + }[keyof Schema][], + ] + | undefined = undefined, >( collection: Collection & { columns?: { @@ -192,6 +238,31 @@ export function collection< ? K & string : never; }[keyof Schema][]; + path?: Path; + slugFields?: SlugFields & + (SlugFields extends readonly unknown[] + ? [Path] extends [undefined] + ? // without an explicit path, the collection path has a single * segment + SlugFields['length'] extends 1 + ? unknown + : 'slugFields with multiple entries requires a path with one * segment per entry' + : string extends Path + ? unknown + : '**' extends GlobSegmentsInPath>[number] + ? SlugFields['length'] extends 1 + ? unknown + : 'slugFields with multiple entries cannot be used with a ** segment in path' + : GlobSegmentsInPath< + Exclude + >['length'] extends SlugFields['length'] + ? unknown + : 'slugFields must contain exactly one entry per * segment in path' + : unknown) & + (SlugFields extends readonly [...(readonly unknown[]), infer Last] + ? Last extends SlugField + ? unknown + : 'the last entry of slugFields must be the slugField' + : unknown); } ): Collection { return collection; diff --git a/packages/keystatic/src/form/api.tsx b/packages/keystatic/src/form/api.tsx index 4b87714cf..dedec39d1 100644 --- a/packages/keystatic/src/form/api.tsx +++ b/packages/keystatic/src/form/api.tsx @@ -72,7 +72,11 @@ 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..926245b0b 100644 --- a/packages/keystatic/src/form/errors.ts +++ b/packages/keystatic/src/form/errors.ts @@ -1,11 +1,50 @@ 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'; +function slugSegmentFromState(schema: ComponentSchema, value: unknown) { + if (value === undefined || value === null) return ''; + if (schema.kind === 'form' && schema.formKind === 'slug') { + return schema.serializeWithSlug(value).slug; + } + return ''; +} + +// distributes the collection's slug field info to the individual fields of a +// top-level object: the last slug field is validated for uniqueness against +// the composite slug built from the other segments, the other slug fields +// only get format validation (their uniqueness is enforced via the composite +// slug on the last slug field) +function slugFieldInfoForChild( + schema: ObjectField>, + value: Record, + key: string, + slugField: SlugFieldInfo | undefined, + atRoot: boolean +): SlugFieldInfo | undefined { + if (!slugField) { + return undefined; + } + if (!atRoot || slugField.fields.length === 1) { + return key === slugField.field ? slugField : undefined; + } + if (key === slugField.field) { + const prefix = slugField.fields + .slice(0, -1) + .map(field => slugSegmentFromState(schema.fields[field], value?.[field])) + .join('/'); + return { ...slugField, prefix }; + } + if (slugField.fields.includes(key)) { + return { ...slugField, field: key, slugs: new Set(), prefix: undefined }; + } + return undefined; +} + export function clientSideValidateProp( schema: ComponentSchema, value: any, @@ -34,7 +73,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 +104,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 +146,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..f0b191122 100644 --- a/packages/keystatic/src/form/fields/slug/index.tsx +++ b/packages/keystatic/src/form/fields/slug/index.tsx @@ -1,7 +1,6 @@ import { FormFieldStoredValue, SlugFormField } from '../../api'; -import { validateText } from '../text/validateText'; +import { validateText, SlugValidationInfo } from '../text/validateText'; import { SlugFieldInput, slugify } from '#field-ui/slug'; -import { Glob } from '../../..'; import { FieldDataError } from '../error'; function parseSlugFieldAsNormalField(value: FormFieldStoredValue) { @@ -103,7 +102,7 @@ export function slug(_args: { { slugField, }: { - slugField: { slugs: Set; glob: Glob } | undefined; + slugField: SlugValidationInfo | undefined; } = { slugField: undefined } ) { const nameMessage = validateText( diff --git a/packages/keystatic/src/form/fields/slug/ui.tsx b/packages/keystatic/src/form/fields/slug/ui.tsx index 6c345a7bc..ddab04d96 100644 --- a/packages/keystatic/src/form/fields/slug/ui.tsx +++ b/packages/keystatic/src/form/fields/slug/ui.tsx @@ -9,7 +9,7 @@ import { useContext, useState } from 'react'; import { FormFieldInputProps } from '../../api'; import { SlugFieldContext, PathContext } from '../text/path-slug-context'; -import { validateText } from '../text/validateText'; +import { validateText, slugIsTaken } from '../text/validateText'; const emptySet = new Set(); @@ -25,8 +25,15 @@ export function SlugFieldInput( const slugContext = useContext(SlugFieldContext); const path = useContext(PathContext); const slugInfo = - path.length === 1 && path[0] === slugContext?.field - ? slugContext + path.length === 1 && + typeof path[0] === 'string' && + slugContext?.fields.includes(path[0]) + ? // the last slug field is validated for uniqueness, the other slug + // fields only get format validation since their uniqueness is + // enforced via the composite slug + path[0] === slugContext.field + ? slugContext + : { slugs: emptySet, glob: slugContext.glob } : { slugs: emptySet, glob: '*' as const }; const [blurredName, setBlurredName] = useState(false); @@ -37,9 +44,9 @@ export function SlugFieldInput( ); const generateSlug = (name: string) => { const generated = props.naiveGenerateSlug(name); - if (slugInfo.slugs.has(generated)) { + if (slugIsTaken(slugInfo, generated)) { let i = 1; - while (slugInfo.slugs.has(`${generated}-${i}`)) { + while (slugIsTaken(slugInfo, `${generated}-${i}`)) { i++; } return `${generated}-${i}`; diff --git a/packages/keystatic/src/form/fields/text/index.tsx b/packages/keystatic/src/form/fields/text/index.tsx index 157c30e01..d07ac3d9a 100644 --- a/packages/keystatic/src/form/fields/text/index.tsx +++ b/packages/keystatic/src/form/fields/text/index.tsx @@ -1,9 +1,8 @@ -import { Glob } from '../../../config'; import { FormFieldStoredValue } from '../../..'; import { SlugFormField } from '../../api'; import { FieldDataError } from '../error'; import { TextFieldInput } from '#field-ui/text'; -import { validateText } from './validateText'; +import { validateText, SlugValidationInfo } from './validateText'; function parseAsNormalField(value: FormFieldStoredValue) { if (value === undefined) { @@ -45,10 +44,7 @@ export function text({ multiline?: boolean; }): SlugFormField { min = Math.max(isRequired ? 1 : 0, min); - function validate( - value: string, - slugField: { slugs: Set; glob: Glob } | undefined - ) { + function validate(value: string, slugField: SlugValidationInfo | undefined) { const message = validateText(value, min, max, label, slugField, pattern); if (message !== undefined) { throw new FieldDataError(message); 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..1c4919bbe 100644 --- a/packages/keystatic/src/form/fields/text/path-slug-context.tsx +++ b/packages/keystatic/src/form/fields/text/path-slug-context.tsx @@ -106,8 +106,15 @@ export function AddToPathProvider(props: { export type SlugFieldInfo = { field: string; + /** all slug fields of the collection, in path segment order; `field` is always the last entry */ + fields: readonly string[]; slugs: Set; glob: Glob; + /** + * the composite slug of the other slug fields (e.g. `en` when the slug is + * `en/my-post`), used to scope the uniqueness check of the last slug field + */ + prefix?: string; }; export const SlugFieldContext = createContext( diff --git a/packages/keystatic/src/form/fields/text/ui.tsx b/packages/keystatic/src/form/fields/text/ui.tsx index 7b1ae6b20..9d224038c 100644 --- a/packages/keystatic/src/form/fields/text/ui.tsx +++ b/packages/keystatic/src/form/fields/text/ui.tsx @@ -4,6 +4,8 @@ import { TextArea, TextField } from '@keystar/ui/text-field'; import { validateText } from './validateText'; import { PathContext, SlugFieldContext } from './path-slug-context'; +const emptySet = new Set(); + export function TextFieldInput( props: FormFieldInputProps & { multiline: boolean; @@ -18,6 +20,17 @@ export function TextFieldInput( const [blurred, setBlurred] = useState(false); const slugContext = useContext(SlugFieldContext); const path = useContext(PathContext); + const slugInfo = + path.length === 1 && + typeof path[0] === 'string' && + slugContext?.fields.includes(path[0]) + ? // the last slug field is validated for uniqueness, the other slug + // fields only get format validation since their uniqueness is + // enforced via the composite slug + path[0] === slugContext.field + ? slugContext + : { slugs: emptySet, glob: slugContext.glob } + : undefined; return ( ; + glob: Glob; + prefix?: string; +}; + +export function slugIsTaken( + slugInfo: { slugs: Set; prefix?: string }, + val: string +) { + return slugInfo.slugs.has( + slugInfo.prefix ? `${slugInfo.prefix}/${val}` : val + ); +} + export function validateText( val: string, min: number, max: number, fieldLabel: string, - slugInfo: { slugs: Set; glob: Glob } | undefined, + slugInfo: SlugValidationInfo | undefined, pattern: { regex: RegExp; message?: string } | undefined ) { if (val.length < min) { @@ -48,7 +63,7 @@ export function validateText( if (/^\s|\s$/.test(val)) { return `${fieldLabel} must not start or end with spaces`; } - if (slugInfo.slugs.has(val)) { + if (slugIsTaken(slugInfo, 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..a1efa63dc 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,23 @@ 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 === '*' + glob === '*' && segments === 1 ? (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), - })); + : (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 +270,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 +282,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 +332,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 +380,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 f80e9a0dcdf01423f1cdf5af93e6f61d3d10aeca Mon Sep 17 00:00:00 2001 From: bkindler <9107850+bkindler@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:53:23 +0200 Subject: [PATCH 2/2] fix(collection): tighten slugFields reader types, path validation, and listing depth - thread the inferred slugFields tuple through Collection, Entry and CollectionReader so secondary slug fields read as their ReaderValueAsSlugField instead of ValueForReading - reject paths with static segments between or after the wildcard segments when multiple slugFields are used, since slug composition only supports a static prefix followed by the wildcard segments - stop the collection listing walker at the slug segment depth for fixed-depth paths instead of traversing every descendant --- packages/keystatic/src/app/duplicate-slug.ts | 2 +- packages/keystatic/src/app/path-utils.ts | 15 ++ .../keystatic/src/app/slug-fields.test.ts | 84 +++++++++- packages/keystatic/src/config.tsx | 42 +++-- packages/keystatic/src/reader/generic.ts | 154 ++++++++---------- packages/keystatic/src/reader/github.ts | 4 +- packages/keystatic/src/reader/index.ts | 4 +- 7 files changed, 201 insertions(+), 104 deletions(-) diff --git a/packages/keystatic/src/app/duplicate-slug.ts b/packages/keystatic/src/app/duplicate-slug.ts index 682c11265..4ad21a8aa 100644 --- a/packages/keystatic/src/app/duplicate-slug.ts +++ b/packages/keystatic/src/app/duplicate-slug.ts @@ -4,7 +4,7 @@ import { ComponentSchema } from '../form/api'; export function useDuplicateSlug( duplicateInitalState: Record | undefined, - collectionConfig: Collection, string> + collectionConfig: Collection, string, readonly string[]> ) { return useMemo(() => { if (duplicateInitalState) { diff --git a/packages/keystatic/src/app/path-utils.ts b/packages/keystatic/src/app/path-utils.ts index 3e8e12187..eda68ca3d 100644 --- a/packages/keystatic/src/app/path-utils.ts +++ b/packages/keystatic/src/app/path-utils.ts @@ -71,6 +71,21 @@ export function getSlugFieldsForCollection( } to match its slug fields but it has ${globSegments.length} (${path})` ); } + if (slugFields.length > 1) { + const segments = fixPath(path).split('/'); + const firstGlob = segments.findIndex( + segment => segment === '*' || segment === '**' + ); + if ( + segments + .slice(firstGlob) + .some(segment => segment !== '*' && segment !== '**') + ) { + throw new Error( + `The path for the "${collection}" collection must consist of a static prefix followed by its * segments when using multiple slugFields but it is ${path}` + ); + } + } return slugFields; } diff --git a/packages/keystatic/src/app/slug-fields.test.ts b/packages/keystatic/src/app/slug-fields.test.ts index 207e193d8..bfb223cae 100644 --- a/packages/keystatic/src/app/slug-fields.test.ts +++ b/packages/keystatic/src/app/slug-fields.test.ts @@ -1,10 +1,11 @@ /** @vitest-environment node */ import { expect, test } from 'vitest'; -import { collection } from '../config'; +import { collection, config } from '../config'; import type { Config } from '../config'; import { fields } from '../form/api'; import { clientSideValidateProp } from '../form/errors'; +import { createReader } from '../reader'; import { collectionReader } from '../reader/generic'; import type { MinimalFs } from '../reader/generic'; import { @@ -101,6 +102,24 @@ test('getSlugFieldsForCollection throws when combining slugFields with a ** segm ); }); +test('getSlugFieldsForCollection throws for a static segment between glob segments', () => { + const config = makeConfig({ + blog: { ...blogCollection, path: 'content/*/posts/*/' }, + }); + expect(() => getSlugFieldsForCollection(config, 'blog')).toThrowError( + /static prefix followed by its \* segments/ + ); +}); + +test('getSlugFieldsForCollection throws for a static suffix with multiple slugFields', () => { + const config = makeConfig({ + blog: { ...blogCollection, path: 'posts/*/*/index.mdoc' }, + }); + expect(() => getSlugFieldsForCollection(config, 'blog')).toThrowError( + /static prefix followed by its \* segments/ + ); +}); + test('getCollectionItemPath maps a composite slug to nested directories', () => { const config = makeConfig({ blog: blogCollection }); expect(getCollectionItemPath(config, 'blog', 'en/hello-world')).toBe( @@ -110,11 +129,17 @@ test('getCollectionItemPath maps a composite slug to nested directories', () => test('getCollectionItemSlugSuffix uses the part after the last glob', () => { const config = makeConfig({ - blog: { ...blogCollection, path: 'posts/*/*/index.mdoc' }, + blog: { + label: 'Blog', + path: 'posts/*/index.mdoc', + slugField: 'title', + format: 'yaml', + schema: { title: fields.slug({ name: { label: 'Title' } }) }, + }, }); expect(getCollectionItemSlugSuffix(config, 'blog')).toBe('/index.mdoc'); const simple = makeConfig({ - blog: { ...blogCollection, path: 'posts/*/' }, + blog: { ...blogCollection, path: 'posts/*/*' }, }); expect(getCollectionItemSlugSuffix(simple, 'blog')).toBe(''); }); @@ -242,6 +267,25 @@ test('reader lists and reads multi-slug entries', async () => { expect(await reader.read('en/deep/hello-world')).toBe(null); }); +test('reader list does not traverse below the entry depth', async () => { + const config = makeConfig({ blog: blogCollection }); + const base = makeLocalFs({ + 'posts/en/hello-world/index.yaml': 'title: ignored\n', + 'posts/en/hello-world/assets/image.png': 'binary', + }); + const readdirPaths: string[] = []; + const fsReader: MinimalFs = { + ...base, + readdir: async path => { + readdirPaths.push(path); + return base.readdir(path); + }, + }; + const reader = collectionReader('blog', config, fsReader); + expect(await reader.list()).toEqual(['en/hello-world']); + expect(readdirPaths).not.toContain('posts/en/hello-world/'); +}); + test('reader still works for single slug collections', async () => { const config = makeConfig({ blog: { @@ -354,3 +398,37 @@ test('slugFields are checked against the path at the type level', () => { }; void typeAssertions; }); + +test('reader entry types follow the slugFields', () => { + const typeAssertions = async () => { + const reader = createReader( + '', + config({ + storage: { kind: 'local' }, + collections: { + blog: collection({ + label: 'Blog', + path: 'posts/*/*/', + slugField: 'title', + slugFields: ['locale', 'title'], + schema: blogCollection.schema, + }), + }, + }) + ); + const entry = await reader.collections.blog.readOrThrow('en/hello-world'); + // fields.text as a secondary slug field reads as null + const locale: null = entry.locale; + // fields.slug as the slugField reads as the stored name + const title: string = entry.title; + // @ts-expect-error - a secondary slug field does not read as a string + const localeAsString: string = entry.locale; + // @ts-expect-error - the slugField does not read as { name, slug } + const titleAsObject: { name: string; slug: string } = entry.title; + void locale; + void title; + void localeAsString; + void titleAsObject; + }; + void typeAssertions; +}); diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 7e849ce87..f659d991a 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -33,6 +33,7 @@ type GlobSegmentsInPath< export type Collection< Schema extends Record, SlugField extends string, + SlugFields extends readonly string[] = readonly [SlugField], > = { label: string; path?: `${string}/${Glob}` | `${string}/${Glob}/${string}`; @@ -50,7 +51,7 @@ export type Collection< * two star segments in `path`, an entry with the locale `en` and the * slug `my-post` is stored at `content/blog/en/my-post`. */ - slugFields?: readonly string[]; + slugFields?: SlugFields; schema: Schema; }; @@ -105,9 +106,9 @@ type GitHubStorageConfig = { export type GitHubConfig< Collections extends { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; } = { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; }, Singletons extends { [key: string]: Singleton>; @@ -124,9 +125,9 @@ type LocalStorageConfig = { kind: 'local' }; export type LocalConfig< Collections extends { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; } = { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; }, Singletons extends { [key: string]: Singleton>; @@ -143,9 +144,9 @@ type CloudStorageConfig = { kind: 'cloud' } & CommonRemoteStorageConfig; export type CloudConfig< Collections extends { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; } = { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; }, Singletons extends { [key: string]: Singleton>; @@ -161,9 +162,9 @@ export type CloudConfig< export type Config< Collections extends { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; } = { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; }, Singletons extends { [key: string]: Singleton>; @@ -184,7 +185,7 @@ export type Config< export function config< Collections extends { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; }, Singletons extends { [key: string]: Singleton>; @@ -226,7 +227,7 @@ export function collection< ] | undefined = undefined, >( - collection: Collection & { + collection: Omit, 'slugFields'> & { columns?: { [K in keyof Schema]: Schema[K] extends | FormField< @@ -264,8 +265,23 @@ export function collection< : 'the last entry of slugFields must be the slugField' : unknown); } -): Collection { - return collection; +): Collection< + Schema, + SlugField & string, + [SlugFields] extends [readonly string[]] + ? SlugFields + : readonly [SlugField & string] +> { + // the validation intersections on slugFields are opaque to the compiler, + // but they always resolve to either the tuple itself or an error message + // that rejects the call site + return collection as Collection< + Schema, + SlugField & string, + [SlugFields] extends [readonly string[]] + ? SlugFields + : readonly [SlugField & string] + >; } export function singleton>( diff --git a/packages/keystatic/src/reader/generic.ts b/packages/keystatic/src/reader/generic.ts index a1efa63dc..558a5ce06 100644 --- a/packages/keystatic/src/reader/generic.ts +++ b/packages/keystatic/src/reader/generic.ts @@ -40,52 +40,55 @@ type OptionalChain< Key extends keyof (T & {}), > = T extends {} ? T[Key] : undefined; +type ValueForCollectionEntryReading< + Schema extends Record, + Key extends keyof Schema, + SlugFields extends readonly string[], + ResolveLinkedFiles extends boolean | undefined, +> = Key extends SlugFields[number] + ? Schema[Key] extends SlugFormField + ? ReaderValueAsSlugField + : ValueForReadingWithMode + : ValueForReadingWithMode; + export type Entry< - CollectionOrSingleton extends Collection | Singleton, -> = CollectionOrSingleton extends Collection - ? CollectionEntry + CollectionOrSingleton extends Collection | Singleton, +> = CollectionOrSingleton extends Collection + ? CollectionEntry : CollectionOrSingleton extends Singleton ? SingletonEntry : never; export type EntryWithResolvedLinkedFiles< - CollectionOrSingleton extends Collection | Singleton, -> = CollectionOrSingleton extends Collection - ? CollectionEntryWithResolvedLinkedFiles + CollectionOrSingleton extends Collection | Singleton, +> = CollectionOrSingleton extends Collection + ? CollectionEntryWithResolvedLinkedFiles : CollectionOrSingleton extends Singleton ? SingletonEntryWithResolvedLinkedFiles : never; type CollectionEntryWithResolvedLinkedFiles< Schema extends Record, - SlugField extends string, + SlugFields extends readonly string[], > = { - [Key in keyof Schema]: SlugField extends Key - ? Schema[Key] extends SlugFormField< - any, - any, - any, - infer SlugSerializedValue - > - ? SlugSerializedValue - : ValueForReadingDeep - : ValueForReadingDeep; + [Key in keyof Schema]: ValueForCollectionEntryReading< + Schema, + Key, + SlugFields, + true + >; }; type CollectionEntry< Schema extends Record, - SlugField extends string, + SlugFields extends readonly string[], > = { - [Key in keyof Schema]: SlugField extends Key - ? Schema[Key] extends SlugFormField< - any, - any, - any, - infer SlugSerializedValue - > - ? SlugSerializedValue - : ValueForReading - : ValueForReading; + [Key in keyof Schema]: ValueForCollectionEntryReading< + Schema, + Key, + SlugFields, + false + >; }; type SingletonEntryWithResolvedLinkedFiles< @@ -97,29 +100,19 @@ type SingletonEntry> = export type CollectionReader< Schema extends Record, - SlugField extends string, + SlugFields extends readonly string[], > = { read: ( slug: string, ...opts: Opts & [opts?: EntryReaderOpts] ) => Promise< | { - [Key in keyof Schema]: SlugField extends Key - ? Schema[Key] extends SlugFormField< - any, - any, - any, - infer SlugSerializedValue - > - ? SlugSerializedValue - : ValueForReadingWithMode< - Schema[Key], - OptionalChain - > - : ValueForReadingWithMode< - Schema[Key], - OptionalChain - >; + [Key in keyof Schema]: ValueForCollectionEntryReading< + Schema, + Key, + SlugFields, + OptionalChain + >; } | null >; @@ -127,22 +120,12 @@ export type CollectionReader< slug: string, ...opts: Opts & [opts?: EntryReaderOpts] ) => Promise<{ - [Key in keyof Schema]: SlugField extends Key - ? Schema[Key] extends SlugFormField< - any, - any, - any, - infer SlugSerializedValue - > - ? SlugSerializedValue - : ValueForReadingWithMode< - Schema[Key], - OptionalChain - > - : ValueForReadingWithMode< - Schema[Key], - OptionalChain - >; + [Key in keyof Schema]: ValueForCollectionEntryReading< + Schema, + Key, + SlugFields, + OptionalChain + >; }>; all: ( ...opts: Opts & [opts?: EntryReaderOpts] @@ -150,22 +133,12 @@ export type CollectionReader< { slug: string; entry: { - [Key in keyof Schema]: SlugField extends Key - ? Schema[Key] extends SlugFormField< - any, - any, - any, - infer SlugSerializedValue - > - ? SlugSerializedValue - : ValueForReadingWithMode< - Schema[Key], - OptionalChain - > - : ValueForReadingWithMode< - Schema[Key], - OptionalChain - >; + [Key in keyof Schema]: ValueForCollectionEntryReading< + Schema, + Key, + SlugFields, + OptionalChain + >; }; }[] >; @@ -199,15 +172,20 @@ export type MinimalFs = { async function getAllEntries( parent: string, - fsReader: MinimalFs + fsReader: MinimalFs, + maxDepth: number, + depth = 1 ): Promise<{ entry: DirEntry; name: string }[]> { return ( await Promise.all( (await fsReader.readdir(parent)).map(async dirent => { const name = `${parent}${dirent.name}`; const entry = { entry: dirent, name }; - if (dirent.kind === 'directory') { - return [entry, ...(await getAllEntries(`${name}/`, fsReader))]; + if (dirent.kind === 'directory' && depth < maxDepth) { + return [ + entry, + ...(await getAllEntries(`${name}/`, fsReader, maxDepth, depth + 1)), + ]; } return entry; }) @@ -229,7 +207,13 @@ const listCollection = cache(async function listCollection( entry, name: entry.name, })) - : (await getAllEntries(`${collectionPath}/`, fsReader)) + : ( + await getAllEntries( + `${collectionPath}/`, + fsReader, + glob === '**' ? Infinity : segments + ) + ) .map(x => ({ entry: x.entry, name: x.name.slice(collectionPath.length + 1), @@ -453,7 +437,11 @@ export function singletonReader( export type BaseReader< Collections extends { - [key: string]: Collection, string>; + [key: string]: Collection< + Record, + string, + readonly string[] + >; }, Singletons extends { [key: string]: Singleton>; @@ -462,7 +450,7 @@ export type BaseReader< collections: { [Key in keyof Collections]: CollectionReader< Collections[Key]['schema'], - Collections[Key]['slugField'] + Exclude >; }; singletons: { diff --git a/packages/keystatic/src/reader/github.ts b/packages/keystatic/src/reader/github.ts index 38ae187da..0ce65d303 100644 --- a/packages/keystatic/src/reader/github.ts +++ b/packages/keystatic/src/reader/github.ts @@ -17,7 +17,7 @@ export type { Entry, EntryWithResolvedLinkedFiles } from './generic'; export type Reader< Collections extends { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; }, Singletons extends { [key: string]: Singleton>; @@ -26,7 +26,7 @@ export type Reader< export function createGitHubReader< Collections extends { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; }, Singletons extends { [key: string]: Singleton>; diff --git a/packages/keystatic/src/reader/index.ts b/packages/keystatic/src/reader/index.ts index 3347496bc..b6d2ab0cc 100644 --- a/packages/keystatic/src/reader/index.ts +++ b/packages/keystatic/src/reader/index.ts @@ -12,7 +12,7 @@ export type { Entry, EntryWithResolvedLinkedFiles } from './generic'; export type Reader< Collections extends { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; }, Singletons extends { [key: string]: Singleton>; @@ -23,7 +23,7 @@ export type Reader< export function createReader< Collections extends { - [key: string]: Collection, string>; + [key: string]: Collection, string, readonly string[]>; }, Singletons extends { [key: string]: Singleton>;