Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/multi-wildcard-collection-path.md
Original file line number Diff line number Diff line change
@@ -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.
158 changes: 158 additions & 0 deletions packages/keystatic/src/app/path-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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(/\.\./);
});
});
71 changes: 67 additions & 4 deletions packages/keystatic/src/app/path-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}/*/`;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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}` : '';
}

Expand Down
3 changes: 2 additions & 1 deletion packages/keystatic/src/app/slugs.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,6 +21,7 @@ export function useSlugFieldInfo(
field: collectionConfig.slugField,
slugs,
glob: getSlugGlobForCollection(config, collection),
segments: getSlugSegmentCount(config, collection),
};
}, [allSlugs, collection, config, slugToExclude]);
}
7 changes: 6 additions & 1 deletion packages/keystatic/src/app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
getCollectionPath,
getDataFileExtension,
getSlugGlobForCollection,
getSlugSegmentCount,
slugHasSegmentCount,
} from './path-utils';
import { collectDirectoriesUsedInSchema, getTreeKey } from './tree-key';
import { getTreeNodeAtPath, TreeNode } from './trees';
Expand Down Expand Up @@ -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';
Expand Down
4 changes: 3 additions & 1 deletion packages/keystatic/src/form/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ export type SlugFormField<
};
validate(
value: ParsedValue,
extra: { slugField: { slugs: Set<string>; glob: Glob } } | undefined
extra:
| { slugField: { slugs: Set<string>; glob: Glob; segments?: number } }
| undefined
): ValidatedValue;
reader: {
parse(value: FormFieldStoredValue): ReaderValue;
Expand Down
6 changes: 5 additions & 1 deletion packages/keystatic/src/form/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/keystatic/src/form/fields/slug/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ export function slug(_args: {
{
slugField,
}: {
slugField: { slugs: Set<string>; glob: Glob } | undefined;
slugField:
| { slugs: Set<string>; glob: Glob; segments?: number }
| undefined;
} = { slugField: undefined }
) {
const nameMessage = validateText(
Expand Down
2 changes: 1 addition & 1 deletion packages/keystatic/src/form/fields/text/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function text({
min = Math.max(isRequired ? 1 : 0, min);
function validate(
value: string,
slugField: { slugs: Set<string>; glob: Glob } | undefined
slugField: { slugs: Set<string>; glob: Glob; segments?: number } | undefined
) {
const message = validateText(value, min, max, label, slugField, pattern);
if (message !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export type SlugFieldInfo = {
field: string;
slugs: Set<string>;
glob: Glob;
segments?: number;
};

export const SlugFieldContext = createContext<SlugFieldInfo | undefined>(
Expand Down
9 changes: 8 additions & 1 deletion packages/keystatic/src/form/fields/text/validateText.tsx
Original file line number Diff line number Diff line change
@@ -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<string>; glob: Glob } | undefined,
slugInfo: { slugs: Set<string>; glob: Glob; segments?: number } | undefined,
pattern: { regex: RegExp; message?: string } | undefined
) {
if (val.length < min) {
Expand Down Expand Up @@ -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`;
}
Expand Down
Loading