Repository navigation
Enhancement: more configurable collection path #340
Description
Activity
Noted on the use case here 👍
We're pretty keen on keeping slugs static (i.e not having a function you need to call in order to know where entries live) because we need to load the content directly from GitHub over their API, and knowing that as part of static config is very helpful. The challenge with allowing a function to define it is that it becomes non-deterministic, while that's not the case in your examples above, there'd be nothing stopping you from doing:
path: () => `/content/${date.now()}/*`... chaos 💥
However we've discussed supporting support multiple
slugFieldsas an alternative, which may solve the problem:export default defineConfig({ // ... collections: { blog: collection({ // ... slugFields: ['locale', 'slug'], path: 'src/content/blog/*/*', // stars are replaced with values in order of the keys in the array above schema: { // ... slug: fields.slug(/* ... */), locale: fields.text({ label: "Locale" }) } }) } })What do you think? sound useful? can you see any limitations with this approach, if we added it, that you couldn't work around?
florian-lefebvre commented
on Jun 26, 2023 ContributorAuthorMore actionsSounds good 👍, a few thoughts:
- It should be also applied for usage with documents fields
- Maybe we could push this a bit further with Typescript string interpolation
Right now,
pathrequires either*or**to be included. By setting the slugFields, we could pass them to the path type to force including[locale]and[slug]in the path. I don't know if this is possible though 🤔.Wdyt?
- addedroadmapSomething we're planning to solveSomething we're planning to solve
on Jun 28, 2023 I had a chat with the team today about it, and there's some nuance that we need to solve, but I've put it on the roadmap.
I like your idea about using the name of the field in the path too, we'll see what we can come up with and whether that's possible 🙂
Can you expand a bit more on what you mean by "it should be applied for usage with document fields" too?
florian-lefebvre commented
on Jun 28, 2023 ContributorAuthorMore actionsAwesome thanks! My bad, I just checked my schema and I thought putting
format: { contentField: 'document' }changed the key to set for the path. Nevermind!florian-lefebvre commented
on Jul 26, 2023 ContributorAuthorMore actionsHey @JedWatson, hope you're doing well! Do you have any ETA about this? Or could I help in any way, maybe with some guidance? It has become a must for the project I'm working on and I can dedicate a bit of time to make changes to keystatic
florian-lefebvre commented
on Aug 1, 2023 ContributorAuthorMore actionsSounds good 👍, a few thoughts:
- It should be also applied for usage with documents fields
- Maybe we could push this a bit further with Typescript string interpolation
Right now,
pathrequires either*or**to be included. By setting the slugFields, we could pass them to the path type to force including[locale]and[slug]in the path. I don't know if this is possible though 🤔.Wdyt?
@JedWatson have a look at https://www.typescriptlang.org/play#code/C4TwDgpgBAxgFhGBrACgQ2HAPAZQDYCuA5lBAB7AQB2AJgM5R3ABOAllUQDRQAqpF1eoxbsiAPigBeKAG18xALr9KtBjIBQUKPJLkVQtFRBQA-FAAUfPYIYADACQBvQyAC+9pzvfOjr26e1CEgAuKCoIADcIZgBKKFDwqOZ1BU0AnjSEyOiAbnV1GAB7KiZYZggMCABhQrw8RGBWYqkoXCDlG2E2Dm50TA7VLtExczS6IIAxVgg8elCdGQVONLAMOFD4RFQ1tuJetbF1GNCIwtYaKQlHVzyC4tKyFphyypq6hqaqcxkAIjQf7g-ABGPyWUB+eEK5QAtgBaVhgOgEaEAensaBRADNCoUgWhmD8YnkgA
Credits to SuperKXT on Matt's TS Wizards Discord server
Hi is this issue still available to be worked on? Would love to give it a shot :)
florian-lefebvre commented
on Aug 25, 2023 ContributorAuthorMore actionscc @JedWatson
@florian-lefebvre any updates?
florian-lefebvre commented
on Aug 30, 2023 ContributorAuthorMore actionsnope, I'm not on the team so waiting for an answer as well
florian-lefebvre commented
on Sep 6, 2023 ContributorAuthorMore actionsThat's a bot, I think labelling has to be done by the keystatic team if they wish
Is this open to work? Would love to crush this.
@JedWatson Any updates?
Hi @JedWatson @timneutkens! 👋
I would like to work on this enhancement to enable configurable dynamic collection paths.
Proposed Implementation Plan:
- Schema & Config Types: Update
Collectionconfiguration types in@keystatic/coreto support an intersection allowingpathas either a validated pattern string or a dynamic callback(fields: Record<string, any>) => string(e.g. for locales and date-based paths). - Path Resolution & Validation: Update reader and writer path resolution routines with wildcard (
*) validation to guarantee deterministic entry lookup. - Tests: Add unit tests in
packages/keystaticcovering dynamic path callbacks, slug generation, and serialization.
Could you please assign this issue to me so I can proceed with the PR? Thank you!
- Schema & Config Types: Update
/attempt
I'd like to implement this. Proposal:
API: extend collection config as suggested -
path: string | ((fields: Record<string, any>) => string). The function form receives the item's initial field values at creation time (locale, date, category...), and today's string form becomes the degenerate case.Implementation sketch:
- In
packages/core/src/config, resolve the path template once at config load; interpolated segments define the slug region, so the existing*validation keeps working unchanged. - Repo backends (local + GitHub) receive the resolved prefix from the config layer - no per-IO changes required.
- Config parse-time validation: error clearly if a function path doesn't return a string containing
*. - Back-compat: string paths short-circuit to current behavior, zero migration for existing users.
Tests: vitest cases for locale-prefixed paths, date-partitioned paths (
posts/2026/*), slug uniqueness across prefixes, plus docs updates for the Astro/Next integration pages.Happy to open the config-types change as a small RFC PR first if you want to settle the API shape before the full implementation lands.
- In
Hi @JedWatson — I'd like to work on this. Rough plan based on your proposal above: extend the collection config type to accept
pathas either the current static string or a function(fields) => string, keepslugFieldas-is for the static case, and thread the function form through wherever the collection path is currently resolved (reader, writer, and the admin UI's item-creation flow) so it's computed from the item's field values at creation time. Happy to adjust scope if you had something narrower in mind — could I get this assigned before I start?Dug into the path-resolution code before starting, and want to flag a real design gap in the function-
pathidea before I write it: every "list existing items" path (app/utils.ts's tree walk,reader/generic.ts'scollectionReader) works by listing one static directory and glob-matching filenames — it never has field values in hand before it locates a file. A barepath: (fields) => stringcant be inverted for listing (chicken-and-egg: need the fields to compute the path, need the file to get the fields).Proposed shape that avoids that problem entirely:
path: | `${string}/${Glob}` | `${string}/${Glob}/${string}` // existing static form, unchanged | { // static, glob-listable root — existing items are discovered by recursively // walking this directory, same as today just recursive instead of one level base: `${string}/${Glob}` | `${string}/${Glob}/${string}`; // computes a NEW item's path (relative structure under `base`) from its field // values at creation time; only consulted on write, never needed for listing resolve: (fields: Record<string, unknown>) => string; }
slugFieldstays required in both forms. Listing/reading is untouched in spirit — it just recursively walksbaseinstead of one level, and derives each item's identity from its file path the same way it does today.resolveonly runs when creating a new item, so there's no inversion problem. This does deviate from the plain-function sketch above, but I don't think a bare function is implementable on the reader side without either parsing every file up front or introducing exactly this kind of static anchor — happy to hear if you had a different discovery mechanism in mind. Starting on this shape now; will adjust if you'd rather go a different direction.Opened #1622 — went with a different (safer) shape than the function-path sketch above; full reasoning is in the PR description and in my earlier comment here.
@JedWatson I implemented this using the static shape you proposed earlier in this thread —
slugFieldsmapped onto the*segments ofpathin order, no callbacks, entry paths stay fully deterministic from config: #1624.Summary of the design:
- entry slug = the segment values joined with
/(e.g.en/my-post), so tree listing, routing, the reader and both storage backends work unchanged - config validation enforces star-segment count ==
slugFields.lengthand that the last entry is theslugField - secondary slug fields validate as slug segments; the primary field is uniqueness-checked against the composite slug
- fully backwards compatible for single
slugFieldcollections
Happy to adjust the shape if you had something different in mind.
- entry slug = the segment values joined with
/attempt #340
Opened PR #1638 implementing deterministic multi-wildcard collection paths (e.g.
blog/*/*), matching exact segment depths (locale/slug) without dynamic functions to preserve static GitHub API schema loading as discussed. Unit tests across path parsing, slug validation, and reader filtering all pass.
Current state
Currently, the path is always static except for the slug, specified by
slugField.The problem
Projects often require more custom paths, e.g.: locales, publish date
Proposal
Make the collection config an intersection like so (pseudocode):
Examples
Locales
Publish date