summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/large-steaks-film.md43
-rw-r--r--packages/astro/src/content/types-generator.ts44
-rw-r--r--packages/astro/src/content/utils.ts56
-rw-r--r--packages/astro/src/content/vite-plugin-content-server.ts7
-rw-r--r--packages/astro/src/core/errors/errors-data.ts90
-rw-r--r--packages/astro/test/content-collections.test.js4
-rw-r--r--packages/astro/test/fixtures/content-collections/src/content/config.ts11
-rw-r--r--packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/one.md (renamed from packages/astro/test/fixtures/content-collections/src/content/with-slug-config/one.md)2
-rw-r--r--packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/three.md (renamed from packages/astro/test/fixtures/content-collections/src/content/with-slug-config/three.md)2
-rw-r--r--packages/astro/test/fixtures/content-collections/src/content/with-custom-slugs/two.md (renamed from packages/astro/test/fixtures/content-collections/src/content/with-slug-config/two.md)2
-rw-r--r--packages/astro/test/fixtures/content-collections/src/pages/collections.json.js2
-rw-r--r--packages/astro/test/fixtures/content-collections/src/pages/entries.json.js2
12 files changed, 188 insertions, 77 deletions
diff --git a/.changeset/large-steaks-film.md b/.changeset/large-steaks-film.md
new file mode 100644
index 000000000..a919c411e
--- /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 813f21c22..b6f359b2c 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 a14be460a..9623054a8 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<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
diff --git a/packages/astro/src/content/vite-plugin-content-server.ts b/packages/astro/src/content/vite-plugin-content-server.ts
index dda1a416f..a0399b94e 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 0fc56a06d..0b74ca675 100644
--- a/packages/astro/src/core/errors/errors-data.ts
+++ b/packages/astro/src/core/errors/errors-data.ts
@@ -499,30 +499,6 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
},
/**
* @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
* - [Modifying frontmatter programmatically](https://docs.astro.build/en/guides/markdown-content/#modifying-frontmatter-programmatically)
* @description
@@ -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: {
diff --git a/packages/astro/test/content-collections.test.js b/packages/astro/test/content-collections.test.js
index 4a900bdfc..2561bdbab 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 dee35967c..fbd4e381d 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
index c066d42d7..d6d5bd907 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
index 6d88598e1..7352e4e0f 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
index f15270f99..292cdfc04 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 897f2ebdd..e74d03ad9 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 05fb1187b..0d7d22d08 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 {