Skip to content

Commit 3048238

Browse files
[Content Collections] Add slug frontmatter field (#5941)
* feat: respect `slug` frontmatter prop * chore: replace `slug` check with proper types * fix: regen types on `slug` change * chore: add TODO on slug gen * tests: update to use `slug` frontmatter prop * chore: add error message on `slug` inside object schema * lint * chore: add note on frontmatter parse * refactor: move content errors to new heading * chore: ContentSchemaContainsSlugError * chore: changeset * docs: be 10% less gentle Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * fix: avoid parsing slug on unlink * docs: clarify old API is for beta users Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
1 parent f62ec16 commit 3048238

12 files changed

Lines changed: 188 additions & 77 deletions

File tree

.changeset/large-steaks-film.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'astro': major
3+
---
4+
5+
Content collections: Introduce a new `slug` frontmatter field for overriding the generated slug. This replaces the previous `slug()` collection config option from Astro 1.X and the 2.0 beta.
6+
7+
When present in a Markdown or MDX file, this will override the generated slug for that entry.
8+
9+
```diff
10+
# src/content/blog/post-1.md
11+
---
12+
title: Post 1
13+
+ slug: post-1-custom-slug
14+
---
15+
```
16+
17+
Astro will respect this slug in the generated `slug` type and when using the `getEntryBySlug()` utility:
18+
19+
```astro
20+
---
21+
import { getEntryBySlug } from 'astro:content';
22+
23+
// Retrieve `src/content/blog/post-1.md` by slug with type safety
24+
const post = await getEntryBySlug('blog', 'post-1-custom-slug');
25+
---
26+
```
27+
28+
#### Migration
29+
30+
If you relied on the `slug()` config option, you will need to move all custom slugs to `slug` frontmatter properties in each collection entry.
31+
32+
Additionally, Astro no longer allows `slug` as a collection schema property. This ensures Astro can manage the `slug` property for type generation and performance. Remove this property from your schema and any relevant `slug()` configuration:
33+
34+
```diff
35+
const blog = defineCollection({
36+
schema: z.object({
37+
- slug: z.string().optional(),
38+
}),
39+
- slug({ defaultSlug, data }) {
40+
- return data.slug ?? defaultSlug;
41+
- },
42+
})
43+
```

packages/astro/src/content/types-generator.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ import {
1212
ContentConfig,
1313
ContentObservable,
1414
ContentPaths,
15+
EntryInfo,
1516
getContentPaths,
1617
getEntryInfo,
18+
getEntrySlug,
1719
loadContentConfig,
1820
NoCollectionError,
21+
parseFrontmatter,
1922
} from './utils.js';
2023

2124
type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
@@ -155,17 +158,19 @@ export async function createContentTypesGenerator({
155158
return { shouldGenerateTypes: false };
156159
}
157160

158-
const { id, slug, collection } = entryInfo;
161+
const { id, collection } = entryInfo;
162+
159163
const collectionKey = JSON.stringify(collection);
160164
const entryKey = JSON.stringify(id);
161165

162166
switch (event.name) {
163167
case 'add':
168+
const addedSlug = await parseSlug({ fs, event, entryInfo });
164169
if (!(collectionKey in contentTypes)) {
165170
addCollection(contentTypes, collectionKey);
166171
}
167172
if (!(entryKey in contentTypes[collectionKey])) {
168-
addEntry(contentTypes, collectionKey, entryKey, slug);
173+
setEntry(contentTypes, collectionKey, entryKey, addedSlug);
169174
}
170175
return { shouldGenerateTypes: true };
171176
case 'unlink':
@@ -174,7 +179,13 @@ export async function createContentTypesGenerator({
174179
}
175180
return { shouldGenerateTypes: true };
176181
case 'change':
177-
// noop. Frontmatter types are inferred from collection schema import, so they won't change!
182+
// User may modify `slug` in their frontmatter.
183+
// Only regen types if this change is detected.
184+
const changedSlug = await parseSlug({ fs, event, entryInfo });
185+
if (contentTypes[collectionKey]?.[entryKey]?.slug !== changedSlug) {
186+
setEntry(contentTypes, collectionKey, entryKey, changedSlug);
187+
return { shouldGenerateTypes: true };
188+
}
178189
return { shouldGenerateTypes: false };
179190
}
180191
}
@@ -243,7 +254,26 @@ function removeCollection(contentMap: ContentTypes, collectionKey: string) {
243254
delete contentMap[collectionKey];
244255
}
245256

246-
function addEntry(
257+
async function parseSlug({
258+
fs,
259+
event,
260+
entryInfo,
261+
}: {
262+
fs: typeof fsMod;
263+
event: ContentEvent;
264+
entryInfo: EntryInfo;
265+
}) {
266+
// `slug` may be present in entry frontmatter.
267+
// This should be respected by the generated `slug` type!
268+
// Parse frontmatter and retrieve `slug` value for this.
269+
// Note: will raise any YAML exceptions and `slug` parse errors (i.e. `slug` is a boolean)
270+
// on dev server startup or production build init.
271+
const rawContents = await fs.promises.readFile(event.entry, 'utf-8');
272+
const { data: frontmatter } = parseFrontmatter(rawContents, fileURLToPath(event.entry));
273+
return getEntrySlug({ ...entryInfo, data: frontmatter });
274+
}
275+
276+
function setEntry(
247277
contentTypes: ContentTypes,
248278
collectionKey: string,
249279
entryKey: string,
@@ -295,11 +325,7 @@ async function writeContentFiles({
295325
for (const entryKey of entryKeys) {
296326
const entryMetadata = contentTypes[collectionKey][entryKey];
297327
const dataType = collectionConfig?.schema ? `InferEntrySchema<${collectionKey}>` : 'any';
298-
// If user has custom slug function, we can't predict slugs at type compilation.
299-
// Would require parsing all data and evaluating ahead-of-time;
300-
// We evaluate with lazy imports at dev server runtime
301-
// to prevent excessive errors
302-
const slugType = collectionConfig?.slug ? 'string' : JSON.stringify(entryMetadata.slug);
328+
const slugType = JSON.stringify(entryMetadata.slug);
303329
contentTypesStr += `${entryKey}: {\n id: ${entryKey},\n slug: ${slugType},\n body: string,\n collection: ${collectionKey},\n data: ${dataType}\n},\n`;
304330
}
305331
contentTypesStr += `},\n`;

packages/astro/src/content/utils.ts

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,6 @@ import { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.
1212

1313
export const collectionConfigParser = z.object({
1414
schema: z.any().optional(),
15-
slug: z
16-
.function()
17-
.args(
18-
z.object({
19-
id: z.string(),
20-
collection: z.string(),
21-
defaultSlug: z.string(),
22-
body: z.string(),
23-
data: z.record(z.any()),
24-
})
25-
)
26-
.returns(z.union([z.string(), z.promise(z.string())]))
27-
.optional(),
2815
});
2916

3017
export function getDotAstroTypeReference({ root, srcDir }: { root: URL; srcDir: URL }) {
@@ -63,20 +50,25 @@ export const msg = {
6350
`${collection} does not have a config. We suggest adding one for type safety!`,
6451
};
6552

66-
export async function getEntrySlug(entry: Entry, collectionConfig: CollectionConfig) {
67-
return (
68-
collectionConfig.slug?.({
69-
id: entry.id,
70-
data: entry.data,
71-
defaultSlug: entry.slug,
72-
collection: entry.collection,
73-
body: entry.body,
74-
}) ?? entry.slug
75-
);
53+
export function getEntrySlug({
54+
id,
55+
collection,
56+
slug,
57+
data: unparsedData,
58+
}: Pick<Entry, 'id' | 'collection' | 'slug' | 'data'>) {
59+
try {
60+
return z.string().default(slug).parse(unparsedData.slug);
61+
} catch {
62+
throw new AstroError({
63+
...AstroErrorData.InvalidContentEntrySlugError,
64+
message: AstroErrorData.InvalidContentEntrySlugError.message(collection, id),
65+
});
66+
}
7667
}
7768

7869
export async function getEntryData(entry: Entry, collectionConfig: CollectionConfig) {
79-
let data = entry.data;
70+
// Remove reserved `slug` field before parsing data
71+
let { slug, ...data } = entry.data;
8072
if (collectionConfig.schema) {
8173
// TODO: remove for 2.0 stable release
8274
if (
@@ -90,14 +82,26 @@ export async function getEntryData(entry: Entry, collectionConfig: CollectionCon
9082
code: 99999,
9183
});
9284
}
85+
// Catch reserved `slug` field inside schema
86+
// Note: will not warn for `z.union` or `z.intersection` schemas
87+
if (
88+
typeof collectionConfig.schema === 'object' &&
89+
'shape' in collectionConfig.schema &&
90+
collectionConfig.schema.shape.slug
91+
) {
92+
throw new AstroError({
93+
...AstroErrorData.ContentSchemaContainsSlugError,
94+
message: AstroErrorData.ContentSchemaContainsSlugError.message(entry.collection),
95+
});
96+
}
9397
// Use `safeParseAsync` to allow async transforms
9498
const parsed = await collectionConfig.schema.safeParseAsync(entry.data, { errorMap });
9599
if (parsed.success) {
96100
data = parsed.data;
97101
} else {
98102
const formattedError = new AstroError({
99-
...AstroErrorData.MarkdownContentSchemaValidationError,
100-
message: AstroErrorData.MarkdownContentSchemaValidationError.message(
103+
...AstroErrorData.InvalidContentEntryFrontmatterError,
104+
message: AstroErrorData.InvalidContentEntryFrontmatterError.message(
101105
entry.collection,
102106
entry.id,
103107
parsed.error

packages/astro/src/content/vite-plugin-content-server.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,14 @@ export function astroContentServerPlugin({
137137

138138
const _internal = { filePath: fileId, rawData };
139139
const partialEntry = { data: unparsedData, body, _internal, ...entryInfo };
140+
// TODO: move slug calculation to the start of the build
141+
// to generate a performant lookup map for `getEntryBySlug`
142+
const slug = getEntrySlug(partialEntry);
143+
140144
const collectionConfig = contentConfig?.collections[entryInfo.collection];
141145
const data = collectionConfig
142146
? await getEntryData(partialEntry, collectionConfig)
143147
: unparsedData;
144-
const slug = collectionConfig
145-
? await getEntrySlug({ ...partialEntry, data }, collectionConfig)
146-
: entryInfo.slug;
147148

148149
const code = escapeViteEnvReferences(`
149150
export const id = ${JSON.stringify(entryInfo.id)};

packages/astro/src/core/errors/errors-data.ts

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -497,30 +497,6 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
497497
title: 'Failed to parse Markdown frontmatter.',
498498
code: 6001,
499499
},
500-
/**
501-
* @docs
502-
* @message
503-
* **Example error message:**<br/>
504-
* Could not parse frontmatter in **blog** → **post.md**<br/>
505-
* "title" is required.<br/>
506-
* "date" must be a valid date.
507-
* @description
508-
* A Markdown document's frontmatter in `src/content/` does not match its collection schema.
509-
* Make sure that all required fields are present, and that all fields are of the correct type.
510-
* You can check against the collection schema in your `src/content/config.*` file.
511-
* See the [Content collections documentation](https://docs.astro.build/en/guides/content-collections/) for more information.
512-
*/
513-
MarkdownContentSchemaValidationError: {
514-
title: 'Content collection frontmatter invalid.',
515-
code: 6002,
516-
message: (collection: string, entryId: string, error: ZodError) => {
517-
return [
518-
`${String(collection)}${String(entryId)} frontmatter does not match collection schema.`,
519-
...error.errors.map((zodError) => zodError.message),
520-
].join('\n');
521-
},
522-
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
523-
},
524500
/**
525501
* @docs
526502
* @see
@@ -603,6 +579,72 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
603579
message: '`astro sync` command failed to generate content collection types.',
604580
hint: 'Check your `src/content/config.*` file for typos.',
605581
},
582+
/**
583+
* @docs
584+
* @kind heading
585+
* @name Content Collection Errors
586+
*/
587+
// Content Collection Errors - 9xxx
588+
UnknownContentCollectionError: {
589+
title: 'Unknown Content Collection Error.',
590+
code: 9000,
591+
},
592+
/**
593+
* @docs
594+
* @message
595+
* **Example error message:**<br/>
596+
* **blog** → **post.md** frontmatter does not match collection schema.<br/>
597+
* "title" is required.<br/>
598+
* "date" must be a valid date.
599+
* @description
600+
* A Markdown or MDX entry in `src/content/` does not match its collection schema.
601+
* Make sure that all required fields are present, and that all fields are of the correct type.
602+
* You can check against the collection schema in your `src/content/config.*` file.
603+
* See the [Content collections documentation](https://docs.astro.build/en/guides/content-collections/) for more information.
604+
*/
605+
InvalidContentEntryFrontmatterError: {
606+
title: 'Content entry frontmatter does not match schema.',
607+
code: 9001,
608+
message: (collection: string, entryId: string, error: ZodError) => {
609+
return [
610+
`${String(collection)}${String(entryId)} frontmatter does not match collection schema.`,
611+
...error.errors.map((zodError) => zodError.message),
612+
].join('\n');
613+
},
614+
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
615+
},
616+
/**
617+
* @docs
618+
* @see
619+
* - [The reserved entry `slug` field](https://docs.astro.build/en/guides/content-collections/)
620+
* @description
621+
* An entry in `src/content/` has an invalid `slug`. This field is reserved for generating entry slugs, and must be a string when present.
622+
*/
623+
InvalidContentEntrySlugError: {
624+
title: 'Invalid content entry slug.',
625+
code: 9002,
626+
message: (collection: string, entryId: string) => {
627+
return `${String(collection)}${String(
628+
entryId
629+
)} has an invalid slug. \`slug\` must be a string.`;
630+
},
631+
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more on the `slug` field.',
632+
},
633+
/**
634+
* @docs
635+
* @see
636+
* - [The reserved entry `slug` field](https://docs.astro.build/en/guides/content-collections/)
637+
* @description
638+
* A content collection schema should not contain the `slug` field. This is reserved by Astro for generating entry slugs. Remove the `slug` field from your schema, or choose a different name.
639+
*/
640+
ContentSchemaContainsSlugError: {
641+
title: 'Content Schema should not contain `slug`.',
642+
code: 9003,
643+
message: (collection: string) => {
644+
return `A content collection schema should not contain \`slug\` since it is reserved for slug generation. Remove this from your ${collection} collection schema.`;
645+
},
646+
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more on the `slug` field.',
647+
},
606648

607649
// Generic catch-all
608650
UnknownError: {

packages/astro/test/content-collections.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe('Content Collections', () => {
7070
expect(Array.isArray(json.withSlugConfig)).to.equal(true);
7171

7272
const slugs = json.withSlugConfig.map((item) => item.slug);
73-
expect(slugs).to.deep.equal(['fancy-one.md', 'excellent-three.md', 'interesting-two.md']);
73+
expect(slugs).to.deep.equal(['fancy-one', 'excellent-three', 'interesting-two']);
7474
});
7575

7676
it('Returns `with union schema` collection', async () => {
@@ -116,7 +116,7 @@ describe('Content Collections', () => {
116116

117117
it('Returns `with custom slugs` collection entry', async () => {
118118
expect(json).to.haveOwnProperty('twoWithSlugConfig');
119-
expect(json.twoWithSlugConfig.slug).to.equal('interesting-two.md');
119+
expect(json.twoWithSlugConfig.slug).to.equal('interesting-two');
120120
});
121121

122122
it('Returns `with union schema` collection entry', async () => {

packages/astro/test/fixtures/content-collections/src/content/config.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import { z, defineCollection } from 'astro:content';
22

3-
const withSlugConfig = defineCollection({
4-
slug({ id, data }) {
5-
return `${data.prefix}-${id}`;
6-
},
7-
schema: z.object({
8-
prefix: z.string(),
9-
}),
3+
const withCustomSlugs = defineCollection({
4+
schema: z.object({}),
105
});
116

127
const withSchemaConfig = defineCollection({
@@ -33,7 +28,7 @@ const withUnionSchema = defineCollection({
3328
});
3429

3530
export const collections = {
36-
'with-slug-config': withSlugConfig,
31+
'with-custom-slugs': withCustomSlugs,
3732
'with-schema-config': withSchemaConfig,
3833
'with-union-schema': withUnionSchema,
3934
}

packages/astro/test/fixtures/content-collections/src/content/with-slug-config/one.md renamed to packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/one.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
prefix: fancy
2+
slug: fancy-one
33
---
44

55
# It's the first page, fancy!

packages/astro/test/fixtures/content-collections/src/content/with-slug-config/three.md renamed to packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/three.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
prefix: excellent
2+
slug: excellent-three
33
---
44

55
# It's the third page, excellent!

0 commit comments

Comments
 (0)