diff --git a/.changeset/configurable-collection-path.md b/.changeset/configurable-collection-path.md new file mode 100644 index 000000000..89451228e --- /dev/null +++ b/.changeset/configurable-collection-path.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': minor +--- + +Support configurable collection paths via dynamic functions and field placeholders (e.g. `path: ({ locale }) => \`src/content/blog/${locale}/*\`` or `path: 'src/content/blog/{locale}/*'`) diff --git a/packages/keystatic/src/app/CollectionPage.tsx b/packages/keystatic/src/app/CollectionPage.tsx index 1604f34cc..b65dd0444 100644 --- a/packages/keystatic/src/app/CollectionPage.tsx +++ b/packages/keystatic/src/app/CollectionPage.tsx @@ -54,9 +54,10 @@ import { getCollectionFormat, getCollectionItemPath, getCollectionPath, + getCollectionPathInfo, + getSlugFieldSlug, getEntriesInCollectionWithTreeKey, getEntryDataFilepath, - getSlugGlobForCollection, isLocalConfig, } from './utils'; import { notFound } from './not-found'; @@ -356,7 +357,7 @@ function CollectionTable( ] as const; }) ); - const glob = getSlugGlobForCollection(props.config, props.collection); + const pathInfo = getCollectionPathInfo(props.config, props.collection); const rootSchema = { kind: 'object' as const, fields: collection.schema }; const parsedEntries = new Map>(); for (const [slug, dataFile] of entries) { @@ -385,8 +386,8 @@ function CollectionTable( ); } return schema.reader.parseWithSlug(value, { - slug, - glob, + slug: getSlugFieldSlug(pathInfo, slug), + glob: pathInfo.glob, }); } } diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx index f19cc2de7..e392eebc0 100644 --- a/packages/keystatic/src/app/ItemPage.tsx +++ b/packages/keystatic/src/app/ItemPage.tsx @@ -76,6 +76,7 @@ import { getCollection, getCollectionFormat, getCollectionItemPath, + getCollectionPathInfo, getRepoUrl, getSlugFromState, isGitHubConfig, @@ -148,7 +149,7 @@ function ItemPageInner( : undefined; const { push, replace } = router; - const slugInfo = useSlugFieldInfo(collection, itemSlug); + const slugInfo = useSlugFieldInfo(collection, itemSlug, props.state); const [deleteResult, deleteItem, resetDeleteItem] = useDeleteItem({ initialFiles: props.initialFiles, @@ -198,10 +199,15 @@ function ItemPageInner( }); const onPaste = useEventCallback(async () => { - const entry = await getPastedEntry(formatInfo, collectionConfig.schema, { - field: collectionConfig.slugField, - slug: getSlugFromState(collectionConfig, props.state), - }); + const entry = await getPastedEntry( + formatInfo, + collectionConfig.schema, + { + field: collectionConfig.slugField, + slug: getSlugFromState(collectionConfig, props.state), + }, + getCollectionPathInfo(config, collection) + ); if (entry) { setValueToPreviewProps(entry, props.previewProps); toastQueue.positive('Entry pasted', { @@ -418,6 +424,7 @@ function LocalItemPage( schema, state, slugField: collectionConfig.slugField, + path: collectionConfig.path, }); const slug = getSlugFromState(collectionConfig, state); @@ -503,6 +510,7 @@ function CollabItemPage(props: ItemPageProps & { map: Y.Map }) { schema, state, slugField: collectionConfig.slugField, + path: collectionConfig.path, }); const futureBasePath = getCollectionItemPath(config, collection, slug); @@ -873,6 +881,7 @@ function ItemPageOuterWrapper(props: ItemPageWrapperProps) { format: getCollectionFormat(props.config, props.collection), schema: collectionConfig.schema, slug: { field: collectionConfig.slugField, slug: stored.slug }, + pathInfo: getCollectionPathInfo(props.config, props.collection), }, stored.files ); @@ -895,6 +904,7 @@ function ItemPageOuterWrapper(props: ItemPageWrapperProps) { schema: collectionConfig.schema, format, slug: slugInfo, + pathInfo: getCollectionPathInfo(props.config, props.collection), }); const currentBranch = useCurrentBranch(); diff --git a/packages/keystatic/src/app/create-item.tsx b/packages/keystatic/src/app/create-item.tsx index 6f8d2417e..152466813 100644 --- a/packages/keystatic/src/app/create-item.tsx +++ b/packages/keystatic/src/app/create-item.tsx @@ -40,6 +40,7 @@ import { useYJsValue } from './useYJsValue'; import { getCollectionFormat, getCollectionItemPath, + getCollectionPathInfo, getSlugFromState, isGitHubConfig, useShowRestoredDraftMessage, @@ -96,6 +97,7 @@ function CreateItemWrapper(props: { format, schema: collectionConfig.schema, slug: { field: collectionConfig.slugField, slug: stored.slug }, + pathInfo: getCollectionPathInfo(props.config, props.collection), }, stored.files ); @@ -133,8 +135,8 @@ function CreateItemWrapper(props: { schema: collectionConfig.schema, format, slug, + pathInfo: getCollectionPathInfo(props.config, props.collection), }); - const duplicateInitalState = isFromTemplate && itemData.kind === 'loaded' && @@ -297,6 +299,7 @@ function CreateItemLocal(props: { schema, state, slugField: collectionConfig.slugField, + path: collectionConfig.path, }); const hasCreated = createResult.kind === 'updated' || createResult.kind === 'loading'; @@ -446,7 +449,7 @@ function CreateItemInner(props: { createResult.kind === 'updated' || createResult.kind === 'loading' ? props.slug : undefined; - const slugInfo = useSlugFieldInfo(props.collection, currentSlug); + const slugInfo = useSlugFieldInfo(props.collection, currentSlug, props.state); const onCreate = async () => { if (createResult.kind === 'loading') return; @@ -469,10 +472,15 @@ function CreateItemInner(props: { }; const onPaste = async () => { - const entry = await getPastedEntry(formatInfo, collectionConfig.schema, { - field: collectionConfig.slugField, - slug: getSlugFromState(collectionConfig, props.state), - }); + const entry = await getPastedEntry( + formatInfo, + collectionConfig.schema, + { + field: collectionConfig.slugField, + slug: getSlugFromState(collectionConfig, props.state), + }, + getCollectionPathInfo(config, props.collection) + ); if (entry) { setValueToPreviewProps(entry, props.previewProps); toastQueue.positive('Entry pasted', { diff --git a/packages/keystatic/src/app/entry-clipboard.ts b/packages/keystatic/src/app/entry-clipboard.ts index 533b99657..fc902a4c2 100644 --- a/packages/keystatic/src/app/entry-clipboard.ts +++ b/packages/keystatic/src/app/entry-clipboard.ts @@ -3,7 +3,7 @@ import { toastQueue } from '@keystar/ui/toast'; import { ComponentSchema } from '../form/api'; import { serializeEntryToFiles } from './updating'; import { parseEntry } from './useItemData'; -import { FormatInfo, getEntryDataFilepath } from './utils'; +import { CollectionPathInfo, FormatInfo, getEntryDataFilepath } from './utils'; import * as s from 'superstruct'; const keystaticEntryAttributeSchema = s.type({ @@ -22,7 +22,8 @@ function parseEntryFromHtml( html: string, format: FormatInfo, schema: Record, - slugField: string | undefined + slugField: string | undefined, + pathInfo: CollectionPathInfo | undefined ) { const parsedHtml = new DOMParser().parseFromString(html, 'text/html'); const pre = parsedHtml.querySelector('pre'); @@ -42,6 +43,7 @@ function parseEntryFromHtml( ? { field: slugField, slug: entryInfo.slug ?? '' } : undefined, requireFrontmatter: true, + pathInfo, }, files ).initialState; @@ -52,12 +54,20 @@ function parseEntryFromPlaintext( bytes: Uint8Array, format: FormatInfo, schema: Record, - slugInfo: { field: string; slug: string } | undefined + slugInfo: { field: string; slug: string } | undefined, + pathInfo: CollectionPathInfo | undefined ) { try { const dirpath = slugInfo?.slug ?? 'entry'; return parseEntry( - { dirpath, format, schema, slug: slugInfo, requireFrontmatter: true }, + { + dirpath, + format, + schema, + slug: slugInfo, + requireFrontmatter: true, + pathInfo, + }, new Map([[getEntryDataFilepath(dirpath, format), bytes]]) ).initialState; } catch {} @@ -66,7 +76,8 @@ function parseEntryFromPlaintext( export async function getPastedEntry( format: FormatInfo, schema: Record, - slugInfo: { field: string; slug: string } | undefined + slugInfo: { field: string; slug: string } | undefined, + pathInfo?: CollectionPathInfo ) { let clipboardItems: ClipboardItem[]; try { @@ -88,7 +99,13 @@ 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?.field, + pathInfo + ); if (entry) { return entry; } @@ -100,7 +117,8 @@ export async function getPastedEntry( new Uint8Array(text), format, schema, - slugInfo + slugInfo, + pathInfo ); if (entry) { return entry; diff --git a/packages/keystatic/src/app/path-utils.test.ts b/packages/keystatic/src/app/path-utils.test.ts new file mode 100644 index 000000000..8d1143594 --- /dev/null +++ b/packages/keystatic/src/app/path-utils.test.ts @@ -0,0 +1,553 @@ +/** @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 { validateText } from '../form/fields/text/validateText'; +import { + createPathProxy, + getCollectionItemPath, + getCollectionItemSlugSuffix, + getCollectionPath, + getCollectionPathInfo, + getSlugFieldSlug, + parseCollectionPath, +} from './path-utils'; +import { getEntriesInCollectionWithTreeKey, getSlugFromState } from './utils'; +import { treeEntriesToTreeNodes, TreeEntry } from './trees'; + +function makeConfig(collection: Record): Config { + return { + storage: { kind: 'local' }, + collections: collection, + } as unknown as Config; +} + +const blogSchema = { + locale: fields.text({ label: 'Locale' }), + title: fields.slug({ name: { label: 'Title' } }), +}; + +const blogCollection = { + label: 'Blog', + path: 'src/content/blog/{locale}/*', + slugField: 'title', + format: 'yaml' as const, + schema: blogSchema, +}; + +test('createPathProxy interpolates property accesses into {field} placeholders', () => { + const proxy = createPathProxy(); + expect(proxy.locale).toBe('{locale}'); + expect(proxy.publishDate).toBe('{publishDate}'); + const fn1 = ({ locale }: { locale: string }) => + `src/content/blog/${locale}/*`; + expect(fn1(proxy as unknown as { locale: string })).toBe( + 'src/content/blog/{locale}/*' + ); + const fn2 = ({ publishDate }: { publishDate: string }) => + `src/content/blog/${publishDate}/*`; + expect(fn2(proxy as unknown as { publishDate: string })).toBe( + 'src/content/blog/{publishDate}/*' + ); +}); + +test('parseCollectionPath parses a plain glob path', () => { + expect(parseCollectionPath('posts/*')).toEqual({ + prefix: 'posts', + slugPattern: [{ kind: 'glob', glob: '*' }], + glob: '*', + suffix: '', + }); + expect(parseCollectionPath('posts/**/data')).toEqual({ + prefix: 'posts', + slugPattern: [{ kind: 'glob', glob: '**' }], + glob: '**', + suffix: 'data', + }); +}); + +test('parseCollectionPath parses field placeholders', () => { + expect(parseCollectionPath('src/content/blog/{locale}/*')).toEqual({ + prefix: 'src/content/blog', + slugPattern: [ + { kind: 'field', name: 'locale' }, + { kind: 'glob', glob: '*' }, + ], + glob: '*', + suffix: '', + }); + // static segments can appear between placeholders and the glob + expect(parseCollectionPath('a/{x}/b/{y}/*/index')).toEqual({ + prefix: 'a', + slugPattern: [ + { kind: 'field', name: 'x' }, + { kind: 'static', value: 'b' }, + { kind: 'field', name: 'y' }, + { kind: 'glob', glob: '*' }, + ], + glob: '*', + suffix: 'index', + }); +}); + +test('parseCollectionPath rejects invalid paths', () => { + expect(() => parseCollectionPath('a/*/*')).toThrow( + 'can only contain a single * or **' + ); + expect(() => parseCollectionPath('a/*/{x}')).toThrow( + 'cannot appear after the * or **' + ); + expect(() => parseCollectionPath('a/{x/*')).toThrow('invalid path segment'); + expect(() => parseCollectionPath('posts')).toThrow( + 'must end with /* or /** or include /*/ or /**/' + ); +}); + +test('getCollectionPathInfo validates placeholders against the schema', () => { + const config = makeConfig({ + blog: { + ...blogCollection, + path: 'blog/{missing}/*', + }, + }); + expect(() => getCollectionPathInfo(config, 'blog')).toThrow( + "does not match a field in the collection's schema" + ); + const nonForm = makeConfig({ + blog: { + ...blogCollection, + path: 'blog/{group}/*', + schema: { + ...blogSchema, + group: fields.object({ inner: fields.text({ label: 'Inner' }) }), + }, + }, + }); + expect(() => getCollectionPathInfo(nonForm, 'blog')).toThrow( + 'must be a form field' + ); +}); + +test('getCollectionPathInfo supports function paths', () => { + const config = makeConfig({ + blog: { + ...blogCollection, + path: ({ locale }: { locale: string }) => `src/content/blog/${locale}/*`, + }, + }); + const info = getCollectionPathInfo(config, 'blog'); + expect(info).toEqual({ + prefix: 'src/content/blog', + slugPattern: [ + { kind: 'field', name: 'locale' }, + { kind: 'glob', glob: '*' }, + ], + glob: '*', + suffix: '', + }); + expect(getCollectionPath(config, 'blog')).toBe('src/content/blog'); +}); + +test('getCollectionPath returns the static prefix for a placeholder path', () => { + const config = makeConfig({ blog: blogCollection }); + expect(getCollectionPath(config, 'blog')).toBe('src/content/blog'); + expect(getCollectionItemSlugSuffix(config, 'blog')).toBe(''); + expect(getCollectionItemPath(config, 'blog', 'en/my-post')).toBe( + 'src/content/blog/en/my-post' + ); +}); + +test('getCollectionItemPath handles empty prefix', () => { + const config = makeConfig({ + blog: { + ...blogCollection, + path: '{locale}/*', + }, + }); + expect(getCollectionPath(config, 'blog')).toBe(''); + expect(getCollectionItemPath(config, 'blog', 'en/my-post')).toBe( + 'en/my-post' + ); +}); + +test('getSlugFromState composes a slug from placeholder field values', () => { + const collectionConfig = { + slugField: 'title', + path: 'src/content/blog/{locale}/*', + schema: blogSchema, + }; + const state = { + locale: 'en', + title: { name: 'My Post', slug: 'my-post' }, + }; + expect(getSlugFromState(collectionConfig, state)).toBe('en/my-post'); +}); + +test('getSlugFromState supports function paths', () => { + const collectionConfig = { + slugField: 'title', + path: ({ locale }: { locale: string }) => `src/content/blog/${locale}/*`, + schema: blogSchema, + }; + const state = { + locale: 'fr', + title: { name: 'Mon Article', slug: 'mon-article' }, + }; + expect(getSlugFromState(collectionConfig, state)).toBe('fr/mon-article'); +}); + +test('getSlugFromState fills static segments and supports non-text fields', () => { + const collectionConfig = { + slugField: 'title', + path: 'posts/{kind}/blog/{year}/*', + schema: { + kind: fields.select({ + label: 'Kind', + options: [ + { label: 'News', value: 'news' }, + { label: 'Guide', value: 'guide' }, + ], + defaultValue: 'news', + }), + year: fields.integer({ label: 'Year' }), + title: fields.slug({ name: { label: 'Title' } }), + }, + }; + const state = { + kind: 'guide', + year: 2026, + title: { name: 'My Post', slug: 'my-post' }, + }; + expect(getSlugFromState(collectionConfig, state)).toBe( + 'guide/blog/2026/my-post' + ); +}); + +test('getSlugFromState is unchanged for paths without placeholders', () => { + const collectionConfig = { + slugField: 'title', + path: 'posts/*', + schema: blogSchema, + }; + const state = { locale: 'en', title: { name: 'X', slug: 'my-post' } }; + expect(getSlugFromState(collectionConfig, state)).toBe('my-post'); +}); + +test('getSlugFieldSlug returns the glob portion of a slug', () => { + const placeholder = parseCollectionPath('blog/{locale}/*'); + expect(getSlugFieldSlug(placeholder, 'en/my-post')).toBe('my-post'); + const staticMiddle = parseCollectionPath('a/{x}/b/*'); + expect(getSlugFieldSlug(staticMiddle, 'en/b/my-post')).toBe('my-post'); + const globstar = parseCollectionPath('posts/{locale}/**'); + expect(getSlugFieldSlug(globstar, 'en/a/b/post')).toBe('a/b/post'); + const plain = parseCollectionPath('posts/*'); + expect(getSlugFieldSlug(plain, 'my-post')).toBe('my-post'); +}); + +function entry(path: string): TreeEntry { + return { mode: '100644', type: 'blob', sha: `sha-${path}`, path }; +} + +function dirEntry(path: string): TreeEntry { + return { mode: '040000', type: 'tree', sha: `sha-${path}`, path }; +} + +function pathsToTreeEntries(paths: string[]): TreeEntry[] { + const dirs = new Set(); + const entries: TreeEntry[] = []; + for (const path of paths) { + const segments = path.split('/'); + for (let i = 1; i < segments.length; i++) { + dirs.add(segments.slice(0, i).join('/')); + } + entries.push(entry(path)); + } + return [...dirs].map(dirEntry).concat(entries); +} + +test('getEntriesInCollectionWithTreeKey lists entries under placeholders', () => { + const config = makeConfig({ + blog: { + ...blogCollection, + path: 'src/content/blog/{locale}/*/', + }, + }); + const tree = treeEntriesToTreeNodes( + pathsToTreeEntries([ + 'src/content/blog/en/my-post/index.yaml', + 'src/content/blog/en/other-post/index.yaml', + 'src/content/blog/fr/my-post/index.yaml', + // wrong depth — should not be listed + 'src/content/blog/index.yaml', + 'src/content/blog/en/my-post/extra/deep/index.yaml', + ]) + ); + const slugs = getEntriesInCollectionWithTreeKey(config, 'blog', tree).map( + x => x.slug + ); + expect(slugs.sort()).toEqual(['en/my-post', 'en/other-post', 'fr/my-post']); +}); + +test('getEntriesInCollectionWithTreeKey supports function paths', () => { + const config = makeConfig({ + blog: { + ...blogCollection, + path: ({ locale }: { locale: string }) => `src/content/blog/${locale}/*/`, + }, + }); + const tree = treeEntriesToTreeNodes( + pathsToTreeEntries([ + 'src/content/blog/en/my-post/index.yaml', + 'src/content/blog/fr/my-post/index.yaml', + ]) + ); + const slugs = getEntriesInCollectionWithTreeKey(config, 'blog', tree).map( + x => x.slug + ); + expect(slugs.sort()).toEqual(['en/my-post', 'fr/my-post']); +}); + +test('getEntriesInCollectionWithTreeKey matches static segments in the slug region', () => { + const config = makeConfig({ + blog: { ...blogCollection, path: 'blog/{locale}/posts/*' }, + }); + const tree = treeEntriesToTreeNodes( + pathsToTreeEntries([ + 'blog/en/posts/my-post.yaml', + 'blog/en/wrong/ignored.yaml', + 'blog/fr/posts/other-post.yaml', + ]) + ); + const slugs = getEntriesInCollectionWithTreeKey(config, 'blog', tree).map( + x => x.slug + ); + expect(slugs.sort()).toEqual(['en/posts/my-post', 'fr/posts/other-post']); +}); + +test('getEntriesInCollectionWithTreeKey is unchanged for plain glob paths', () => { + const config = makeConfig({ + blog: { ...blogCollection, path: 'blog/*' }, + }); + const tree = treeEntriesToTreeNodes( + pathsToTreeEntries(['blog/my-post.yaml', 'blog/nested/deeper.yaml']) + ); + const slugs = getEntriesInCollectionWithTreeKey(config, 'blog', tree).map( + x => x.slug + ); + expect(slugs).toEqual(['my-post']); +}); + +function memFs(files: Record): MinimalFs { + const dirs = new Map< + string, + { name: string; kind: 'file' | 'directory' }[] + >(); + for (const path of Object.keys(files)) { + const segments = path.split('/'); + for (let i = 0; i < segments.length; i++) { + const dir = segments.slice(0, i).join('/'); + const name = segments[i]; + const kind = i === segments.length - 1 ? 'file' : 'directory'; + const list = dirs.get(dir) ?? []; + if (!list.some(x => x.name === name)) { + list.push({ name, kind }); + dirs.set(dir, list); + } + } + } + return { + async readdir(path: string) { + return dirs.get(path.replace(/\/$/, '')) ?? []; + }, + async readFile(path: string) { + const contents = files[path]; + return contents === undefined ? null : new TextEncoder().encode(contents); + }, + async fileExists(path: string) { + return path in files; + }, + }; +} + +test('reader lists and reads entries from placeholder paths', async () => { + const config = makeConfig({ blog: blogCollection }); + const fs = memFs({ + 'src/content/blog/en/my-post.yaml': 'title: My Post\nlocale: en\n', + 'src/content/blog/fr/my-post.yaml': 'title: Mon Post\nlocale: fr\n', + 'src/content/blog/top-level.yaml': 'title: Nope\nlocale: en\n', + 'src/content/blog/en/nested/too-deep.yaml': 'title: Nope\nlocale: en\n', + }); + const reader = collectionReader('blog', config, fs); + expect((await reader.list()).sort()).toEqual(['en/my-post', 'fr/my-post']); + expect(await reader.read('en/my-post')).toEqual({ + locale: 'en', + title: 'My Post', + }); + // wrong depth and static mismatches are rejected + expect(await reader.read('my-post')).toBeNull(); + expect(await reader.read('en/nested/too-deep')).toBeNull(); +}); + +test('reader lists and reads entries from function paths', async () => { + const config = makeConfig({ + blog: { + ...blogCollection, + path: ({ locale }: { locale: string }) => `src/content/blog/${locale}/*`, + }, + }); + const fs = memFs({ + 'src/content/blog/en/my-post.yaml': 'title: My Post\nlocale: en\n', + 'src/content/blog/ja/tokyo.yaml': 'title: Tokyo\nlocale: ja\n', + }); + const reader = collectionReader('blog', config, fs); + expect((await reader.list()).sort()).toEqual(['en/my-post', 'ja/tokyo']); + expect(await reader.read('ja/tokyo')).toEqual({ + locale: 'ja', + title: 'Tokyo', + }); +}); + +test('reader read for a placeholder path is consistent across locales', async () => { + const config = makeConfig({ + blog: { + ...blogCollection, + path: 'blog/{locale}/*', + format: 'yaml' as const, + }, + }); + const fs = memFs({ + 'blog/en/my-post.yaml': 'title: My Post\nlocale: en\n', + 'blog/fr/my-post.yaml': 'title: Mon Post\nlocale: fr\n', + }); + const reader = collectionReader('blog', config, fs); + // the same slug field value can exist under different path segments + expect(await reader.read('en/my-post')).toEqual({ + locale: 'en', + title: 'My Post', + }); + expect(await reader.read('fr/my-post')).toEqual({ + locale: 'fr', + title: 'Mon Post', + }); +}); + +test('validateText validates placeholder path segments and composite uniqueness', () => { + const slugs = new Set(['en/my-post', 'fr/other-post']); + const glob = '*'; + + // Same slug in another locale is allowed + expect( + validateText( + 'my-post', + 1, + Infinity, + 'Slug', + { + slugs, + glob, + pathSegments: [{ field: 'locale', segment: 'fr' }], + }, + undefined + ) + ).toBeUndefined(); + + // Same slug in the same locale is rejected + expect( + validateText( + 'my-post', + 1, + Infinity, + 'Slug', + { + slugs, + glob, + pathSegments: [{ field: 'locale', segment: 'en' }], + }, + undefined + ) + ).toBe('Slug must be unique'); + + // Invalid placeholder values + expect( + validateText( + 'new-post', + 1, + Infinity, + 'Slug', + { + slugs, + glob, + pathSegments: [{ field: 'locale', segment: '' }], + }, + undefined + ) + ).toBe( + 'The "locale" field is used in the collection\'s path, so it must not be empty' + ); + + expect( + validateText( + 'new-post', + 1, + Infinity, + 'Slug', + { + slugs, + glob, + pathSegments: [{ field: 'locale', segment: 'en/sub' }], + }, + undefined + ) + ).toBe( + 'The "locale" field is used in the collection\'s path, so it must not contain slashes' + ); + + expect( + validateText( + 'new-post', + 1, + Infinity, + 'Slug', + { + slugs, + glob, + pathSegments: [{ field: 'locale', segment: '..' }], + }, + undefined + ) + ).toBe( + 'The "locale" field is used in the collection\'s path, so it must not be ..' + ); +}); + +test('supports publishDate example from issue #340', () => { + const dateSchema = { + publishDate: fields.date({ label: 'Published date' }), + title: fields.slug({ name: { label: 'Title' } }), + }; + const collectionConfig = { + label: 'Blog', + path: ({ publishDate }: { publishDate: string }) => + `src/content/blog/${publishDate}/*`, + slugField: 'title', + schema: dateSchema, + }; + const config = makeConfig({ blog: collectionConfig }); + const info = getCollectionPathInfo(config, 'blog'); + expect(info.prefix).toBe('src/content/blog'); + expect(info.slugPattern).toEqual([ + { kind: 'field', name: 'publishDate' }, + { kind: 'glob', glob: '*' }, + ]); + const slug = getSlugFromState(collectionConfig, { + publishDate: '2026-10-30', + title: { name: 'Launch Day', slug: 'launch-day' }, + }); + expect(slug).toBe('2026-10-30/launch-day'); + expect(getCollectionItemPath(config, 'blog', slug)).toBe( + 'src/content/blog/2026-10-30/launch-day' + ); +}); diff --git a/packages/keystatic/src/app/path-utils.ts b/packages/keystatic/src/app/path-utils.ts index 97fb22caa..ae98fb1bb 100644 --- a/packages/keystatic/src/app/path-utils.ts +++ b/packages/keystatic/src/app/path-utils.ts @@ -5,12 +5,123 @@ import { memoize } from './memoize'; export function fixPath(path: string) { return path.replace(/^\.?\/+/, '').replace(/\/*$/, ''); } +export type CollectionPathSegment = + | { kind: 'static'; value: string } + | { kind: 'field'; name: string } + | { kind: 'glob'; glob: Glob }; + +export type CollectionPathInfo = { + /** The static directory that all entries in the collection live under. */ + prefix: string; + /** + * The segments an entry's slug is made up of, in order. `field` segments + * are filled in from the value of the named field in the collection's + * schema and the `glob` segment is filled in with the collection's slug + * field's slug. + */ + slugPattern: CollectionPathSegment[]; + glob: Glob; + /** The static path appended to the path of every entry (e.g. `data`). */ + suffix: string; +}; + +const globSegment = /^\*\*?$/; +const fieldSegment = /^\{([^{}]+)\}$/; + +export function createPathProxy(): Record { + return new Proxy( + {}, + { + get(_target, prop) { + if (typeof prop === 'symbol') { + return undefined; + } + return `{${prop}}`; + }, + } + ); +} + +export const parseCollectionPath = memoize(_parseCollectionPath); + +function _parseCollectionPath(path: string): CollectionPathInfo { + const segments = fixPath(path) + .split('/') + .filter(segment => segment !== ''); + let globIndex = -1; + let glob: Glob = '*'; + for (const [i, segment] of segments.entries()) { + if (globSegment.test(segment)) { + if (globIndex !== -1) { + throw new Error( + `Collection paths can only contain a single * or ** segment but "${path}" contains more than one` + ); + } + globIndex = i; + glob = segment as Glob; + continue; + } + if (segment.includes('{') || segment.includes('}')) { + if (!fieldSegment.test(segment)) { + throw new Error( + `Collection path "${path}" contains an invalid path segment "${segment}"` + ); + } + if (globIndex !== -1) { + throw new Error( + `Field placeholders like "${segment}" cannot appear after the * or ** segment in collection path "${path}"` + ); + } + } + } + if (globIndex === -1) { + throw new Error( + `Collection path must end with /* or /** or include /*/ or /**/ but got "${path}"` + ); + } + const firstDynamicIndex = segments.findIndex( + segment => globSegment.test(segment) || fieldSegment.test(segment) + ); + const slugPattern: CollectionPathSegment[] = segments + .slice(firstDynamicIndex, globIndex + 1) + .map(segment => { + if (globSegment.test(segment)) { + return { kind: 'glob', glob } as const; + } + const fieldMatch = fieldSegment.exec(segment); + if (fieldMatch) { + return { kind: 'field', name: fieldMatch[1] } as const; + } + return { kind: 'static', value: segment } as const; + }); + return { + prefix: segments.slice(0, firstDynamicIndex).join('/'), + slugPattern, + glob, + suffix: segments.slice(globIndex + 1).join('/'), + }; +} + +export function getSlugFieldSlug(pathInfo: CollectionPathInfo, slug: string) { + const split = slug.split('/'); + const globIndex = pathInfo.slugPattern.length - 1; + if (pathInfo.glob === '**') { + return split.slice(globIndex).join('/'); + } + return split[globIndex] ?? slug; +} const collectionPath = /\/\*\*?(?:$|\/)/; -function getConfiguredCollectionPath(config: Config, collection: string) { +function getConfiguredCollectionPath( + config: Config, + collection: string +): string { const collectionConfig = config.collections![collection]; - const path = collectionConfig.path ?? `${collection}/*/`; + const rawPath = collectionConfig.path; + const path = + (typeof rawPath === 'function' ? rawPath(createPathProxy()) : rawPath) ?? + `${collection}/*/`; if (!collectionPath.test(path)) { throw new Error( `Collection path must end with /* or /** or include /*/ or /**/ but ${collection} has ${path}` @@ -19,10 +130,35 @@ function getConfiguredCollectionPath(config: Config, collection: string) { return path; } +export const getCollectionPathInfo = memoize(_getCollectionPathInfo); + +function _getCollectionPathInfo( + config: Config, + collection: string +): CollectionPathInfo { + const collectionConfig = config.collections![collection]; + const info = parseCollectionPath( + getConfiguredCollectionPath(config, collection) + ); + for (const segment of info.slugPattern) { + if (segment.kind !== 'field') continue; + const field = collectionConfig.schema[segment.name]; + if (!field) { + throw new Error( + `Collection path placeholder "{${segment.name}}" in ${collection} does not match a field in the collection's schema` + ); + } + if (field.kind !== 'form') { + throw new Error( + `Collection path placeholder "{${segment.name}}" in ${collection} must be a form field` + ); + } + } + return info; +} + export function getCollectionPath(config: Config, collection: string) { - const configuredPath = getConfiguredCollectionPath(config, collection); - const path = fixPath(configuredPath.replace(/\*\*?.*$/, '')); - return path; + return getCollectionPathInfo(config, collection).prefix; } export function getCollectionFormat(config: Config, collection: string) { @@ -40,7 +176,7 @@ export function getCollectionItemPath( ) { const basePath = getCollectionPath(config, collection); const suffix = getCollectionItemSlugSuffix(config, collection); - return `${basePath}/${slug}${suffix}`; + return basePath ? `${basePath}/${slug}${suffix}` : `${slug}${suffix}`; } export function getEntryDataFilepath(dir: string, formatInfo: FormatInfo) { @@ -53,19 +189,16 @@ export function getSlugGlobForCollection( config: Config, collection: string ): Glob { - const collectionPath = getConfiguredCollectionPath(config, collection); - return collectionPath.includes('**') ? '**' : '*'; + return getCollectionPathInfo(config, collection).glob; } export function getCollectionItemSlugSuffix( config: Config, collection: string ) { - const configuredPath = getConfiguredCollectionPath(config, collection); - const path = fixPath(configuredPath.replace(/^[^*]+\*\*?/, '')); - return path ? `/${path}` : ''; + const suffix = getCollectionPathInfo(config, collection).suffix; + return suffix ? `/${suffix}` : ''; } - export function getSingletonPath(config: Config, singleton: string) { if (config.singletons![singleton].path?.includes('*')) { throw new Error( @@ -95,7 +228,7 @@ function _getFormatInfo( const path = type === 'collections' ? getConfiguredCollectionPath(config, key) - : collectionOrSingleton.path ?? `${key}/`; + : config.singletons![key].path ?? `${key}/`; const dataLocation = path.endsWith('/') ? 'index' : 'outer'; const { schema, format = 'yaml' } = collectionOrSingleton; if (typeof format === 'string') { diff --git a/packages/keystatic/src/app/slugs.tsx b/packages/keystatic/src/app/slugs.tsx index 2f829a355..8c7e4e534 100644 --- a/packages/keystatic/src/app/slugs.tsx +++ b/packages/keystatic/src/app/slugs.tsx @@ -1,12 +1,14 @@ import { useMemo } from 'react'; import { getSlugGlobForCollection } from './path-utils'; import { useSlugsInCollection } from './useSlugsInCollection'; +import { getCollectionPathFieldSegments } from './utils'; import { SlugFieldInfo } from '../form/fields/text/path-slug-context'; import { useConfig } from './shell/context'; export function useSlugFieldInfo( collection: string, - slugToExclude?: string + slugToExclude?: string, + state?: Record ): SlugFieldInfo { const config = useConfig(); const allSlugs = useSlugsInCollection(collection); @@ -21,6 +23,10 @@ export function useSlugFieldInfo( field: collectionConfig.slugField, slugs, glob: getSlugGlobForCollection(config, collection), + pathSegments: + state === undefined + ? undefined + : getCollectionPathFieldSegments(collectionConfig, state), }; - }, [allSlugs, collection, config, slugToExclude]); + }, [allSlugs, collection, config, slugToExclude, state]); } diff --git a/packages/keystatic/src/app/useHasChanged.ts b/packages/keystatic/src/app/useHasChanged.ts index 2f6007737..8caede80f 100644 --- a/packages/keystatic/src/app/useHasChanged.ts +++ b/packages/keystatic/src/app/useHasChanged.ts @@ -1,6 +1,7 @@ import isEqual from 'fast-deep-equal'; import { useCallback, useMemo, useState } from 'react'; import { ComponentSchema, ObjectField } from '../form/api'; +import { CollectionPath } from '../config'; import { getSlugFromState } from './utils'; import { serializeProps } from '../form/serialize-props'; import { useData } from './useData'; @@ -11,12 +12,17 @@ export function useHasChanged(args: { state: unknown; schema: ObjectField>; slugField: string | undefined; + path?: CollectionPath | string; }) { const serialize = useCallback( async (state: unknown) => { const slug = args.slugField ? getSlugFromState( - { schema: args.schema.fields, slugField: args.slugField }, + { + schema: args.schema.fields, + slugField: args.slugField, + path: args.path, + }, state as Record ) : undefined; @@ -40,7 +46,7 @@ export function useHasChanged(args: { ), }; }, - [args.schema, args.slugField] + [args.schema, args.slugField, args.path] ); const initialFilesForUpdate = useData( useCallback( diff --git a/packages/keystatic/src/app/useItemData.ts b/packages/keystatic/src/app/useItemData.ts index 48ba8d896..f04085f89 100644 --- a/packages/keystatic/src/app/useItemData.ts +++ b/packages/keystatic/src/app/useItemData.ts @@ -15,9 +15,11 @@ import { getDirectoriesForTreeKey, getTreeKey } from './tree-key'; import { TreeNode, getTreeNodeAtPath, TreeEntry, blobSha } from './trees'; import { LOADING, useData } from './useData'; import { + CollectionPathInfo, FormatInfo, getEntryDataFilepath, getPathPrefix, + getSlugFieldSlug, KEYSTATIC_CLOUD_API_URL, KEYSTATIC_CLOUD_HEADERS, MaybePromise, @@ -51,6 +53,7 @@ export function parseEntry( schema: Record; slug: { slug: string; field: string } | undefined; requireFrontmatter?: boolean; + pathInfo?: CollectionPathInfo; }, files: Map ) { @@ -124,7 +127,11 @@ export function parseEntry( if (schema.formKind !== 'slug') { throw new Error(`slugField is not a slug field`); } - return schema.parse(value, { slug: args.slug.slug }); + return schema.parse(value, { + slug: args.pathInfo + ? getSlugFieldSlug(args.pathInfo, args.slug.slug) + : args.slug.slug, + }); } if (schema.formKind === 'asset') { const suggestedFilenamePrefix = pathWithArrayFieldSlugs.join('/'); @@ -197,6 +204,7 @@ type UseItemDataArgs = { dirpath: string; format: FormatInfo; slug: { slug: string; field: string } | undefined; + pathInfo?: CollectionPathInfo; }; function getAllFilesInTree(tree: Map): TreeEntry[] { @@ -256,6 +264,7 @@ export function useItemData(args: UseItemDataArgs) { format: args.format, schema: args.schema, slug: args.slug, + pathInfo: args.pathInfo, }; const allBlobs = locationsForTreeKey .flatMap(dir => { @@ -312,6 +321,7 @@ export function useItemData(args: UseItemDataArgs) { args.config, args.schema, args.slug, + args.pathInfo, locationsForTreeKey, baseCommit, repoInfo, diff --git a/packages/keystatic/src/app/utils.ts b/packages/keystatic/src/app/utils.ts index fd2a11e15..81c074afc 100644 --- a/packages/keystatic/src/app/utils.ts +++ b/packages/keystatic/src/app/utils.ts @@ -1,15 +1,17 @@ import { base64UrlEncode } from '#base64'; import { isDefined } from 'emery'; -import { Config, GitHubConfig, LocalConfig } from '../config'; +import { Config, GitHubConfig, LocalConfig, CollectionPath } from '../config'; import { CloudConfig, ComponentSchema } from '..'; import { + createPathProxy, getCollectionFormat, getCollectionItemPath, getCollectionItemSlugSuffix, getCollectionPath, + getCollectionPathInfo, getDataFileExtension, - getSlugGlobForCollection, + parseCollectionPath, } from './path-utils'; import { collectDirectoriesUsedInSchema, getTreeKey } from './tree-key'; import { getTreeNodeAtPath, TreeNode } from './trees'; @@ -81,6 +83,7 @@ export function getSlugFromState( collectionConfig: { slugField: string; schema: Record; + path?: CollectionPath | string; }, state: Record ) { @@ -89,7 +92,116 @@ export function getSlugFromState( if (field.kind !== 'form' || field.formKind !== 'slug') { throw new Error(`slugField is not a slug field`); } - return field.serializeWithSlug(value).slug; + const slug = field.serializeWithSlug(value).slug; + if (collectionConfig.path === undefined) { + return slug; + } + const pathString = + typeof collectionConfig.path === 'function' + ? collectionConfig.path(createPathProxy()) + : collectionConfig.path; + const { slugPattern } = parseCollectionPath(pathString); + if (slugPattern.every(segment => segment.kind !== 'field')) { + return slug; + } + return slugPattern + .map(segment => + segment.kind === 'glob' + ? slug + : segment.kind === 'static' + ? segment.value + : getFieldPathSegment(collectionConfig.schema, state, segment.name) + ) + .join('/'); +} + +/** + * The value a `{field}` placeholder in a collection's path resolves to for a + * given entry's state. + */ +function getFieldPathSegment( + schema: Record, + state: Record, + name: string +): string { + const field = schema[name]; + if (!field) { + throw new Error( + `Collection path placeholder "{${name}}" does not match a field in the collection's schema` + ); + } + if (field.kind === 'form' && field.formKind === 'slug') { + try { + return field.serializeWithSlug(state[name]).slug; + } catch { + return ''; + } + } + if (field.kind !== 'form') { + throw new Error( + `Collection path placeholder "{${name}}" must be a form field` + ); + } + let serialized: unknown; + try { + serialized = (field.serialize as (value: unknown) => { value: unknown })( + state[name] + ).value; + } catch { + return ''; + } + if (serialized == null) { + return ''; + } + if (typeof serialized === 'string') { + return serialized; + } + if (typeof serialized === 'number' || typeof serialized === 'boolean') { + return String(serialized); + } + if (serialized instanceof Date) { + const str = String(serialized); + if (str && !str.includes('GMT') && str !== 'Invalid Date') { + return str; + } + return serialized.toISOString().split('T')[0]; + } + throw new Error( + `Collection path placeholder "{${name}}" must be a field that serializes to a string, number, boolean or Date` + ); +} + +/** + * The segments derived from `{field}` placeholders in a collection's path for + * a given entry's state, in path order. Returns `undefined` when the + * collection's path has no placeholders. + */ +export function getCollectionPathFieldSegments( + collectionConfig: { + schema: Record; + path?: CollectionPath | string; + }, + state: Record +) { + if (collectionConfig.path === undefined) { + return undefined; + } + const pathString = + typeof collectionConfig.path === 'function' + ? collectionConfig.path(createPathProxy()) + : collectionConfig.path; + const { slugPattern } = parseCollectionPath(pathString); + const fieldSegments = slugPattern.filter( + (segment): segment is { kind: 'field'; name: string } => + segment.kind === 'field' + ); + if (!fieldSegments.length) { + return undefined; + } + return fieldSegments.map(segment => ({ + field: segment.name, + segment: getFieldPathSegment(collectionConfig.schema, state, segment.name), + })); } export function getEntriesInCollectionWithTreeKey( @@ -101,27 +213,38 @@ export function getEntriesInCollectionWithTreeKey( const schema = object(collectionConfig.schema); const formatInfo = getCollectionFormat(config, collection); const extension = getDataFileExtension(formatInfo); - const glob = getSlugGlobForCollection(config, collection); + const pathInfo = getCollectionPathInfo(config, collection); const collectionPath = getCollectionPath(config, collection); const directory: Map = getTreeNodeAtPath(rootTree, collectionPath)?.children ?? new Map(); const entries: { key: string; slug: string; sha: string }[] = []; const directoriesUsedInSchema = [...collectDirectoriesUsedInSchema(schema)]; const suffix = getCollectionItemSlugSuffix(config, collection); - const possibleEntries = new Map(directory); - if (glob === '**') { - const handleDirectory = (dir: Map, prefix: string) => { - for (const [key, entry] of dir) { + const possibleEntries = new Map(); + const collectEntries = ( + dir: Map, + prefix: string, + patternIndex: number + ) => { + const segment = pathInfo.slugPattern[patternIndex]; + for (const [key, entry] of dir) { + const slug = `${prefix}${key}`; + if (segment.kind === 'glob' && segment.glob === '**') { + possibleEntries.set(slug, entry); if (entry.children) { - possibleEntries.set(`${prefix}${key}`, entry); - handleDirectory(entry.children, `${prefix}${key}/`); - } else { - possibleEntries.set(`${prefix}${key}`, entry); + collectEntries(entry.children, `${slug}/`, patternIndex); } + continue; } - }; - handleDirectory(directory, ''); - } + if (segment.kind === 'static' && segment.value !== key) continue; + if (patternIndex === pathInfo.slugPattern.length - 1) { + possibleEntries.set(slug, entry); + } else if (entry.children) { + collectEntries(entry.children, `${slug}/`, patternIndex + 1); + } + } + }; + collectEntries(directory, '', 0); for (const [key, entry] of possibleEntries) { if (formatInfo.dataLocation === 'index') { const actualEntry = getTreeNodeAtPath( diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 4c95368f3..d78d274b0 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -17,12 +17,22 @@ export type Format = }; export type EntryLayout = 'content' | 'form'; export type Glob = '*' | '**'; +export type CollectionPath< + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Schema extends Record = Record< + string, + ComponentSchema + >, +> = + | `${string}/${Glob}` + | `${string}/${Glob}/${string}` + | ((fields: any) => string); export type Collection< Schema extends Record, SlugField extends string, > = { label: string; - path?: `${string}/${Glob}` | `${string}/${Glob}/${string}`; + path?: CollectionPath; entryLayout?: EntryLayout; format?: Format; previewUrl?: string; diff --git a/packages/keystatic/src/form/api.tsx b/packages/keystatic/src/form/api.tsx index 4b87714cf..19780d9e2 100644 --- a/packages/keystatic/src/form/api.tsx +++ b/packages/keystatic/src/form/api.tsx @@ -72,7 +72,15 @@ export type SlugFormField< }; validate( value: ParsedValue, - extra: { slugField: { slugs: Set; glob: Glob } } | undefined + extra: + | { + slugField: { + slugs: Set; + glob: Glob; + pathSegments?: { field: string; segment: 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..6d3f124df 100644 --- a/packages/keystatic/src/form/errors.ts +++ b/packages/keystatic/src/form/errors.ts @@ -34,7 +34,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, + pathSegments: slugField.pathSegments, + }, }); return; } diff --git a/packages/keystatic/src/form/fields/slug/index.tsx b/packages/keystatic/src/form/fields/slug/index.tsx index e4dd5673e..2950d5857 100644 --- a/packages/keystatic/src/form/fields/slug/index.tsx +++ b/packages/keystatic/src/form/fields/slug/index.tsx @@ -103,7 +103,13 @@ export function slug(_args: { { slugField, }: { - slugField: { slugs: Set; glob: Glob } | undefined; + slugField: + | { + slugs: Set; + glob: Glob; + pathSegments?: { field: string; segment: 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..84ad5bc1c 100644 --- a/packages/keystatic/src/form/fields/text/index.tsx +++ b/packages/keystatic/src/form/fields/text/index.tsx @@ -47,7 +47,13 @@ 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; + pathSegments?: { field: string; segment: 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..671ecec3d 100644 --- a/packages/keystatic/src/form/fields/text/path-slug-context.tsx +++ b/packages/keystatic/src/form/fields/text/path-slug-context.tsx @@ -108,6 +108,7 @@ export type SlugFieldInfo = { field: string; slugs: Set; glob: Glob; + pathSegments?: { field: string; segment: string }[]; }; export const SlugFieldContext = createContext( diff --git a/packages/keystatic/src/form/fields/text/validateText.tsx b/packages/keystatic/src/form/fields/text/validateText.tsx index e50b04a4e..8b480f466 100644 --- a/packages/keystatic/src/form/fields/text/validateText.tsx +++ b/packages/keystatic/src/form/fields/text/validateText.tsx @@ -1,11 +1,35 @@ import { Glob } from '../../../config'; +function validatePathSegment(segment: string) { + if (segment === '') { + return 'must not be empty'; + } + if (/[\\/]/.test(segment)) { + return 'must not contain slashes'; + } + if (segment === '..') { + return 'must not be ..'; + } + if (segment === '.') { + return 'must not be .'; + } + if (/^\s|\s$/.test(segment)) { + return 'must not start or end with spaces'; + } +} + export function validateText( val: string, min: number, max: number, fieldLabel: string, - slugInfo: { slugs: Set; glob: Glob } | undefined, + slugInfo: + | { + slugs: Set; + glob: Glob; + pathSegments?: { field: string; segment: string }[]; + } + | undefined, pattern: { regex: RegExp; message?: string } | undefined ) { if (val.length < min) { @@ -48,7 +72,18 @@ export function validateText( if (/^\s|\s$/.test(val)) { return `${fieldLabel} must not start or end with spaces`; } - if (slugInfo.slugs.has(val)) { + if (slugInfo.pathSegments) { + for (const { field, segment } of slugInfo.pathSegments) { + const segmentError = validatePathSegment(segment); + if (segmentError !== undefined) { + return `The "${field}" field is used in the collection's path, so it ${segmentError}`; + } + } + } + const fullSlug = slugInfo.pathSegments?.length + ? `${slugInfo.pathSegments.map(x => x.segment).join('/')}/${val}` + : val; + if (slugInfo.slugs.has(fullSlug)) { return `${fieldLabel} must be unique`; } } diff --git a/packages/keystatic/src/reader/generic.ts b/packages/keystatic/src/reader/generic.ts index 9d6d4ba39..043612c81 100644 --- a/packages/keystatic/src/reader/generic.ts +++ b/packages/keystatic/src/reader/generic.ts @@ -8,15 +8,18 @@ import { ValueForReadingDeep, } from '../form/api'; import { + CollectionPathInfo, + CollectionPathSegment, FormatInfo, getCollectionFormat, getCollectionItemPath, getCollectionPath, + getCollectionPathInfo, getDataFileExtension, getEntryDataFilepath, getSingletonFormat, getSingletonPath, - getSlugGlobForCollection, + getSlugFieldSlug, } from '../app/path-utils'; import { parseProps } from '../form/parse-props'; import { loadDataFile } from '../app/required-files'; @@ -196,50 +199,77 @@ export type MinimalFs = { fileExists(path: string): Promise; }; -async function getAllEntries( +async function getEntriesMatchingSlugPattern( + fsReader: MinimalFs, parent: string, - fsReader: MinimalFs + slugPattern: CollectionPathSegment[], + patternIndex: number, + namePrefix: string ): 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))]; - } - return entry; - }) - ) - ).flat(); + const segment = slugPattern[patternIndex]; + const entries: { entry: DirEntry; name: string }[] = []; + for (const dirent of await fsReader.readdir(parent)) { + const name = `${namePrefix}${dirent.name}`; + if (segment.kind === 'glob' && segment.glob === '**') { + entries.push({ entry: dirent, name }); + if (dirent.kind === 'directory') { + entries.push( + ...(await getEntriesMatchingSlugPattern( + fsReader, + `${parent}${dirent.name}/`, + slugPattern, + patternIndex, + `${name}/` + )) + ); + } + continue; + } + if (segment.kind === 'static' && segment.value !== dirent.name) continue; + if (patternIndex === slugPattern.length - 1) { + entries.push({ entry: dirent, name }); + } else if (dirent.kind === 'directory') { + entries.push( + ...(await getEntriesMatchingSlugPattern( + fsReader, + `${parent}${dirent.name}/`, + slugPattern, + patternIndex + 1, + `${name}/` + )) + ); + } + } + return entries; } const listCollection = cache(async function listCollection( collectionPath: string, - glob: Glob, + pathInfo: CollectionPathInfo, 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), - })); + await getEntriesMatchingSlugPattern( + fsReader, + collectionPath ? `${collectionPath}/` : '', + pathInfo.slugPattern, + 0, + '' + ); return ( await Promise.all( entries.map(async x => { if (formatInfo.dataLocation === 'index') { if (x.entry.kind !== 'directory') return []; + const itemPath = collectionPath + ? `${collectionPath}/${x.name}` + : x.name; if ( !(await fsReader.fileExists( - getEntryDataFilepath(`${collectionPath}/${x.name}`, formatInfo) + getEntryDataFilepath(itemPath, formatInfo) )) ) { return []; @@ -265,7 +295,7 @@ export function collectionReader( const collectionPath = getCollectionPath(config, collection); const collectionConfig = config.collections![collection]; const schema = fields.object(collectionConfig.schema); - const glob = getSlugGlobForCollection(config, collection); + const pathInfo = getCollectionPathInfo(config, collection); const extension = getDataFileExtension(formatInfo); const read: CollectionReader['read'] = (slug, ...args) => @@ -278,12 +308,11 @@ export function collectionReader( fsReader, slug, collectionConfig.slugField, - glob + pathInfo ); const list = () => - listCollection(collectionPath, glob, formatInfo, extension, fsReader); - + listCollection(collectionPath, pathInfo, formatInfo, extension, fsReader); return { read, readOrThrow: async (...args) => { @@ -320,12 +349,26 @@ 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, field: string, pathInfo: CollectionPathInfo] ) { if (typeof slugInfo[0] === 'string') { - if (slugInfo[0].includes('\\')) return null; - const split = slugInfo[0].split('/'); - if (slugInfo[2] === '*' && split.length !== 1) return null; + const [slug, , pathInfo] = slugInfo; + if (slug.includes('\\')) return null; + const split = slug.split('/'); + if ( + pathInfo.glob === '**' + ? split.length < pathInfo.slugPattern.length + : split.length !== pathInfo.slugPattern.length + ) { + return null; + } + for (const [i, segment] of pathInfo.slugPattern.entries()) { + if (segment.kind === 'static' && split[i] !== segment.value) { + return null; + } + } if (split.includes('..') || split.includes('.')) return null; } const dataFile = await fsReader.readFile( @@ -366,12 +409,15 @@ const readItem = cache(async function readItem( }; } if (path.length === 1 && slugInfo[0] !== undefined) { - const [slug, slugField, glob] = slugInfo; + const [slug, slugField, pathInfo] = slugInfo; if (path[0] === slugField) { if (schema.formKind !== 'slug') { throw new Error(`Slug field ${slugInfo[1]} is not a slug field`); } - return schema.reader.parseWithSlug(value, { slug, glob }); + return schema.reader.parseWithSlug(value, { + slug: getSlugFieldSlug(pathInfo, slug), + glob: pathInfo.glob, + }); } } return schema.reader.parse(value);