From 95c99efee8aeb8d41ddb640f0bf7378a1737dc89 Mon Sep 17 00:00:00 2001 From: Framma Williansyah Akbar Date: Sat, 3 Oct 2026 08:02:09 +0700 Subject: [PATCH] feat(core): support multiple wildcard segments in collection paths (#340) --- .changeset/multi-wildcard-collection-path.md | 5 + packages/keystatic/src/app/path-utils.test.ts | 158 ++++++++++++++++++ packages/keystatic/src/app/path-utils.ts | 71 +++++++- packages/keystatic/src/app/slugs.tsx | 3 +- packages/keystatic/src/app/utils.ts | 7 +- packages/keystatic/src/form/api.tsx | 4 +- packages/keystatic/src/form/errors.ts | 6 +- .../keystatic/src/form/fields/slug/index.tsx | 4 +- .../keystatic/src/form/fields/text/index.tsx | 2 +- .../form/fields/text/path-slug-context.tsx | 1 + .../src/form/fields/text/validateText.tsx | 9 +- packages/keystatic/src/reader/generic.ts | 30 +++- .../src/reader/multi-wildcard.test.ts | 107 ++++++++++++ 13 files changed, 392 insertions(+), 15 deletions(-) create mode 100644 .changeset/multi-wildcard-collection-path.md create mode 100644 packages/keystatic/src/app/path-utils.test.ts create mode 100644 packages/keystatic/src/reader/multi-wildcard.test.ts diff --git a/.changeset/multi-wildcard-collection-path.md b/.changeset/multi-wildcard-collection-path.md new file mode 100644 index 000000000..c48277810 --- /dev/null +++ b/.changeset/multi-wildcard-collection-path.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': minor +--- + +Collection `path` now supports multiple adjacent `*` wildcards (e.g. `blog/*/*`). Each `*` matches exactly one segment of the entry's slug, so slugs look like `en/hello`. The slug field validates the segment count and the reader only lists and reads entries at that depth. diff --git a/packages/keystatic/src/app/path-utils.test.ts b/packages/keystatic/src/app/path-utils.test.ts new file mode 100644 index 000000000..fd865f1da --- /dev/null +++ b/packages/keystatic/src/app/path-utils.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from 'vitest'; +import { collection, fields, type Config } from '..'; +import { + getCollectionItemPath, + getCollectionPath, + getEntryDataFilepath, + getCollectionFormat, + getSlugGlobForCollection, + getSlugSegmentCount, + parseCollectionPathWildcards, + slugHasSegmentCount, +} from './path-utils'; +import { validateText } from '../form/fields/text/validateText'; + +function makeConfig(path?: string) { + const c: Config = { + storage: { kind: 'local' }, + collections: { + posts: collection({ + label: 'Posts', + slugField: 'title', + path: path as any, + schema: { title: fields.slug({ name: { label: 'Title' } }) }, + }), + }, + }; + return c; +} + +describe('parseCollectionPathWildcards', () => { + test.each([ + ['posts/*', { glob: '*', segments: undefined, suffix: '' }], + ['posts/*/', { glob: '*', segments: undefined, suffix: '' }], + ['posts/*/inner', { glob: '*', segments: undefined, suffix: 'inner' }], + ['posts/**', { glob: '**', segments: undefined, suffix: '' }], + ['posts/**/inner', { glob: '**', segments: undefined, suffix: 'inner' }], + ['posts/*/*', { glob: '**', segments: 2, suffix: '' }], + ['posts/*/*/', { glob: '**', segments: 2, suffix: '' }], + ['posts/*/*/*/x/y', { glob: '**', segments: 3, suffix: 'x/y' }], + ])('%s', (path, expected) => { + expect(parseCollectionPathWildcards(path)).toEqual(expected); + }); + + test.each(['posts/*/x/*', 'posts/**/*', 'posts/*/**', 'posts/**/**'])( + 'rejects non-adjacent or mixed wildcards: %s', + path => { + expect(() => parseCollectionPathWildcards(path)).toThrow(); + } + ); +}); + +describe('collection path helpers', () => { + test('defaults are unchanged', () => { + const c = makeConfig(); + expect(getCollectionPath(c, 'posts')).toBe('posts'); + expect(getCollectionItemPath(c, 'posts', 'a')).toBe('posts/a'); + expect(getSlugGlobForCollection(c, 'posts')).toBe('*'); + expect(getSlugSegmentCount(c, 'posts')).toBeUndefined(); + expect(getCollectionFormat(c, 'posts').dataLocation).toBe('index'); + }); + + test('single wildcard with suffix is unchanged', () => { + const c = makeConfig('content/posts/*/entry'); + expect(getCollectionPath(c, 'posts')).toBe('content/posts'); + expect(getCollectionItemPath(c, 'posts', 'a')).toBe( + 'content/posts/a/entry' + ); + expect(getSlugSegmentCount(c, 'posts')).toBeUndefined(); + }); + + test('globstar is unchanged', () => { + const c = makeConfig('posts/**'); + expect(getSlugGlobForCollection(c, 'posts')).toBe('**'); + expect(getSlugSegmentCount(c, 'posts')).toBeUndefined(); + expect(getCollectionItemPath(c, 'posts', 'a/b/c')).toBe('posts/a/b/c'); + }); + + test('multiple wildcards, data file outside a directory', () => { + const c = makeConfig('src/content/blog/*/*'); + expect(getCollectionPath(c, 'posts')).toBe('src/content/blog'); + expect(getSlugGlobForCollection(c, 'posts')).toBe('**'); + expect(getSlugSegmentCount(c, 'posts')).toBe(2); + expect(getCollectionItemPath(c, 'posts', 'en/hello')).toBe( + 'src/content/blog/en/hello' + ); + const format = getCollectionFormat(c, 'posts'); + expect(format.dataLocation).toBe('outer'); + expect(getEntryDataFilepath('src/content/blog/en/hello', format)).toBe( + 'src/content/blog/en/hello.yaml' + ); + }); + + test('multiple wildcards, data file in a directory', () => { + const c = makeConfig('blog/*/*/'); + expect(getCollectionItemPath(c, 'posts', 'en/hello')).toBe('blog/en/hello'); + const format = getCollectionFormat(c, 'posts'); + expect(format.dataLocation).toBe('index'); + expect(getEntryDataFilepath('blog/en/hello', format)).toBe( + 'blog/en/hello/index.yaml' + ); + }); + + test('multiple wildcards with a suffix', () => { + const c = makeConfig('blog/*/*/data'); + expect(getCollectionItemPath(c, 'posts', 'en/hello')).toBe( + 'blog/en/hello/data' + ); + }); + + test('invalid paths throw', () => { + expect(() => getCollectionPath(makeConfig('blog/*/x/*'), 'posts')).toThrow( + /adjacent/ + ); + expect(() => getCollectionPath(makeConfig('blog/x'), 'posts')).toThrow( + /must end with/ + ); + }); +}); + +describe('slugHasSegmentCount', () => { + test.each([ + ['en/hello', 2, true], + ['hello', 2, false], + ['a/b/c', 2, false], + ['/hello', 2, false], + ['en/', 2, false], + ['en//x', 3, false], + ['hello', 1, true], + ])('%s with %i', (slug, count, expected) => { + expect(slugHasSegmentCount(slug, count)).toBe(expected); + }); +}); + +describe('validateText with a segment count', () => { + const slugs = new Set(['en/taken']); + const validate = (val: string) => + validateText( + val, + 1, + Infinity, + 'Slug', + { slugs, glob: '**', segments: 2 }, + undefined + ); + + test('accepts the right number of segments', () => { + expect(validate('en/hello')).toBeUndefined(); + }); + test('rejects the wrong number of segments', () => { + expect(validate('hello')).toMatch(/exactly 2/); + expect(validate('a/b/c')).toMatch(/exactly 2/); + expect(validate('en/')).toMatch(/exactly 2/); + }); + test('still rejects duplicates and dot segments', () => { + expect(validate('en/taken')).toMatch(/unique/); + expect(validate('en/..')).toMatch(/\.\./); + }); +}); diff --git a/packages/keystatic/src/app/path-utils.ts b/packages/keystatic/src/app/path-utils.ts index 97fb22caa..9d72ad99b 100644 --- a/packages/keystatic/src/app/path-utils.ts +++ b/packages/keystatic/src/app/path-utils.ts @@ -8,6 +8,47 @@ export function fixPath(path: string) { const collectionPath = /\/\*\*?(?:$|\/)/; +/** + * Splits the wildcard portion of a collection path template. Consecutive `*` + * segments each match exactly one path segment of the slug (so two of them + * give slugs like `en/hello`), `**` matches any number of segments, and + * whatever follows the wildcards is a literal suffix. + */ +export function parseCollectionPathWildcards(path: string): { + glob: Glob; + /** number of slug segments, or undefined if it is not fixed */ + segments: number | undefined; + suffix: string; +} { + const wildcardStart = path.indexOf('*'); + const rest = path.slice(wildcardStart).split('/'); + let segments = 0; + while (rest[0] === '*') { + segments++; + rest.shift(); + } + let glob: Glob = '*'; + if (segments === 0 && rest[0] === '**') { + glob = '**'; + rest.shift(); + } + const suffix = rest.join('/'); + if (segments === 0 && glob === '*') { + throw new Error(`Invalid wildcard in collection path ${path}`); + } + if (suffix.includes('*')) { + throw new Error( + `Collection path wildcards must be adjacent and come before any other path segments but got ${path}` + ); + } + return { + // a slug with several segments needs to be allowed to contain slashes + glob: segments > 1 ? '**' : glob, + segments: glob === '*' && segments > 1 ? segments : undefined, + suffix, + }; +} + function getConfiguredCollectionPath(config: Config, collection: string) { const collectionConfig = config.collections![collection]; const path = collectionConfig.path ?? `${collection}/*/`; @@ -16,6 +57,7 @@ function getConfiguredCollectionPath(config: Config, collection: string) { `Collection path must end with /* or /** or include /*/ or /**/ but ${collection} has ${path}` ); } + parseCollectionPathWildcards(path); return path; } @@ -53,16 +95,37 @@ export function getSlugGlobForCollection( config: Config, collection: string ): Glob { - const collectionPath = getConfiguredCollectionPath(config, collection); - return collectionPath.includes('**') ? '**' : '*'; + return parseCollectionPathWildcards( + getConfiguredCollectionPath(config, collection) + ).glob; +} + +/** + * The exact number of `/`-separated segments slugs in the collection must + * have, or undefined if the collection doesn't constrain it. + */ +export function getSlugSegmentCount( + config: Config, + collection: string +): number | undefined { + return parseCollectionPathWildcards( + getConfiguredCollectionPath(config, collection) + ).segments; +} + +export function slugHasSegmentCount(slug: string, segments: number) { + const split = slug.split('/'); + return split.length === segments && split.every(x => x !== ''); } export function getCollectionItemSlugSuffix( config: Config, collection: string ) { - const configuredPath = getConfiguredCollectionPath(config, collection); - const path = fixPath(configuredPath.replace(/^[^*]+\*\*?/, '')); + const { suffix } = parseCollectionPathWildcards( + getConfiguredCollectionPath(config, collection) + ); + const path = fixPath(suffix); return path ? `/${path}` : ''; } diff --git a/packages/keystatic/src/app/slugs.tsx b/packages/keystatic/src/app/slugs.tsx index 2f829a355..2df761b01 100644 --- a/packages/keystatic/src/app/slugs.tsx +++ b/packages/keystatic/src/app/slugs.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { getSlugGlobForCollection } from './path-utils'; +import { getSlugGlobForCollection, getSlugSegmentCount } from './path-utils'; import { useSlugsInCollection } from './useSlugsInCollection'; import { SlugFieldInfo } from '../form/fields/text/path-slug-context'; import { useConfig } from './shell/context'; @@ -21,6 +21,7 @@ export function useSlugFieldInfo( field: collectionConfig.slugField, slugs, glob: getSlugGlobForCollection(config, collection), + segments: getSlugSegmentCount(config, collection), }; }, [allSlugs, collection, config, slugToExclude]); } diff --git a/packages/keystatic/src/app/utils.ts b/packages/keystatic/src/app/utils.ts index fd2a11e15..0fd5172ac 100644 --- a/packages/keystatic/src/app/utils.ts +++ b/packages/keystatic/src/app/utils.ts @@ -10,6 +10,8 @@ import { getCollectionPath, getDataFileExtension, getSlugGlobForCollection, + getSlugSegmentCount, + slugHasSegmentCount, } from './path-utils'; import { collectDirectoriesUsedInSchema, getTreeKey } from './tree-key'; import { getTreeNodeAtPath, TreeNode } from './trees'; @@ -176,7 +178,10 @@ export function getEntriesInCollectionWithTreeKey( }); } } - return entries; + const segments = getSlugSegmentCount(config, collection); + return segments === undefined + ? entries + : entries.filter(entry => slugHasSegmentCount(entry.slug, segments)); } export const KEYSTATIC_CLOUD_API_URL = 'https://api.keystatic.cloud'; diff --git a/packages/keystatic/src/form/api.tsx b/packages/keystatic/src/form/api.tsx index 4b87714cf..a5cc3f491 100644 --- a/packages/keystatic/src/form/api.tsx +++ b/packages/keystatic/src/form/api.tsx @@ -72,7 +72,9 @@ export type SlugFormField< }; validate( value: ParsedValue, - extra: { slugField: { slugs: Set; glob: Glob } } | undefined + extra: + | { slugField: { slugs: Set; glob: Glob; segments?: number } } + | undefined ): ValidatedValue; reader: { parse(value: FormFieldStoredValue): ReaderValue; diff --git a/packages/keystatic/src/form/errors.ts b/packages/keystatic/src/form/errors.ts index a0d31c1b7..951220d65 100644 --- a/packages/keystatic/src/form/errors.ts +++ b/packages/keystatic/src/form/errors.ts @@ -34,7 +34,11 @@ function validateValueWithSchema( try { if (slugField && path[path.length - 1] === slugField?.field) { schema.validate(value, { - slugField: { slugs: slugField.slugs, glob: slugField.glob }, + slugField: { + slugs: slugField.slugs, + glob: slugField.glob, + segments: slugField.segments, + }, }); return; } diff --git a/packages/keystatic/src/form/fields/slug/index.tsx b/packages/keystatic/src/form/fields/slug/index.tsx index e4dd5673e..aea77d896 100644 --- a/packages/keystatic/src/form/fields/slug/index.tsx +++ b/packages/keystatic/src/form/fields/slug/index.tsx @@ -103,7 +103,9 @@ export function slug(_args: { { slugField, }: { - slugField: { slugs: Set; glob: Glob } | undefined; + slugField: + | { slugs: Set; glob: Glob; segments?: number } + | undefined; } = { slugField: undefined } ) { const nameMessage = validateText( diff --git a/packages/keystatic/src/form/fields/text/index.tsx b/packages/keystatic/src/form/fields/text/index.tsx index 157c30e01..df454e377 100644 --- a/packages/keystatic/src/form/fields/text/index.tsx +++ b/packages/keystatic/src/form/fields/text/index.tsx @@ -47,7 +47,7 @@ export function text({ min = Math.max(isRequired ? 1 : 0, min); function validate( value: string, - slugField: { slugs: Set; glob: Glob } | undefined + slugField: { slugs: Set; glob: Glob; segments?: number } | undefined ) { const message = validateText(value, min, max, label, slugField, pattern); if (message !== undefined) { diff --git a/packages/keystatic/src/form/fields/text/path-slug-context.tsx b/packages/keystatic/src/form/fields/text/path-slug-context.tsx index ec5907536..a476c2a0f 100644 --- a/packages/keystatic/src/form/fields/text/path-slug-context.tsx +++ b/packages/keystatic/src/form/fields/text/path-slug-context.tsx @@ -108,6 +108,7 @@ export type SlugFieldInfo = { field: string; slugs: Set; glob: Glob; + segments?: number; }; export const SlugFieldContext = createContext( diff --git a/packages/keystatic/src/form/fields/text/validateText.tsx b/packages/keystatic/src/form/fields/text/validateText.tsx index e50b04a4e..e001a533f 100644 --- a/packages/keystatic/src/form/fields/text/validateText.tsx +++ b/packages/keystatic/src/form/fields/text/validateText.tsx @@ -1,11 +1,12 @@ import { Glob } from '../../../config'; +import { slugHasSegmentCount } from '../../../app/path-utils'; export function validateText( val: string, min: number, max: number, fieldLabel: string, - slugInfo: { slugs: Set; glob: Glob } | undefined, + slugInfo: { slugs: Set; glob: Glob; segments?: number } | undefined, pattern: { regex: RegExp; message?: string } | undefined ) { if (val.length < min) { @@ -42,6 +43,12 @@ export function validateText( return `${fieldLabel} must not be .`; } } + if ( + slugInfo.segments !== undefined && + !slugHasSegmentCount(val, slugInfo.segments) + ) { + return `${fieldLabel} must have exactly ${slugInfo.segments} non-empty parts separated by slashes`; + } if ((slugInfo.glob === '*' ? /[\\/]/ : /[\\]/).test(val)) { return `${fieldLabel} must not contain slashes`; } diff --git a/packages/keystatic/src/reader/generic.ts b/packages/keystatic/src/reader/generic.ts index 9d6d4ba39..964f9b801 100644 --- a/packages/keystatic/src/reader/generic.ts +++ b/packages/keystatic/src/reader/generic.ts @@ -17,6 +17,8 @@ import { getSingletonFormat, getSingletonPath, getSlugGlobForCollection, + getSlugSegmentCount, + slugHasSegmentCount, } from '../app/path-utils'; import { parseProps } from '../form/parse-props'; import { loadDataFile } from '../app/required-files'; @@ -266,6 +268,7 @@ export function collectionReader( const collectionConfig = config.collections![collection]; const schema = fields.object(collectionConfig.schema); const glob = getSlugGlobForCollection(config, collection); + const segments = getSlugSegmentCount(config, collection); const extension = getDataFileExtension(formatInfo); const read: CollectionReader['read'] = (slug, ...args) => @@ -278,11 +281,22 @@ export function collectionReader( fsReader, slug, collectionConfig.slugField, - glob + glob, + segments ); - const list = () => - listCollection(collectionPath, glob, formatInfo, extension, fsReader); + const list = async () => { + const slugs = await listCollection( + collectionPath, + glob, + formatInfo, + extension, + fsReader + ); + return segments === undefined + ? slugs + : slugs.filter(slug => slugHasSegmentCount(slug, segments)); + }; return { read, @@ -320,12 +334,20 @@ const readItem = cache(async function readItem( resolveLinkedFiles: boolean | undefined, debugReference: string, fsReader: MinimalFs, - ...slugInfo: [slug: undefined] | [slug: string, field: string, glob: Glob] + ...slugInfo: + | [slug: undefined] + | [slug: string, field: string, glob: Glob, segments: number | undefined] ) { if (typeof slugInfo[0] === 'string') { if (slugInfo[0].includes('\\')) return null; const split = slugInfo[0].split('/'); if (slugInfo[2] === '*' && split.length !== 1) return null; + if ( + slugInfo[3] !== undefined && + !slugHasSegmentCount(slugInfo[0], slugInfo[3]) + ) { + return null; + } if (split.includes('..') || split.includes('.')) return null; } const dataFile = await fsReader.readFile( diff --git a/packages/keystatic/src/reader/multi-wildcard.test.ts b/packages/keystatic/src/reader/multi-wildcard.test.ts new file mode 100644 index 000000000..fa3611fbc --- /dev/null +++ b/packages/keystatic/src/reader/multi-wildcard.test.ts @@ -0,0 +1,107 @@ +/** @vitest-environment node */ +import { describe, expect, test } from 'vitest'; +import { collection, fields, type Config } from '..'; +import { collectionReader, MinimalFs, DirEntry } from './generic'; + +function memoryFs(files: Record): MinimalFs { + const enc = new TextEncoder(); + const dirs = new Set(['']); + for (const file of Object.keys(files)) { + const parts = file.split('/'); + for (let i = 1; i < parts.length; i++) { + dirs.add(parts.slice(0, i).join('/')); + } + } + return { + async readFile(path) { + return path in files ? enc.encode(files[path]) : null; + }, + async fileExists(path) { + return path in files; + }, + async readdir(path) { + const prefix = path.replace(/\/$/, '') + '/'; + const out = new Map(); + for (const file of Object.keys(files)) { + if (!file.startsWith(prefix)) continue; + const [name, ...rest] = file.slice(prefix.length).split('/'); + out.set(name, { name, kind: rest.length ? 'directory' : 'file' }); + } + return [...out.values()]; + }, + }; +} + +const files = { + 'blog/en/hello.yaml': 'title: Hello\n', + 'blog/fr/bonjour.yaml': 'title: Bonjour\n', + 'blog/stray.yaml': 'title: Stray\n', + 'blog/en/deep/too-deep.yaml': 'title: Deep\n', +}; + +function makeReader(path: string, fs = memoryFs(files)) { + const c: Config = { + storage: { kind: 'local' }, + collections: { + posts: collection({ + label: 'Posts', + slugField: 'title', + path: path as any, + schema: { title: fields.slug({ name: { label: 'Title' } }) }, + }), + }, + }; + return collectionReader('posts', c, fs); +} + +describe('collection reader with multiple wildcards', () => { + test('list only returns entries with the right depth', async () => { + expect((await makeReader('blog/*/*').list()).sort()).toEqual([ + 'en/hello', + 'fr/bonjour', + ]); + }); + + test('read resolves a multi-segment slug', async () => { + const entry = await makeReader('blog/*/*').read('en/hello'); + expect(entry).toEqual({ title: 'Hello' }); + }); + + test('read returns null for slugs of the wrong depth', async () => { + const reader = makeReader('blog/*/*'); + expect(await reader.read('stray')).toBeNull(); + expect(await reader.read('en/deep/too-deep')).toBeNull(); + expect(await reader.read('en/')).toBeNull(); + expect(await reader.read('../blog/en/hello')).toBeNull(); + }); + + test('all returns slugs and entries', async () => { + const all = await makeReader('blog/*/*').all(); + expect(all.map(x => x.slug).sort()).toEqual(['en/hello', 'fr/bonjour']); + }); + + test('single wildcard behaviour is unchanged', async () => { + const reader = makeReader('blog/*'); + expect(await reader.list()).toEqual(['stray']); + expect(await reader.read('en/hello')).toBeNull(); + }); + + test('globstar behaviour is unchanged', async () => { + const reader = makeReader('blog/**'); + expect((await reader.list()).sort()).toEqual([ + 'en/deep/too-deep', + 'en/hello', + 'fr/bonjour', + 'stray', + ]); + }); + + test('works with directory-style entries and a suffix', async () => { + const fs = memoryFs({ + 'blog/en/hello/data/index.yaml': 'title: Hello\n', + 'blog/en/orphan/index.yaml': 'title: Orphan\n', + }); + const reader = makeReader('blog/*/*/data/', fs); + expect(await reader.read('en/hello')).toEqual({ title: 'Hello' }); + }); +});