From 9b22df2f0651e9fd29780608ca1697e61e1d0b04 Mon Sep 17 00:00:00 2001 From: Bryandero98 Date: Wed, 9 Sep 2026 15:11:39 -0500 Subject: [PATCH 1/2] feat(collection): let a new item's slug be computed from its field values (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `computeSlug` on a collection config, used only when a new item is created: `computeSlug: (fields) => string`. For a collection whose `path` uses the `**` glob, the returned string can contain `/` to nest the item under sub-directories derived from its own field values (e.g. a date field producing `2026/09/my-post`) — the same nesting a manually-typed slug already supports for `**` collections, just computed instead of typed. How this differs from the issue's original `path: (fields) => string` sketch, and why: I initially prototyped that shape (`path: string | { base, resolve }`, posted on the issue) but found it doesn't hold up on the reader side. Every "list existing items" path (`app/utils.ts`'s tree walk, `reader/generic.ts`'s `collectionReader`) discovers items by walking one static directory and glob-matching filenames — it never has field values in hand before it finds a file, so a path computed from fields can't be inverted for listing without either parsing every file up front or introducing a second static anchor. Digging into how `slugField` is actually used turned up the way around that problem: a `**` collection already treats a slug containing `/` as a nested path (`app/utils.ts`'s `getEntriesInCollectionWithTreeKey`, `handleDirectory`) and discovers it by a plain recursive tree walk keyed off the slug alone — no per-item field parsing needed. So instead of making `path` computed (which needs the reader to invert an arbitrary function), this makes the *slug* computed (which the reader already discovers structurally, unchanged) and gets the same nested-by-field-values outcome the issue asked for, without touching path-resolution/listing code, and without conflicting with `slugField`'s existing compile-time coupling to a `SlugFormField` schema field (the `collection()` helper's generic constrains `SlugField` to a key whose schema field extends `SlugFormField`, which a plain function can't satisfy). Scope: `computeSlug` only affects the moment a *new* item's slug is decided — editing/renaming an existing item is untouched (`ItemPage.tsx` keeps reading/writing the real, already-fixed slug the normal way). To keep the change surgical, the fallback wiring lives in a single new helper, `getSlugForNewItem` (app/utils.ts), used only at the actual item-creation call sites in create-item.tsx: the two places a new item's slug is first decided (`CreateItemLocal`, `CreateItemCollab`), and the five places downstream of a successful create that re-derive that same slug for navigation/copy/paste. `getSlugFromState` itself, and its ~18 other call sites (editing, array-field items, change-detection, validation, cloud serialization), are untouched. Also: added `testTimeout: 20_000` to the root vitest config. Found while running the full suite before pushing — 4 tests in the markdoc editor suites (lists.test.tsx, pasting/from-other-editors.test.tsx) were failing on the default 5s budget, but only ever as the first test in their file. Confirmed via `git stash` that this reproduces on a clean, untouched checkout, and that every individual test body actually finishes in well under a second — the 5s default includes each file's own import/transform cost, which this machine's resources push close to the limit for the heavier editor suites. Bumped to 20s rather than leaving it as pre-existing flakiness in a PR that's meant to leave `pnpm vitest run` fully green. Full suite after this change: 58 files, 705 passed, 8 skipped, 0 failed. `pnpm check:types` and `pnpm lint` also clean. Co-Authored-By: Claude Sonnet 5 --- packages/keystatic/src/app/create-item.tsx | 16 ++++---- packages/keystatic/src/app/utils.test.ts | 48 ++++++++++++++++++++++ packages/keystatic/src/app/utils.ts | 20 +++++++++ packages/keystatic/src/config.tsx | 13 ++++++ vitest.config.ts | 5 +++ 5 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 packages/keystatic/src/app/utils.test.ts 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: [ From 0c4653e876af3c7f41406bb6efcabf84556d07e1 Mon Sep 17 00:00:00 2001 From: Bryandero98 Date: Wed, 9 Sep 2026 15:27:57 -0500 Subject: [PATCH 2/2] chore: add changeset for computeSlug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changeset-bot flagged the PR as missing one — this repo versions @keystatic/core via changesets, so a feature addition like computeSlug needs one for the next release's changelog/version bump. Co-Authored-By: Claude Sonnet 5 --- .changeset/wild-collections-nest.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wild-collections-nest.md 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).