From 304823811eddd8e72aa1d8e2d39b40ab5cda3565 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Mon, 23 Jan 2023 15:04:56 -0500 Subject: [PATCH] [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 * fix: avoid parsing slug on unlink * docs: clarify old API is for beta users Co-authored-by: Sarah Rainsberger --- .changeset/large-steaks-film.md | 43 +++++++++ packages/astro/src/content/types-generator.ts | 44 +++++++-- packages/astro/src/content/utils.ts | 56 ++++++------ .../src/content/vite-plugin-content-server.ts | 7 +- packages/astro/src/core/errors/errors-data.ts | 90 ++++++++++++++----- .../astro/test/content-collections.test.js | 4 +- .../content-collections/src/content/config.ts | 11 +-- .../one.md | 2 +- .../three.md | 2 +- .../two.md | 2 +- .../src/pages/collections.json.js | 2 +- .../src/pages/entries.json.js | 2 +- 12 files changed, 188 insertions(+), 77 deletions(-) create mode 100644 .changeset/large-steaks-film.md rename packages/astro/test/fixtures/content-collections/src/content/{with-slug-config => with-custom-slugs}/one.md (70%) rename packages/astro/test/fixtures/content-collections/src/content/{with-slug-config => with-custom-slugs}/three.md (66%) rename packages/astro/test/fixtures/content-collections/src/content/{with-slug-config => with-custom-slugs}/two.md (67%) diff --git a/.changeset/large-steaks-film.md b/.changeset/large-steaks-film.md new file mode 100644 index 0000000000..a919c411eb --- /dev/null +++ b/.changeset/large-steaks-film.md @@ -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; +- }, +}) +``` diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 813f21c226..b6f359b2cd 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -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`; diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index a14be460aa..9623054a83 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -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) { + 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 diff --git a/packages/astro/src/content/vite-plugin-content-server.ts b/packages/astro/src/content/vite-plugin-content-server.ts index dda1a416f3..a0399b94e5 100644 --- a/packages/astro/src/content/vite-plugin-content-server.ts +++ b/packages/astro/src/content/vite-plugin-content-server.ts @@ -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)}; diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 0fc56a06d8..0b74ca6752 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -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:**
- * Could not parse frontmatter in **blog** → **post.md**
- * "title" is required.
- * "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:**
+ * **blog** → **post.md** frontmatter does not match collection schema.
+ * "title" is required.
+ * "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: { diff --git a/packages/astro/test/content-collections.test.js b/packages/astro/test/content-collections.test.js index 4a900bdfc9..2561bdbabc 100644 --- a/packages/astro/test/content-collections.test.js +++ b/packages/astro/test/content-collections.test.js @@ -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 () => { diff --git a/packages/astro/test/fixtures/content-collections/src/content/config.ts b/packages/astro/test/fixtures/content-collections/src/content/config.ts index dee35967c0..fbd4e381da 100644 --- a/packages/astro/test/fixtures/content-collections/src/content/config.ts +++ b/packages/astro/test/fixtures/content-collections/src/content/config.ts @@ -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, } diff --git a/packages/astro/test/fixtures/content-collections/src/content/with-slug-config/one.md b/packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/one.md similarity index 70% rename from packages/astro/test/fixtures/content-collections/src/content/with-slug-config/one.md rename to packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/one.md index c066d42d70..d6d5bd9079 100644 --- a/packages/astro/test/fixtures/content-collections/src/content/with-slug-config/one.md +++ b/packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/one.md @@ -1,5 +1,5 @@ --- -prefix: fancy +slug: fancy-one --- # It's the first page, fancy! diff --git a/packages/astro/test/fixtures/content-collections/src/content/with-slug-config/three.md b/packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/three.md similarity index 66% rename from packages/astro/test/fixtures/content-collections/src/content/with-slug-config/three.md rename to packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/three.md index 6d88598e18..7352e4e0f5 100644 --- a/packages/astro/test/fixtures/content-collections/src/content/with-slug-config/three.md +++ b/packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/three.md @@ -1,5 +1,5 @@ --- -prefix: excellent +slug: excellent-three --- # It's the third page, excellent! diff --git a/packages/astro/test/fixtures/content-collections/src/content/with-slug-config/two.md b/packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/two.md similarity index 67% rename from packages/astro/test/fixtures/content-collections/src/content/with-slug-config/two.md rename to packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/two.md index f15270f99e..292cdfc048 100644 --- a/packages/astro/test/fixtures/content-collections/src/content/with-slug-config/two.md +++ b/packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/two.md @@ -1,5 +1,5 @@ --- -prefix: interesting +slug: interesting-two --- # It's the second page, interesting! diff --git a/packages/astro/test/fixtures/content-collections/src/pages/collections.json.js b/packages/astro/test/fixtures/content-collections/src/pages/collections.json.js index 897f2ebdd2..e74d03ad9b 100644 --- a/packages/astro/test/fixtures/content-collections/src/pages/collections.json.js +++ b/packages/astro/test/fixtures/content-collections/src/pages/collections.json.js @@ -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 { diff --git a/packages/astro/test/fixtures/content-collections/src/pages/entries.json.js b/packages/astro/test/fixtures/content-collections/src/pages/entries.json.js index 05fb1187ba..0d7d22d086 100644 --- a/packages/astro/test/fixtures/content-collections/src/pages/entries.json.js +++ b/packages/astro/test/fixtures/content-collections/src/pages/entries.json.js @@ -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 {