Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Content Collections] Add slug frontmatter field #5941

Merged
merged 14 commits into from
Jan 23, 2023
Merged
43 changes: 43 additions & 0 deletions .changeset/large-steaks-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'astro': major
---

Replace the content collection `slug()` config with a new `slug` frontmatter field.
bholmesdev marked this conversation as resolved.
Show resolved Hide resolved

This introduces a reserved `slug` property you can add to any Markdown or MDX collection entry. When present, this will override the generated slug for that entry.

```diff
# src/content/blog/post-1.md
---
title: Post 1
+ slug: post-1-custom-slug
---
```

Astro will respect this slug in the generated `slug` type and when using the `getEntryBySlug()` utility:

```astro
---
import { getEntryBySlug } from 'astro:content';

// Retrieve `src/content/blog/post-1.md` by slug with type safety
const post = await getEntryBySlug('blog', 'post-1-custom-slug');
---
```

#### Migration

If you relied on the `slug()` config option, we suggest moving all custom slugs to `slug` frontmatter properties in each collection entry.
bholmesdev marked this conversation as resolved.
Show resolved Hide resolved

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:

```diff
const blog = defineCollection({
schema: z.object({
- slug: z.string().optional(),
}),
- slug({ defaultSlug, data }) {
- return data.slug ?? defaultSlug;
- },
})
```
30 changes: 21 additions & 9 deletions packages/astro/src/content/types-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
ContentPaths,
getContentPaths,
getEntryInfo,
getEntrySlug,
loadContentConfig,
NoCollectionError,
parseFrontmatter,
} from './utils.js';

type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
Expand Down Expand Up @@ -155,7 +157,16 @@ export async function createContentTypesGenerator({
return { shouldGenerateTypes: false };
}

const { id, slug, collection } = entryInfo;
// `slug` may be present in entry frontmatter.
// This should be respected by the generated `slug` type!
// Parse frontmatter and retrieve `slug` value for this.
// Note: will raise any YAML exceptions and `slug` parse errors (i.e. `slug` is a boolean)
// on dev server startup or production build init.
const rawContents = await fs.promises.readFile(event.entry, 'utf-8');
bholmesdev marked this conversation as resolved.
Show resolved Hide resolved
const { data: frontmatter } = parseFrontmatter(rawContents, fileURLToPath(event.entry));
const slug = getEntrySlug({ ...entryInfo, data: frontmatter });
const { id, collection } = entryInfo;

const collectionKey = JSON.stringify(collection);
const entryKey = JSON.stringify(id);

Expand All @@ -165,7 +176,7 @@ export async function createContentTypesGenerator({
addCollection(contentTypes, collectionKey);
}
if (!(entryKey in contentTypes[collectionKey])) {
addEntry(contentTypes, collectionKey, entryKey, slug);
setEntry(contentTypes, collectionKey, entryKey, slug);
}
return { shouldGenerateTypes: true };
case 'unlink':
Expand All @@ -174,7 +185,12 @@ export async function createContentTypesGenerator({
}
return { shouldGenerateTypes: true };
case 'change':
// noop. Frontmatter types are inferred from collection schema import, so they won't change!
// User may modify `slug` in their frontmatter.
// Only regen types if this change is detected.
if (contentTypes[collectionKey]?.[entryKey]?.slug !== slug) {
setEntry(contentTypes, collectionKey, entryKey, slug);
return { shouldGenerateTypes: true };
}
return { shouldGenerateTypes: false };
}
}
Expand Down Expand Up @@ -243,7 +259,7 @@ function removeCollection(contentMap: ContentTypes, collectionKey: string) {
delete contentMap[collectionKey];
}

function addEntry(
function setEntry(
contentTypes: ContentTypes,
collectionKey: string,
entryKey: string,
Expand Down Expand Up @@ -295,11 +311,7 @@ async function writeContentFiles({
for (const entryKey of entryKeys) {
const entryMetadata = contentTypes[collectionKey][entryKey];
const dataType = collectionConfig?.schema ? `InferEntrySchema<${collectionKey}>` : 'any';
// If user has custom slug function, we can't predict slugs at type compilation.
// Would require parsing all data and evaluating ahead-of-time;
// We evaluate with lazy imports at dev server runtime
// to prevent excessive errors
const slugType = collectionConfig?.slug ? 'string' : JSON.stringify(entryMetadata.slug);
const slugType = JSON.stringify(entryMetadata.slug);
contentTypesStr += `${entryKey}: {\n id: ${entryKey},\n slug: ${slugType},\n body: string,\n collection: ${collectionKey},\n data: ${dataType}\n},\n`;
}
contentTypesStr += `},\n`;
Expand Down
56 changes: 30 additions & 26 deletions packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,6 @@ import { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.

export const collectionConfigParser = z.object({
schema: z.any().optional(),
slug: z
.function()
.args(
z.object({
id: z.string(),
collection: z.string(),
defaultSlug: z.string(),
body: z.string(),
data: z.record(z.any()),
})
)
.returns(z.union([z.string(), z.promise(z.string())]))
.optional(),
});

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

export async function getEntrySlug(entry: Entry, collectionConfig: CollectionConfig) {
return (
collectionConfig.slug?.({
id: entry.id,
data: entry.data,
defaultSlug: entry.slug,
collection: entry.collection,
body: entry.body,
}) ?? entry.slug
);
export function getEntrySlug({
id,
collection,
slug,
data: unparsedData,
}: Pick<Entry, 'id' | 'collection' | 'slug' | 'data'>) {
try {
return z.string().default(slug).parse(unparsedData.slug);
} catch {
throw new AstroError({
...AstroErrorData.InvalidContentEntrySlugError,
message: AstroErrorData.InvalidContentEntrySlugError.message(collection, id),
});
}
}

export async function getEntryData(entry: Entry, collectionConfig: CollectionConfig) {
let data = entry.data;
// Remove reserved `slug` field before parsing data
let { slug, ...data } = entry.data;
if (collectionConfig.schema) {
// TODO: remove for 2.0 stable release
if (
Expand All @@ -90,14 +82,26 @@ export async function getEntryData(entry: Entry, collectionConfig: CollectionCon
code: 99999,
});
}
// Catch reserved `slug` field inside schema
// Note: will not warn for `z.union` or `z.intersection` schemas
if (
typeof collectionConfig.schema === 'object' &&
'shape' in collectionConfig.schema &&
collectionConfig.schema.shape.slug
) {
throw new AstroError({
...AstroErrorData.ContentSchemaContainsSlugError,
message: AstroErrorData.ContentSchemaContainsSlugError.message(entry.collection),
});
}
// Use `safeParseAsync` to allow async transforms
const parsed = await collectionConfig.schema.safeParseAsync(entry.data, { errorMap });
if (parsed.success) {
data = parsed.data;
} else {
const formattedError = new AstroError({
...AstroErrorData.MarkdownContentSchemaValidationError,
message: AstroErrorData.MarkdownContentSchemaValidationError.message(
...AstroErrorData.InvalidContentEntryFrontmatterError,
message: AstroErrorData.InvalidContentEntryFrontmatterError.message(
entry.collection,
entry.id,
parsed.error
Expand Down
7 changes: 4 additions & 3 deletions packages/astro/src/content/vite-plugin-content-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,14 @@ export function astroContentServerPlugin({

const _internal = { filePath: fileId, rawData };
const partialEntry = { data: unparsedData, body, _internal, ...entryInfo };
// TODO: move slug calculation to the start of the build
// to generate a performant lookup map for `getEntryBySlug`
const slug = getEntrySlug(partialEntry);

const collectionConfig = contentConfig?.collections[entryInfo.collection];
const data = collectionConfig
? await getEntryData(partialEntry, collectionConfig)
: unparsedData;
const slug = collectionConfig
? await getEntrySlug({ ...partialEntry, data }, collectionConfig)
: entryInfo.slug;

const code = escapeViteEnvReferences(`
export const id = ${JSON.stringify(entryInfo.id)};
Expand Down
90 changes: 66 additions & 24 deletions packages/astro/src/core/errors/errors-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,30 +497,6 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
title: 'Failed to parse Markdown frontmatter.',
code: 6001,
},
/**
* @docs
* @message
* **Example error message:**<br/>
* Could not parse frontmatter in **blog** → **post.md**<br/>
* "title" is required.<br/>
* "date" must be a valid date.
* @description
* A Markdown document's frontmatter in `src/content/` does not match its collection schema.
* Make sure that all required fields are present, and that all fields are of the correct type.
* You can check against the collection schema in your `src/content/config.*` file.
* See the [Content collections documentation](https://docs.astro.build/en/guides/content-collections/) for more information.
*/
MarkdownContentSchemaValidationError: {
title: 'Content collection frontmatter invalid.',
code: 6002,
message: (collection: string, entryId: string, error: ZodError) => {
return [
`${String(collection)} → ${String(entryId)} frontmatter does not match collection schema.`,
...error.errors.map((zodError) => zodError.message),
].join('\n');
},
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
},
/**
* @docs
* @see
Expand Down Expand Up @@ -603,6 +579,72 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
message: '`astro sync` command failed to generate content collection types.',
hint: 'Check your `src/content/config.*` file for typos.',
},
/**
* @docs
* @kind heading
* @name Content Collection Errors
*/
// Content Collection Errors - 9xxx
UnknownContentCollectionError: {
title: 'Unknown Content Collection Error.',
code: 9000,
},
/**
* @docs
* @message
* **Example error message:**<br/>
* **blog** → **post.md** frontmatter does not match collection schema.<br/>
* "title" is required.<br/>
* "date" must be a valid date.
* @description
* A Markdown or MDX entry in `src/content/` does not match its collection schema.
* Make sure that all required fields are present, and that all fields are of the correct type.
* You can check against the collection schema in your `src/content/config.*` file.
* See the [Content collections documentation](https://docs.astro.build/en/guides/content-collections/) for more information.
*/
InvalidContentEntryFrontmatterError: {
title: 'Content entry frontmatter does not match schema.',
code: 9001,
message: (collection: string, entryId: string, error: ZodError) => {
return [
`${String(collection)} → ${String(entryId)} frontmatter does not match collection schema.`,
...error.errors.map((zodError) => zodError.message),
].join('\n');
},
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
},
/**
* @docs
* @see
* - [The reserved entry `slug` field](https://docs.astro.build/en/guides/content-collections/)
* @description
* An entry in `src/content/` has an invalid `slug`. This field is reserved for generating entry slugs, and must be a string when present.
*/
InvalidContentEntrySlugError: {
title: 'Invalid content entry slug.',
code: 9002,
message: (collection: string, entryId: string) => {
return `${String(collection)} → ${String(
entryId
)} has an invalid slug. \`slug\` must be a string.`;
},
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more on the `slug` field.',
},
/**
* @docs
* @see
* - [The reserved entry `slug` field](https://docs.astro.build/en/guides/content-collections/)
* @description
* 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.
*/
ContentSchemaContainsSlugError: {
title: 'Content Schema should not contain `slug`.',
code: 9003,
message: (collection: string) => {
return `A content collection schema should not contain \`slug\` since it is reserved for slug generation. Remove this from your ${collection} collection schema.`;
},
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more on the `slug` field.',
},

// Generic catch-all
UnknownError: {
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/test/content-collections.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe('Content Collections', () => {
expect(Array.isArray(json.withSlugConfig)).to.equal(true);

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

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

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

it('Returns `with union schema` collection entry', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { z, defineCollection } from 'astro:content';

const withSlugConfig = defineCollection({
slug({ id, data }) {
return `${data.prefix}-${id}`;
},
schema: z.object({
prefix: z.string(),
}),
const withCustomSlugs = defineCollection({
schema: z.object({}),
});

const withSchemaConfig = defineCollection({
Expand All @@ -33,7 +28,7 @@ const withUnionSchema = defineCollection({
});

export const collections = {
'with-slug-config': withSlugConfig,
'with-custom-slugs': withCustomSlugs,
'with-schema-config': withSchemaConfig,
'with-union-schema': withUnionSchema,
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
prefix: fancy
slug: fancy-one
---

# It's the first page, fancy!
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
prefix: excellent
slug: excellent-three
---

# It's the third page, excellent!
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
prefix: interesting
slug: interesting-two
---

# It's the second page, interesting!
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { stripAllRenderFn } from '../utils.js';
export async function get() {
const withoutConfig = stripAllRenderFn(await getCollection('without-config'));
const withSchemaConfig = stripAllRenderFn(await getCollection('with-schema-config'));
const withSlugConfig = stripAllRenderFn(await getCollection('with-slug-config'));
const withSlugConfig = stripAllRenderFn(await getCollection('with-custom-slugs'));
const withUnionSchema = stripAllRenderFn(await getCollection('with-union-schema'));

return {
Expand Down
Loading