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/lucky-pugs-learn.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, enabling entry paths such as `src/content/blog/en/my-post` while keeping slugs static and deterministic.
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 to make up the slug of each item. Each entry is mapped to 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 title `hello-world` is stored at `src/content/blog/en/hello-world`.

### 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
30 changes: 20 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,10 @@ 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 +506,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 +518,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 +854,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 +879,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
28 changes: 19 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,10 @@ 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 +391,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 +473,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
5 changes: 2 additions & 3 deletions packages/keystatic/src/app/duplicate-slug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ export function useDuplicateSlug(
// but if it fails a user can change it before creating
// (e.g. potentially it's not just a text field so appending -copy might not work)
const { slugField } = collectionConfig;
const defaultSlugVal = duplicateInitalState[collectionConfig.slugField];
const slugFieldSchema =
collectionConfig.schema[collectionConfig.slugField];
const defaultSlugVal = duplicateInitalState[slugField];
const slugFieldSchema = collectionConfig.schema[slugField];
if (
slugFieldSchema.kind === 'form' &&
slugFieldSchema.formKind === 'slug'
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