Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wild-collections-nest.md
Original file line number Diff line number Diff line change
@@ -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).
16 changes: 8 additions & 8 deletions packages/keystatic/src/app/create-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { useYJsValue } from './useYJsValue';
import {
getCollectionFormat,
getCollectionItemPath,
getSlugFromState,
getSlugForNewItem,
isGitHubConfig,
useShowRestoredDraftMessage,
} from './utils';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -368,7 +368,7 @@ function CreateItemCollab(props: {
const state = useYJsValue(schema, props.map) as Record<string, unknown>;
const previewProps = usePreviewPropsFromY(schema, props.map, state);

const slug = getSlugFromState(collectionConfig, state);
const slug = getSlugForNewItem(collectionConfig, state);

const formatInfo = getCollectionFormat(props.config, props.collection);

Expand Down Expand Up @@ -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
}
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -630,7 +630,7 @@ function CreateItemInner(props: {
<ForkRepoDialog
onCreate={async () => {
if (await props.createItem()) {
const slug = getSlugFromState(collectionConfig, props.state);
const slug = getSlugForNewItem(collectionConfig, props.state);
router.push(
`${collectionPath}/item/${encodeURIComponent(slug)}`
);
Expand Down
48 changes: 48 additions & 0 deletions packages/keystatic/src/app/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) =>
`${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'
);
});
20 changes: 20 additions & 0 deletions packages/keystatic/src/app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ComponentSchema>;
computeSlug?: (fields: Record<string, unknown>) => string;
},
state: Record<string, unknown>
) {
return collectionConfig.computeSlug
? collectionConfig.computeSlug(state)
: getSlugFromState(collectionConfig, state);
}

export function getEntriesInCollectionWithTreeKey(
config: Config,
collection: string,
Expand Down
13 changes: 13 additions & 0 deletions packages/keystatic/src/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>) => string;
schema: Schema;
};

Expand Down
5 changes: 5 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down