Skip to content

Commit

Permalink
[Content Collections] Add slug frontmatter field (#5941)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
bholmesdev and sarah11918 committed Jan 23, 2023
1 parent f62ec16 commit 3048238
Show file tree
Hide file tree
Showing 12 changed files with 188 additions and 77 deletions.
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
---

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.

When present in a Markdown or MDX file, 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, you will need to move all custom slugs to `slug` frontmatter properties in each collection entry.

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;
- },
})
```
44 changes: 35 additions & 9 deletions packages/astro/src/content/types-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ import {
ContentConfig,
ContentObservable,
ContentPaths,
EntryInfo,
getContentPaths,
getEntryInfo,
getEntrySlug,
loadContentConfig,
NoCollectionError,
parseFrontmatter,
} from './utils.js';

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

const { id, slug, collection } = entryInfo;
const { id, collection } = entryInfo;

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

switch (event.name) {
case 'add':
const addedSlug = await parseSlug({ fs, event, entryInfo });
if (!(collectionKey in contentTypes)) {
addCollection(contentTypes, collectionKey);
}
if (!(entryKey in contentTypes[collectionKey])) {
addEntry(contentTypes, collectionKey, entryKey, slug);
setEntry(contentTypes, collectionKey, entryKey, addedSlug);
}
return { shouldGenerateTypes: true };
case 'unlink':
Expand All @@ -174,7 +179,13 @@ 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.
const changedSlug = await parseSlug({ fs, event, entryInfo });
if (contentTypes[collectionKey]?.[entryKey]?.slug !== changedSlug) {
setEntry(contentTypes, collectionKey, entryKey, changedSlug);
return { shouldGenerateTypes: true };
}
return { shouldGenerateTypes: false };
}
}
Expand Down Expand Up @@ -243,7 +254,26 @@ function removeCollection(contentMap: ContentTypes, collectionKey: string) {
delete contentMap[collectionKey];
}

function addEntry(
async function parseSlug({
fs,
event,
entryInfo,
}: {
fs: typeof fsMod;
event: ContentEvent;
entryInfo: 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');
const { data: frontmatter } = parseFrontmatter(rawContents, fileURLToPath(event.entry));
return getEntrySlug({ ...entryInfo, data: frontmatter });
}

function setEntry(
contentTypes: ContentTypes,
collectionKey: string,
entryKey: string,
Expand Down Expand Up @@ -295,11 +325,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!
Loading

0 comments on commit 3048238

Please sign in to comment.