mirror of
https://github.com/withastro/astro.git
synced 2025-01-06 22:10:10 -05:00
[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>
This commit is contained in:
parent
f62ec162c2
commit
304823811e
12 changed files with 188 additions and 77 deletions
43
.changeset/large-steaks-film.md
Normal file
43
.changeset/large-steaks-film.md
Normal file
|
@ -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;
|
||||
- },
|
||||
})
|
||||
```
|
|
@ -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';
|
||||
|
@ -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':
|
||||
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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`;
|
||||
|
|
|
@ -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 }) {
|
||||
|
@ -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 (
|
||||
|
@ -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
|
||||
|
|
|
@ -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)};
|
||||
|
|
|
@ -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
|
||||
|
@ -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: {
|
||||
|
|
|
@ -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 () => {
|
||||
|
@ -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 () => {
|
||||
|
|
|
@ -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({
|
||||
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
prefix: fancy
|
||||
slug: fancy-one
|
||||
---
|
||||
|
||||
# It's the first page, fancy!
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
prefix: excellent
|
||||
slug: excellent-three
|
||||
---
|
||||
|
||||
# It's the third page, excellent!
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
prefix: interesting
|
||||
slug: interesting-two
|
||||
---
|
||||
|
||||
# It's the second page, interesting!
|
|
@ -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 {
|
||||
|
|
|
@ -5,7 +5,7 @@ import { stripRenderFn } from '../utils.js';
|
|||
export async function get() {
|
||||
const columbiaWithoutConfig = stripRenderFn(await getEntryBySlug('without-config', 'columbia'));
|
||||
const oneWithSchemaConfig = stripRenderFn(await getEntryBySlug('with-schema-config', 'one'));
|
||||
const twoWithSlugConfig = stripRenderFn(await getEntryBySlug('with-slug-config', 'interesting-two.md'));
|
||||
const twoWithSlugConfig = stripRenderFn(await getEntryBySlug('with-custom-slugs', 'interesting-two'));
|
||||
const postWithUnionSchema = stripRenderFn(await getEntryBySlug('with-union-schema', 'post'));
|
||||
|
||||
return {
|
||||
|
|
Loading…
Reference in a new issue