mirror of
https://github.com/withastro/astro.git
synced 2025-02-10 22:38:53 -05:00
Improve errors for invalid IDs in content collections (#12892)
* Improve error handling for content collection entries where ID isn't a string * Add passthrough to zod schema to still access other properties after validation * Add new test for numbers as IDs, add changeset * Update error for invalid IDs * Update test for new error message * Update .changeset/dry-dragons-shout.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> * Update errors-data.ts to (possibly) fix tests failing * Update errors-data.ts --------- Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> Co-authored-by: Matt Kane <m@mk.gg>
This commit is contained in:
parent
d5fb7a34ea
commit
8f520f1cc6
9 changed files with 139 additions and 45 deletions
5
.changeset/dry-dragons-shout.md
Normal file
5
.changeset/dry-dragons-shout.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Adds a more descriptive error when a content collection entry has an invalid ID.
|
|
@ -20,8 +20,10 @@ import {
|
||||||
getEntryConfigByExtMap,
|
getEntryConfigByExtMap,
|
||||||
getEntryDataAndImages,
|
getEntryDataAndImages,
|
||||||
globalContentConfigObserver,
|
globalContentConfigObserver,
|
||||||
|
loaderReturnSchema,
|
||||||
safeStringify,
|
safeStringify,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
import type { z } from 'zod';
|
||||||
import { type WrappedWatcher, createWatcherWrapper } from './watcher.js';
|
import { type WrappedWatcher, createWatcherWrapper } from './watcher.js';
|
||||||
|
|
||||||
export interface ContentLayerOptions {
|
export interface ContentLayerOptions {
|
||||||
|
@ -31,6 +33,12 @@ export interface ContentLayerOptions {
|
||||||
watcher?: FSWatcher;
|
watcher?: FSWatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CollectionLoader<TData> = () =>
|
||||||
|
| Array<TData>
|
||||||
|
| Promise<Array<TData>>
|
||||||
|
| Record<string, Record<string, unknown>>
|
||||||
|
| Promise<Record<string, Record<string, unknown>>>;
|
||||||
|
|
||||||
export class ContentLayer {
|
export class ContentLayer {
|
||||||
#logger: Logger;
|
#logger: Logger;
|
||||||
#store: MutableDataStore;
|
#store: MutableDataStore;
|
||||||
|
@ -276,7 +284,7 @@ export class ContentLayer {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof collection.loader === 'function') {
|
if (typeof collection.loader === 'function') {
|
||||||
return simpleLoader(collection.loader, context);
|
return simpleLoader(collection.loader as CollectionLoader<{ id: string }>, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!collection.loader.load) {
|
if (!collection.loader.load) {
|
||||||
|
@ -334,15 +342,38 @@ export class ContentLayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function simpleLoader<TData extends { id: string }>(
|
export async function simpleLoader<TData extends { id: string }>(
|
||||||
handler: () =>
|
handler: CollectionLoader<TData>,
|
||||||
| Array<TData>
|
|
||||||
| Promise<Array<TData>>
|
|
||||||
| Record<string, Record<string, unknown>>
|
|
||||||
| Promise<Record<string, Record<string, unknown>>>,
|
|
||||||
context: LoaderContext,
|
context: LoaderContext,
|
||||||
) {
|
) {
|
||||||
const data = await handler();
|
const unsafeData = await handler();
|
||||||
|
const parsedData = loaderReturnSchema.safeParse(unsafeData);
|
||||||
|
|
||||||
|
if (!parsedData.success) {
|
||||||
|
const issue = parsedData.error.issues[0] as z.ZodInvalidUnionIssue;
|
||||||
|
|
||||||
|
// Due to this being a union, zod will always throw an "Expected array, received object" error along with the other errors.
|
||||||
|
// This error is in the second position if the data is an array, and in the first position if the data is an object.
|
||||||
|
const parseIssue = Array.isArray(unsafeData)
|
||||||
|
? issue.unionErrors[0]
|
||||||
|
: issue.unionErrors[1];
|
||||||
|
|
||||||
|
const error = parseIssue.errors[0];
|
||||||
|
const firstPathItem = error.path[0];
|
||||||
|
|
||||||
|
const entry = Array.isArray(unsafeData)
|
||||||
|
? unsafeData[firstPathItem as number]
|
||||||
|
: unsafeData[firstPathItem as string];
|
||||||
|
|
||||||
|
throw new AstroError({
|
||||||
|
...AstroErrorData.ContentLoaderReturnsInvalidId,
|
||||||
|
message: AstroErrorData.ContentLoaderReturnsInvalidId.message(context.collection, entry),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsedData.data;
|
||||||
|
|
||||||
context.store.clear();
|
context.store.clear();
|
||||||
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
for (const raw of data) {
|
for (const raw of data) {
|
||||||
if (!raw.id) {
|
if (!raw.id) {
|
||||||
|
|
|
@ -26,8 +26,9 @@ import {
|
||||||
} from './consts.js';
|
} from './consts.js';
|
||||||
import { glob } from './loaders/glob.js';
|
import { glob } from './loaders/glob.js';
|
||||||
import { createImage } from './runtime-assets.js';
|
import { createImage } from './runtime-assets.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Amap from a collection + slug to the local file path.
|
* A map from a collection + slug to the local file path.
|
||||||
* This is used internally to resolve entry imports when using `getEntry()`.
|
* This is used internally to resolve entry imports when using `getEntry()`.
|
||||||
* @see `templates/content/module.mjs`
|
* @see `templates/content/module.mjs`
|
||||||
*/
|
*/
|
||||||
|
@ -41,10 +42,20 @@ const entryTypeSchema = z
|
||||||
.string({
|
.string({
|
||||||
invalid_type_error: 'Content entry `id` must be a string',
|
invalid_type_error: 'Content entry `id` must be a string',
|
||||||
// Default to empty string so we can validate properly in the loader
|
// Default to empty string so we can validate properly in the loader
|
||||||
})
|
}),
|
||||||
.catch(''),
|
}).passthrough();
|
||||||
})
|
|
||||||
.catchall(z.unknown());
|
export const loaderReturnSchema = z.union([
|
||||||
|
z.array(entryTypeSchema),
|
||||||
|
z.record(
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
id: z.string({
|
||||||
|
invalid_type_error: 'Content entry `id` must be a string'
|
||||||
|
}).optional()
|
||||||
|
}).passthrough()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const collectionConfigParser = z.union([
|
const collectionConfigParser = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
|
@ -59,39 +70,7 @@ const collectionConfigParser = z.union([
|
||||||
type: z.literal(CONTENT_LAYER_TYPE),
|
type: z.literal(CONTENT_LAYER_TYPE),
|
||||||
schema: z.any().optional(),
|
schema: z.any().optional(),
|
||||||
loader: z.union([
|
loader: z.union([
|
||||||
z.function().returns(
|
z.function(),
|
||||||
z.union([
|
|
||||||
z.array(entryTypeSchema),
|
|
||||||
z.promise(z.array(entryTypeSchema)),
|
|
||||||
z.record(
|
|
||||||
z.string(),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
id: z
|
|
||||||
.string({
|
|
||||||
invalid_type_error: 'Content entry `id` must be a string',
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.catchall(z.unknown()),
|
|
||||||
),
|
|
||||||
|
|
||||||
z.promise(
|
|
||||||
z.record(
|
|
||||||
z.string(),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
id: z
|
|
||||||
.string({
|
|
||||||
invalid_type_error: 'Content entry `id` must be a string',
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.catchall(z.unknown()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
load: z.function(
|
load: z.function(
|
||||||
|
|
|
@ -1565,6 +1565,32 @@ export const InvalidContentEntryDataError = {
|
||||||
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
|
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
|
||||||
} satisfies ErrorData;
|
} satisfies ErrorData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @docs
|
||||||
|
* @message
|
||||||
|
* **Example error message:**<br/>
|
||||||
|
* The content loader for the collection **blog** returned an entry with an invalid `id`:<br/>
|
||||||
|
* {<br/>
|
||||||
|
* "id": 1,<br/>
|
||||||
|
* "title": "Hello, World!"<br/>
|
||||||
|
* }
|
||||||
|
* @description
|
||||||
|
* A content loader returned an invalid `id`.
|
||||||
|
* Make sure that the `id` of the entry is a string.
|
||||||
|
* See the [Content collections documentation](https://docs.astro.build/en/guides/content-collections/) for more information.
|
||||||
|
*/
|
||||||
|
export const ContentLoaderReturnsInvalidId = {
|
||||||
|
name: 'ContentLoaderReturnsInvalidId',
|
||||||
|
title: 'Content loader returned an entry with an invalid `id`.',
|
||||||
|
message(collection: string, entry: any) {
|
||||||
|
return [
|
||||||
|
`The content loader for the collection **${String(collection)}** returned an entry with an invalid \`id\`:`,
|
||||||
|
JSON.stringify(entry, null, 2),
|
||||||
|
].join('\n');
|
||||||
|
},
|
||||||
|
hint: 'Make sure that the `id` of the entry is a string. See https://docs.astro.build/en/guides/content-collections/ for more information on content loaders.',
|
||||||
|
} satisfies ErrorData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @docs
|
* @docs
|
||||||
* @message
|
* @message
|
||||||
|
|
|
@ -300,6 +300,21 @@ describe('Content Collections', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('With numbers for IDs', () => {
|
||||||
|
it('Throws the right error', async () => {
|
||||||
|
const fixture = await loadFixture({
|
||||||
|
root: './fixtures/content-collections-number-id/',
|
||||||
|
});
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
await fixture.build({ force: true });
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
assert.match(error, /returned an entry with an invalid `id`/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('With empty collections directory', () => {
|
describe('With empty collections directory', () => {
|
||||||
it('Handles the empty directory correctly', async () => {
|
it('Handles the empty directory correctly', async () => {
|
||||||
const fixture = await loadFixture({
|
const fixture = await loadFixture({
|
||||||
|
|
9
packages/astro/test/fixtures/content-collections-number-id/package.json
vendored
Normal file
9
packages/astro/test/fixtures/content-collections-number-id/package.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "@test/content-collections-number-id",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
17
packages/astro/test/fixtures/content-collections-number-id/src/content.config.ts
vendored
Normal file
17
packages/astro/test/fixtures/content-collections-number-id/src/content.config.ts
vendored
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { defineCollection, z } from 'astro:content';
|
||||||
|
|
||||||
|
const data = defineCollection({
|
||||||
|
loader: async () => ([
|
||||||
|
{ id: 1, title: 'One!' },
|
||||||
|
{ id: 2, title: 'Two!' },
|
||||||
|
{ id: 3, title: 'Three!' },
|
||||||
|
]),
|
||||||
|
schema: z.object({
|
||||||
|
id: z.number(),
|
||||||
|
title: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
data,
|
||||||
|
};
|
6
packages/astro/test/fixtures/content-collections-number-id/src/pages/index.astro
vendored
Normal file
6
packages/astro/test/fixtures/content-collections-number-id/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
import { getEntry } from 'astro:content';
|
||||||
|
const data = await getEntry('blog', 1);
|
||||||
|
---
|
||||||
|
|
||||||
|
{JSON.stringify(data)}
|
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
@ -2601,6 +2601,12 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../..
|
version: link:../../..
|
||||||
|
|
||||||
|
packages/astro/test/fixtures/content-collections-number-id:
|
||||||
|
dependencies:
|
||||||
|
astro:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../..
|
||||||
|
|
||||||
packages/astro/test/fixtures/content-collections-same-contents:
|
packages/astro/test/fixtures/content-collections-same-contents:
|
||||||
dependencies:
|
dependencies:
|
||||||
astro:
|
astro:
|
||||||
|
|
Loading…
Add table
Reference in a new issue