Skip to content

feat(collection): let a new item's slug be computed from its field values (#340) - #1622

Open
Bryandero98 wants to merge 2 commits into
Thinkmill:mainfrom
Bryandero98:feat/computed-slug-for-new-items
Open

Bryandero98 wants to merge 2 commits into
Thinkmill:mainfrom
Bryandero98:feat/computed-slug-for-new-items

Conversation

@Bryandero98

Copy link
Copy Markdown

Summary

Fixes #340 — but with a different shape than the path: (fields) => string sketch in the issue. Wrote up why on the issue before starting (#340 (comment)), then found an even simpler route while digging into how slugField is actually wired — full reasoning below.

Adds an optional computeSlug on a collection config:

collections: {
  posts: collection({
    label: 'Posts',
    path: 'posts/**',
    slugField: 'title',
    computeSlug: fields => `${fields.publishDate}/${fields.title.slug}`,
    schema: { /* ... */ },
  }),
}

computeSlug only runs when a new item is created. Its return value can contain / to nest the item under sub-directories derived from its own field values — the same nesting a manually-typed slug already supports for a **-glob collection, just computed instead of typed.

Why not path: (fields) => string

I prototyped the issue's literal shape first (path: string | { base, resolve }) and posted it on the issue, but 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 locates a file. A path computed from fields can't be inverted for listing without either parsing every file up front, or introducing a second static anchor to walk instead.

Digging into how slugField is 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 discovers it with 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 — the reader discovers it exactly as it already does today, unchanged — and gets the same nested-by-field-values outcome the issue asked for.

It also sidesteps a compile-time conflict: collection()'s generic constrains SlugField to a schema key whose field extends SlugFormField, so slugField itself can't become a plain function without breaking that.

Scope

computeSlug only affects the moment a new item's slug is first decided — editing/renaming an existing item is untouched (ItemPage.tsx keeps reading/writing the real, already-fixed slug the normal way, same as before).

To keep the change surgical, the fallback logic lives in one 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), plus the five places downstream of a successful create that re-derive that same slug for navigation/copy/paste (these need to match what was actually written, or a successful create would navigate to the wrong URL when computeSlug diverges from the raw slugField value). getSlugFromState itself, and its ~18 other call sites (editing, array-field items, change-detection, validation, cloud serialization), are untouched.

Also fixed: a pre-existing test flake

Unrelated to the feature, but found while making sure pnpm vitest run was fully green before opening this: 4 tests in the markdoc editor suites (lists.test.tsx, pasting/from-other-editors.test.tsx) were failing on the default 5s timeout, always as the first test in their file. Confirmed via git stash that this reproduces on an untouched main, and that every individual test body finishes in well under a second — the 5s default budget includes each file's own import/transform cost, which pushed close to the limit on this machine for the heavier editor suites. Bumped testTimeout to 20s in the root vitest.config.ts.

Test plan

  • New unit tests for getSlugForNewItem (app/utils.test.ts): falls back to the normal slugField value when no computeSlug is set, uses computeSlug's return value when set (ignoring the raw slugField value), and supports a nested (/-containing) result.
  • pnpm vitest run — 58 files, 705 passed, 8 skipped, 0 failed.
  • pnpm check:types — clean.
  • pnpm lint — clean.

🤖 Generated with Claude Code

…lues (Thinkmill#340)

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 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0c4653e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@keystatic/core Minor
@keystatic/templates-astro Patch
@keystatic/templates-nextjs Patch
@keystatic/templates-remix Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhancement: more configurable collection path

1 participant