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/great-lobsters-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystatic/core': minor
---

Add `slugFields` option to collections for multi-segment collection paths. Each entry maps onto a `*` segment of the collection `path` in order — e.g. `slugFields: ['locale', 'title']` with `path: 'content/blog/*/*'` stores an entry with locale `en` and slug `my-post` at `content/blog/en/my-post` — while keeping slugs static and deterministic. The number of `*` segments and the `slugFields` entries are also checked against each other at the type level.
19 changes: 19 additions & 0 deletions docs/src/content/pages/collections.mdoc
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,25 @@ testimonials: collection({
}),
```

### Multiple slug fields

`slugFields` — defines multiple fields that make up the slug of each item. Each entry maps onto a `*` segment of the collection `path` in order, and the last entry must be the `slugField`. Entries are stored in nested directories, one per slug field.

```typescript
blog: collection({
label: 'Blog',
path: 'src/content/blog/*/*',
slugFields: ['locale', 'title'],
schema: {
locale: fields.text({ label: 'Locale' }),
title: fields.slug({ name: { label: 'Title' } }),
},
slugField: 'title',
}),
```

An entry with the locale `en` and the slug `my-post` is stored at `src/content/blog/en/my-post`, and its slug is `en/my-post`. Slugs stay static and deterministic: the location of every entry is always known from the config, which keeps collections working over the GitHub API.

### Template

`template` — the path to a content file (existing collection entry or "template") to use as a starting point for new entries.
Expand Down
16 changes: 13 additions & 3 deletions packages/keystatic/src/app/CollectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
getCollectionPath,
getEntriesInCollectionWithTreeKey,
getEntryDataFilepath,
getSlugFieldsForCollection,
getSlugGlobForCollection,
isLocalConfig,
} from './utils';
Expand Down Expand Up @@ -357,11 +358,16 @@ function CollectionTable(
})
);
const glob = getSlugGlobForCollection(props.config, props.collection);
const slugFields = getSlugFieldsForCollection(
props.config,
props.collection
);
const rootSchema = { kind: 'object' as const, fields: collection.schema };
const parsedEntries = new Map<string, Record<string, unknown>>();
for (const [slug, dataFile] of entries) {
try {
const { loaded } = loadDataFile(dataFile, formatInfo);
const slugSegments = slug.split('/');
const validated = parseProps(
rootSchema,
loaded,
Expand All @@ -378,14 +384,18 @@ function CollectionTable(
return;
}
if (path.length === 1 && slug !== undefined) {
if (path[0] === collection.slugField) {
const slugFieldIndex = slugFields.indexOf(path[0] as string);
if (slugFieldIndex !== -1) {
if (schema.formKind !== 'slug') {
throw new Error(
`Slug field ${collection.slugField} is not a slug field`
`Slug field ${path[0]} is not a slug field`
);
}
return schema.reader.parseWithSlug(value, {
slug,
slug:
slugFields.length === 1
? slug
: slugSegments[slugFieldIndex] ?? '',
glob,
});
}
Expand Down
27 changes: 17 additions & 10 deletions packages/keystatic/src/app/ItemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
import { useHasChanged } from './useHasChanged';
import { parseEntry, useItemData } from './useItemData';
import {
collectionSlugFields,
getBranchPrefix,
getCollection,
getCollectionFormat,
Expand Down Expand Up @@ -192,14 +193,14 @@ function ItemPageInner(

const onCopy = useEventCallback(() => {
copyEntryToClipboard(props.state, formatInfo, collectionConfig.schema, {
field: collectionConfig.slugField,
fields: collectionSlugFields(collectionConfig),
value: getSlugFromState(collectionConfig, props.state),
});
});

const onPaste = useEventCallback(async () => {
const entry = await getPastedEntry(formatInfo, collectionConfig.schema, {
field: collectionConfig.slugField,
fields: collectionSlugFields(collectionConfig),
slug: getSlugFromState(collectionConfig, props.state),
});
if (entry) {
Expand Down Expand Up @@ -417,7 +418,7 @@ function LocalItemPage(
initialState,
schema,
state,
slugField: collectionConfig.slugField,
slugFields: collectionSlugFields(collectionConfig),
});

const slug = getSlugFromState(collectionConfig, state);
Expand All @@ -431,7 +432,7 @@ function LocalItemPage(
basePath: futureBasePath,
format: formatInfo,
currentLocalTreeKey: localTreeKey,
slug: { field: collectionConfig.slugField, value: slug },
slug: { fields: collectionSlugFields(collectionConfig), value: slug },
});

useEffect(() => {
Expand All @@ -441,7 +442,7 @@ function LocalItemPage(
basePath: futureBasePath,
format: getCollectionFormat(config, collection),
schema: collectionConfig.schema,
slug: { field: collectionConfig.slugField, value: slug },
slug: { fields: collectionSlugFields(collectionConfig), value: slug },
state,
});
const files = new Map(serialized.map(x => [x.path, x.contents]));
Expand Down Expand Up @@ -502,7 +503,7 @@ function CollabItemPage(props: ItemPageProps & { map: Y.Map<any> }) {
initialState,
schema,
state,
slugField: collectionConfig.slugField,
slugFields: collectionSlugFields(collectionConfig),
});

const futureBasePath = getCollectionItemPath(config, collection, slug);
Expand All @@ -514,7 +515,7 @@ function CollabItemPage(props: ItemPageProps & { map: Y.Map<any> }) {
basePath: futureBasePath,
format: formatInfo,
currentLocalTreeKey: localTreeKey,
slug: { field: collectionConfig.slugField, value: slug },
slug: { fields: collectionSlugFields(collectionConfig), value: slug },
});

const update = useEventCallback(_update);
Expand Down Expand Up @@ -850,8 +851,11 @@ function ItemPageOuterWrapper(props: ItemPageWrapperProps) {
);

const slugInfo = useMemo(() => {
return { slug: props.itemSlug, field: collectionConfig.slugField };
}, [collectionConfig.slugField, props.itemSlug]);
return {
slug: props.itemSlug,
fields: collectionSlugFields(collectionConfig),
};
}, [collectionConfig, props.itemSlug]);

const draftData = useData(
useCallback(async () => {
Expand All @@ -872,7 +876,10 @@ function ItemPageOuterWrapper(props: ItemPageWrapperProps) {
),
format: getCollectionFormat(props.config, props.collection),
schema: collectionConfig.schema,
slug: { field: collectionConfig.slugField, slug: stored.slug },
slug: {
fields: collectionSlugFields(collectionConfig),
slug: stored.slug,
},
},
stored.files
);
Expand Down
4 changes: 2 additions & 2 deletions packages/keystatic/src/app/SingletonPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ function LocalSingletonPage(

const isCreating = initialState === null;
const hasChanged =
useHasChanged({ initialState, state, schema, slugField: undefined }) ||
useHasChanged({ initialState, state, schema, slugFields: undefined }) ||
isCreating;

useEffect(() => {
Expand Down Expand Up @@ -477,7 +477,7 @@ function CollabSingletonPage(

const isCreating = initialState === null;
const hasChanged =
useHasChanged({ initialState, state, schema, slugField: undefined }) ||
useHasChanged({ initialState, state, schema, slugFields: undefined }) ||
isCreating;

const formatInfo = getSingletonFormat(config, singleton);
Expand Down
25 changes: 16 additions & 9 deletions packages/keystatic/src/app/create-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { parseEntry, useItemData } from './useItemData';
import { useHasChanged } from './useHasChanged';
import { useYJsValue } from './useYJsValue';
import {
collectionSlugFields,
getCollectionFormat,
getCollectionItemPath,
getSlugFromState,
Expand Down Expand Up @@ -95,7 +96,10 @@ function CreateItemWrapper(props: {
),
format,
schema: collectionConfig.schema,
slug: { field: collectionConfig.slugField, slug: stored.slug },
slug: {
fields: collectionSlugFields(collectionConfig),
slug: stored.slug,
},
},
stored.files
);
Expand All @@ -111,10 +115,13 @@ function CreateItemWrapper(props: {

const slug = useMemo(() => {
if (duplicateSlug) {
return { field: collectionConfig.slugField, slug: duplicateSlug };
return {
fields: collectionSlugFields(collectionConfig),
slug: duplicateSlug,
};
}
if (collectionConfig.template) {
return { field: collectionConfig.slugField, slug: '' };
return { fields: collectionSlugFields(collectionConfig), slug: '' };
}
}, [duplicateSlug, collectionConfig]);

Expand Down Expand Up @@ -288,15 +295,15 @@ function CreateItemLocal(props: {
schema: collectionConfig.schema,
format: formatInfo,
currentLocalTreeKey: undefined,
slug: { field: collectionConfig.slugField, value: slug },
slug: { fields: collectionSlugFields(collectionConfig), value: slug },
});
const createItem = useEventCallback(_createItem);

const hasChanged = useHasChanged({
initialState,
schema,
state,
slugField: collectionConfig.slugField,
slugFields: collectionSlugFields(collectionConfig),
});
const hasCreated =
createResult.kind === 'updated' || createResult.kind === 'loading';
Expand All @@ -314,7 +321,7 @@ function CreateItemLocal(props: {
basePath,
format: formatInfo,
schema: collectionConfig.schema,
slug: { field: collectionConfig.slugField, value: slug },
slug: { fields: collectionSlugFields(collectionConfig), value: slug },
state,
});
const files = new Map(serialized.map(x => [x.path, x.contents]));
Expand Down Expand Up @@ -381,7 +388,7 @@ function CreateItemCollab(props: {
schema: collectionConfig.schema,
format: formatInfo,
currentLocalTreeKey: undefined,
slug: { field: collectionConfig.slugField, value: slug },
slug: { fields: collectionSlugFields(collectionConfig), value: slug },
});
const createItem = useEventCallback(_createItem);

Expand Down Expand Up @@ -463,14 +470,14 @@ function CreateItemInner(props: {

const onCopy = () => {
copyEntryToClipboard(props.state, formatInfo, collectionConfig.schema, {
field: collectionConfig.slugField,
fields: collectionSlugFields(collectionConfig),
value: getSlugFromState(collectionConfig, props.state),
});
};

const onPaste = async () => {
const entry = await getPastedEntry(formatInfo, collectionConfig.schema, {
field: collectionConfig.slugField,
fields: collectionSlugFields(collectionConfig),
slug: getSlugFromState(collectionConfig, props.state),
});
if (entry) {
Expand Down
2 changes: 1 addition & 1 deletion packages/keystatic/src/app/duplicate-slug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ComponentSchema } from '../form/api';

export function useDuplicateSlug(
duplicateInitalState: Record<string, unknown> | undefined,
collectionConfig: Collection<Record<string, ComponentSchema>, string>
collectionConfig: Collection<Record<string, ComponentSchema>, string, readonly string[]>
) {
return useMemo(() => {
if (duplicateInitalState) {
Expand Down
16 changes: 8 additions & 8 deletions packages/keystatic/src/app/entry-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function parseEntryFromHtml(
html: string,
format: FormatInfo,
schema: Record<string, ComponentSchema>,
slugField: string | undefined
slugFields: readonly string[] | undefined
) {
const parsedHtml = new DOMParser().parseFromString(html, 'text/html');
const pre = parsedHtml.querySelector('pre');
Expand All @@ -38,8 +38,8 @@ function parseEntryFromHtml(
dirpath: entryInfo.slug ?? 'entry',
format,
schema,
slug: slugField
? { field: slugField, slug: entryInfo.slug ?? '' }
slug: slugFields
? { fields: slugFields, slug: entryInfo.slug ?? '' }
: undefined,
requireFrontmatter: true,
},
Expand All @@ -52,7 +52,7 @@ function parseEntryFromPlaintext(
bytes: Uint8Array,
format: FormatInfo,
schema: Record<string, ComponentSchema>,
slugInfo: { field: string; slug: string } | undefined
slugInfo: { fields: readonly string[]; slug: string } | undefined
) {
try {
const dirpath = slugInfo?.slug ?? 'entry';
Expand All @@ -66,7 +66,7 @@ function parseEntryFromPlaintext(
export async function getPastedEntry(
format: FormatInfo,
schema: Record<string, ComponentSchema>,
slugInfo: { field: string; slug: string } | undefined
slugInfo: { fields: readonly string[]; slug: string } | undefined
) {
let clipboardItems: ClipboardItem[];
try {
Expand All @@ -88,7 +88,7 @@ export async function getPastedEntry(
if (item.types.includes('text/html')) {
const html = await item.getType('text/html');
const text = await html.text();
const entry = parseEntryFromHtml(text, format, schema, slugInfo?.field);
const entry = parseEntryFromHtml(text, format, schema, slugInfo?.fields);
if (entry) {
return entry;
}
Expand All @@ -114,7 +114,7 @@ function serializeEntryForClipboard(
state: Record<string, unknown>,
format: FormatInfo,
schema: Record<string, ComponentSchema>,
slug: { field: string; value: string } | undefined
slug: { fields: readonly string[]; value: string } | undefined
) {
const basePath = slug?.value ?? 'entry';
const files = serializeEntryToFiles({
Expand Down Expand Up @@ -146,7 +146,7 @@ export function copyEntryToClipboard(
state: Record<string, unknown>,
format: FormatInfo,
schema: Record<string, ComponentSchema>,
slug: { field: string; value: string } | undefined
slug: { fields: readonly string[]; value: string } | undefined
) {
const out = serializeEntryForClipboard(state, format, schema, slug);
navigator.clipboard.write([
Expand Down
Loading