feat(collection): let a new item's slug be computed from its field values (#340) - #1622
Open
Bryandero98 wants to merge 2 commits into
Open
Bryandero98 wants to merge 2 commits into
Bryandero98 wants to merge 2 commits into
Conversation
…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 detectedLatest commit: 0c4653e The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #340 — but with a different shape than the
path: (fields) => stringsketch in the issue. Wrote up why on the issue before starting (#340 (comment)), then found an even simpler route while digging into howslugFieldis actually wired — full reasoning below.Adds an optional
computeSlugon a collection config:computeSlugonly 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) => stringI 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'scollectionReader) 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
slugFieldis used turned up the way around that problem: a**collection already treats a slug containing/as a nested path —app/utils.ts'sgetEntriesInCollectionWithTreeKey/handleDirectorydiscovers it with a plain recursive tree walk keyed off the slug alone, no per-item field parsing needed. So instead of makingpathcomputed (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 constrainsSlugFieldto a schema key whose field extendsSlugFormField, soslugFielditself can't become a plain function without breaking that.Scope
computeSlugonly affects the moment a new item's slug is first decided — editing/renaming an existing item is untouched (ItemPage.tsxkeeps 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 increate-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 whencomputeSlugdiverges from the rawslugFieldvalue).getSlugFromStateitself, 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 runwas 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 viagit stashthat this reproduces on an untouchedmain, 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. BumpedtestTimeoutto 20s in the rootvitest.config.ts.Test plan
getSlugForNewItem(app/utils.test.ts): falls back to the normalslugFieldvalue when nocomputeSlugis set, usescomputeSlug's return value when set (ignoring the rawslugFieldvalue), 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