diff --git a/.changeset/wild-collections-nest.md b/.changeset/wild-collections-nest.md new file mode 100644 index 000000000..7b3d4a675 --- /dev/null +++ b/.changeset/wild-collections-nest.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': minor +--- + +Add an optional `computeSlug` to `collection()` that derives a new item's slug from its own field values instead of `slugField`'s input. For a collection whose `path` uses the `**` glob, the returned slug can contain `/` to nest the item under sub-directories (e.g. deriving `2026/09/my-post` from a date field). diff --git a/packages/keystatic/src/app/create-item.tsx b/packages/keystatic/src/app/create-item.tsx index 6f8d2417e..9fa643702 100644 --- a/packages/keystatic/src/app/create-item.tsx +++ b/packages/keystatic/src/app/create-item.tsx @@ -40,7 +40,7 @@ import { useYJsValue } from './useYJsValue'; import { getCollectionFormat, getCollectionItemPath, - getSlugFromState, + getSlugForNewItem, isGitHubConfig, useShowRestoredDraftMessage, } from './utils'; @@ -275,7 +275,7 @@ function CreateItemLocal(props: { useShowRestoredDraftMessage(props.draft, state, undefined); - const slug = getSlugFromState(collectionConfig, state); + const slug = getSlugForNewItem(collectionConfig, state); const formatInfo = getCollectionFormat(props.config, props.collection); @@ -368,7 +368,7 @@ function CreateItemCollab(props: { const state = useYJsValue(schema, props.map) as Record; const previewProps = usePreviewPropsFromY(schema, props.map, state); - const slug = getSlugFromState(collectionConfig, state); + const slug = getSlugForNewItem(collectionConfig, state); const formatInfo = getCollectionFormat(props.config, props.collection); @@ -455,7 +455,7 @@ function CreateItemInner(props: { return; } if (await props.createItem()) { - const slug = getSlugFromState(collectionConfig, props.state); + const slug = getSlugForNewItem(collectionConfig, props.state); router.push(`${collectionPath}/item/${encodeURIComponent(slug)}`); toastQueue.positive('Entry created', { timeout: 5000 }); // TODO: l10n } @@ -464,14 +464,14 @@ function CreateItemInner(props: { const onCopy = () => { copyEntryToClipboard(props.state, formatInfo, collectionConfig.schema, { field: collectionConfig.slugField, - value: getSlugFromState(collectionConfig, props.state), + value: getSlugForNewItem(collectionConfig, props.state), }); }; const onPaste = async () => { const entry = await getPastedEntry(formatInfo, collectionConfig.schema, { field: collectionConfig.slugField, - slug: getSlugFromState(collectionConfig, props.state), + slug: getSlugForNewItem(collectionConfig, props.state), }); if (entry) { setValueToPreviewProps(entry, props.previewProps); @@ -603,7 +603,7 @@ function CreateItemInner(props: { if ( await props.createItem({ branch: newBranch, sha: baseCommit }) ) { - const slug = getSlugFromState(collectionConfig, props.state); + const slug = getSlugForNewItem(collectionConfig, props.state); router.push( `/keystatic/branch/${encodeURIComponent( @@ -630,7 +630,7 @@ function CreateItemInner(props: { { if (await props.createItem()) { - const slug = getSlugFromState(collectionConfig, props.state); + const slug = getSlugForNewItem(collectionConfig, props.state); router.push( `${collectionPath}/item/${encodeURIComponent(slug)}` ); diff --git a/packages/keystatic/src/app/utils.test.ts b/packages/keystatic/src/app/utils.test.ts new file mode 100644 index 000000000..c7018facc --- /dev/null +++ b/packages/keystatic/src/app/utils.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from 'vitest'; +import { fields } from '../form/api'; +import { getSlugForNewItem } from './utils'; + +const schema = { + title: fields.slug({ name: { label: 'Title' } }), + publishDate: fields.text({ label: 'Publish Date' }), +}; + +test('getSlugForNewItem falls back to the slugField value when no computeSlug is set', () => { + const collectionConfig = { slugField: 'title', schema }; + const state = { + title: { name: 'Hello World', slug: 'hello-world' }, + publishDate: '2026-09-09', + }; + expect(getSlugForNewItem(collectionConfig, state)).toBe('hello-world'); +}); + +test('getSlugForNewItem uses computeSlug when set, ignoring the slugField value', () => { + const collectionConfig = { + slugField: 'title', + schema, + computeSlug: (fields: Record) => + `${fields.publishDate}/${(fields.title as { slug: string }).slug}`, + }; + const state = { + title: { name: 'Hello World', slug: 'hello-world' }, + publishDate: '2026-09-09', + }; + expect(getSlugForNewItem(collectionConfig, state)).toBe( + '2026-09-09/hello-world' + ); +}); + +test('getSlugForNewItem lets computeSlug return a nested slug for a "**" collection', () => { + const collectionConfig = { + slugField: 'title', + schema, + computeSlug: () => '2026/09/deeply/nested-post', + }; + const state = { + title: { name: 'Ignored', slug: 'ignored' }, + publishDate: '2026-09-09', + }; + expect(getSlugForNewItem(collectionConfig, state)).toBe( + '2026/09/deeply/nested-post' + ); +}); diff --git a/packages/keystatic/src/app/utils.ts b/packages/keystatic/src/app/utils.ts index fd2a11e15..9735747f1 100644 --- a/packages/keystatic/src/app/utils.ts +++ b/packages/keystatic/src/app/utils.ts @@ -92,6 +92,26 @@ export function getSlugFromState( return field.serializeWithSlug(value).slug; } +/** + * The slug to use when creating a *new* item: `collectionConfig.computeSlug` + * when the collection defines one, falling back to the normal + * `slugField`-driven value otherwise. Existing items always keep reading + * their slug the normal way (via {@link getSlugFromState} directly) — this is + * only for the moment a new item's slug/path is decided. + */ +export function getSlugForNewItem( + collectionConfig: { + slugField: string; + schema: Record; + computeSlug?: (fields: Record) => string; + }, + state: Record +) { + return collectionConfig.computeSlug + ? collectionConfig.computeSlug(state) + : getSlugFromState(collectionConfig, state); +} + export function getEntriesInCollectionWithTreeKey( config: Config, collection: string, diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 4c95368f3..f13efbc7c 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -30,6 +30,19 @@ export type Collection< template?: string; parseSlugForSort?: (slug: string) => string | number; slugField: SlugField; + /** + * Computes the slug for a *new* item from its field values instead of + * reading it from `slugField`'s own input. Existing items are unaffected — + * this only runs once, when an item is first created. + * + * The returned string may contain `/` to nest the item under + * sub-directories (e.g. deriving `2026/09/my-post` from a date field), the + * same way a manually-typed nested slug already works for a collection + * whose `path` uses the `**` glob — the collection is still listed and + * read the normal way, no path-resolution changes are needed on top of + * this. + */ + computeSlug?: (fields: Record) => string; schema: Schema; }; diff --git a/vitest.config.ts b/vitest.config.ts index cebc110b1..b60ea2e03 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,6 +18,11 @@ export default defineConfig({ oxc: { jsx: { runtime: 'automatic', development: false } }, test: { reporters: ['verbose'], + // The default 5s budget includes each file's own import/transform cost, + // not just its test bodies — on a slower machine or a cold cache, the + // first test in a heavier file (e.g. the markdoc editor suites) can miss + // it even though every individual test runs in well under a second. + testTimeout: 20_000, fakeTimers: { shouldAdvanceTime: true, toFake: [