summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/cold-bananas-hear.md5
-rw-r--r--packages/astro/src/content/content-layer.ts48
-rw-r--r--packages/astro/src/content/utils.ts51
-rw-r--r--packages/astro/src/core/errors/errors-data.ts70
-rw-r--r--packages/astro/test/content-layer.test.js21
-rw-r--r--packages/astro/test/fixtures/content-layer/src/content/config.ts67
-rw-r--r--packages/astro/test/fixtures/content-layer/src/pages/collections.json.js3
-rw-r--r--packages/astro/types/content.d.ts8
8 files changed, 252 insertions, 21 deletions
diff --git a/.changeset/cold-bananas-hear.md b/.changeset/cold-bananas-hear.md
new file mode 100644
index 000000000..dfa9ade4a
--- /dev/null
+++ b/.changeset/cold-bananas-hear.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Correctly parse values returned from inline loader
diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts
index bf3213901..2e092ae61 100644
--- a/packages/astro/src/content/content-layer.ts
+++ b/packages/astro/src/content/content-layer.ts
@@ -2,6 +2,7 @@ import { promises as fs, existsSync } from 'node:fs';
import * as fastq from 'fastq';
import type { FSWatcher } from 'vite';
import xxhash from 'xxhash-wasm';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
import type { Logger } from '../core/logger/core.js';
import type { AstroSettings } from '../types/astro.js';
import type { ContentEntryType, RefreshContentOptions } from '../types/public/content.js';
@@ -266,15 +267,54 @@ export class ContentLayer {
}
export async function simpleLoader<TData extends { id: string }>(
- handler: () => Array<TData> | Promise<Array<TData>>,
+ handler: () =>
+ | Array<TData>
+ | Promise<Array<TData>>
+ | Record<string, Record<string, unknown>>
+ | Promise<Record<string, Record<string, unknown>>>,
context: LoaderContext,
) {
const data = await handler();
context.store.clear();
- for (const raw of data) {
- const item = await context.parseData({ id: raw.id, data: raw });
- context.store.set({ id: raw.id, data: item });
+ if (Array.isArray(data)) {
+ for (const raw of data) {
+ if (!raw.id) {
+ throw new AstroError({
+ ...AstroErrorData.ContentLoaderInvalidDataError,
+ message: AstroErrorData.ContentLoaderInvalidDataError.message(
+ context.collection,
+ `Entry missing ID:\n${JSON.stringify({ ...raw, id: undefined }, null, 2)}`,
+ ),
+ });
+ }
+ const item = await context.parseData({ id: raw.id, data: raw });
+ context.store.set({ id: raw.id, data: item });
+ }
+ return;
+ }
+ if (typeof data === 'object') {
+ for (const [id, raw] of Object.entries(data)) {
+ if (raw.id && raw.id !== id) {
+ throw new AstroError({
+ ...AstroErrorData.ContentLoaderInvalidDataError,
+ message: AstroErrorData.ContentLoaderInvalidDataError.message(
+ context.collection,
+ `Object key ${JSON.stringify(id)} does not match ID ${JSON.stringify(raw.id)}`,
+ ),
+ });
+ }
+ const item = await context.parseData({ id, data: raw });
+ context.store.set({ id, data: item });
+ }
+ return;
}
+ throw new AstroError({
+ ...AstroErrorData.ExpectedImageOptions,
+ message: AstroErrorData.ContentLoaderInvalidDataError.message(
+ context.collection,
+ `Invalid data type: ${typeof data}`,
+ ),
+ });
}
/**
* Get the path to the data store file.
diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts
index 1dd1a457f..0fa7bed06 100644
--- a/packages/astro/src/content/utils.ts
+++ b/packages/astro/src/content/utils.ts
@@ -32,6 +32,17 @@ export type ContentLookupMap = {
[collectionName: string]: { type: 'content' | 'data'; entries: { [lookupId: string]: string } };
};
+const entryTypeSchema = z
+ .object({
+ id: z
+ .string({
+ invalid_type_error: 'Content entry `id` must be a string',
+ // Default to empty string so we can validate properly in the loader
+ })
+ .catch(''),
+ })
+ .catchall(z.unknown());
+
const collectionConfigParser = z.union([
z.object({
type: z.literal('content').optional().default('content'),
@@ -47,18 +58,31 @@ const collectionConfigParser = z.union([
loader: z.union([
z.function().returns(
z.union([
- z.array(
+ z.array(entryTypeSchema),
+ z.promise(z.array(entryTypeSchema)),
+ z.record(
+ z.string(),
z
.object({
- id: z.string(),
+ id: z
+ .string({
+ invalid_type_error: 'Content entry `id` must be a string',
+ })
+ .optional(),
})
.catchall(z.unknown()),
),
+
z.promise(
- z.array(
+ z.record(
+ z.string(),
z
.object({
- id: z.string(),
+ id: z
+ .string({
+ invalid_type_error: 'Content entry `id` must be a string',
+ })
+ .optional(),
})
.catchall(z.unknown()),
),
@@ -194,16 +218,19 @@ export async function getEntryDataAndImages<
data = parsed.data as TOutputData;
} else {
if (!formattedError) {
+ const errorType =
+ collectionConfig.type === 'content'
+ ? AstroErrorData.InvalidContentEntryFrontmatterError
+ : AstroErrorData.InvalidContentEntryDataError;
formattedError = new AstroError({
- ...AstroErrorData.InvalidContentEntryFrontmatterError,
- message: AstroErrorData.InvalidContentEntryFrontmatterError.message(
- entry.collection,
- entry.id,
- parsed.error,
- ),
+ ...errorType,
+ message: errorType.message(entry.collection, entry.id, parsed.error),
location: {
- file: entry._internal.filePath,
- line: getYAMLErrorLine(entry._internal.rawData, String(parsed.error.errors[0].path[0])),
+ file: entry._internal?.filePath,
+ line: getYAMLErrorLine(
+ entry._internal?.rawData,
+ String(parsed.error.errors[0].path[0]),
+ ),
column: 0,
},
});
diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts
index 89bdfb55a..fd930cf33 100644
--- a/packages/astro/src/core/errors/errors-data.ts
+++ b/packages/astro/src/core/errors/errors-data.ts
@@ -1490,6 +1490,76 @@ export const InvalidContentEntryFrontmatterError = {
},
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message
+ * **Example error message:**<br/>
+ * **blog** → **post** frontmatter does not match collection schema.<br/>
+ * "title" is required.<br/>
+ * "date" must be a valid date.
+ * @description
+ * A content entry 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.
+ */
+export const InvalidContentEntryDataError = {
+ name: 'InvalidContentEntryDataError',
+ title: 'Content entry data does not match schema.',
+ message(collection: string, entryId: string, error: ZodError) {
+ return [
+ `**${String(collection)} → ${String(entryId)}** data 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.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message
+ * **Example error message:**<br/>
+ * **blog** → **post** data does not match collection schema.<br/>
+ * "title" is required.<br/>
+ * "date" must be a valid date.
+ * @description
+ * A content entry 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.
+ */
+export const ContentEntryDataError = {
+ name: 'ContentEntryDataError',
+ title: 'Content entry data does not match schema.',
+ message(collection: string, entryId: string, error: ZodError) {
+ return [
+ `**${String(collection)} → ${String(entryId)}** data 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.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message
+ * **Example error message:**<br/>
+ * The loader for **blog** returned invalid data.<br/>
+ * Object is missing required property "id".
+ * @description
+ * The loader for a content collection returned invalid data.
+ * Inline loaders must return an array of objects with unique ID fields or a plain object with IDs as keys and entries as values.
+ */
+export const ContentLoaderInvalidDataError = {
+ name: 'ContentLoaderInvalidDataError',
+ title: 'Content entry is missing an ID',
+ message(collection: string, extra: string) {
+ return `**${String(collection)}** entry is missing an ID.\n${extra}`;
+ },
+ hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content loaders.',
+} satisfies ErrorData;
+
/**
* @docs
* @message `COLLECTION_NAME` → `ENTRY_ID` has an invalid slug. `slug` must be a string.
diff --git a/packages/astro/test/content-layer.test.js b/packages/astro/test/content-layer.test.js
index 6fceaec44..5be395386 100644
--- a/packages/astro/test/content-layer.test.js
+++ b/packages/astro/test/content-layer.test.js
@@ -3,8 +3,8 @@ import { promises as fs } from 'node:fs';
import { sep } from 'node:path';
import { sep as posixSep } from 'node:path/posix';
import { after, before, describe, it } from 'node:test';
-import * as devalue from 'devalue';
import * as cheerio from 'cheerio';
+import * as devalue from 'devalue';
import { loadFixture } from './test-utils.js';
describe('Content Layer', () => {
@@ -134,6 +134,23 @@ describe('Content Layer', () => {
});
});
+ it('returns a collection from a simple loader that uses an object', async () => {
+ assert.ok(json.hasOwnProperty('simpleLoaderObject'));
+ assert.ok(Array.isArray(json.simpleLoaderObject));
+ assert.deepEqual(json.simpleLoaderObject[0], {
+ id: 'capybara',
+ collection: 'rodents',
+ data: {
+ name: 'Capybara',
+ scientificName: 'Hydrochoerus hydrochaeris',
+ lifespan: 10,
+ weight: 50000,
+ diet: ['grass', 'aquatic plants', 'bark', 'fruits'],
+ nocturnal: false,
+ },
+ });
+ });
+
it('transforms a reference id to a reference object', async () => {
assert.ok(json.hasOwnProperty('entryWithReference'));
assert.deepEqual(json.entryWithReference.data.cat, { collection: 'cats', id: 'tabby' });
@@ -168,7 +185,7 @@ describe('Content Layer', () => {
});
it('displays public images unchanged', async () => {
- assert.equal($('img[alt="buzz"]').attr('src'), "/buzz.jpg");
+ assert.equal($('img[alt="buzz"]').attr('src'), '/buzz.jpg');
});
it('renders local images', async () => {
diff --git a/packages/astro/test/fixtures/content-layer/src/content/config.ts b/packages/astro/test/fixtures/content-layer/src/content/config.ts
index 79412da66..402bad7fc 100644
--- a/packages/astro/test/fixtures/content-layer/src/content/config.ts
+++ b/packages/astro/test/fixtures/content-layer/src/content/config.ts
@@ -18,6 +18,59 @@ const dogs = defineCollection({
}),
});
+const rodents = defineCollection({
+ loader: () => ({
+ capybara: {
+ name: 'Capybara',
+ scientificName: 'Hydrochoerus hydrochaeris',
+ lifespan: 10,
+ weight: 50000,
+ diet: ['grass', 'aquatic plants', 'bark', 'fruits'],
+ nocturnal: false,
+ },
+ hamster: {
+ name: 'Golden Hamster',
+ scientificName: 'Mesocricetus auratus',
+ lifespan: 2,
+ weight: 120,
+ diet: ['seeds', 'nuts', 'insects'],
+ nocturnal: true,
+ },
+ rat: {
+ name: 'Brown Rat',
+ scientificName: 'Rattus norvegicus',
+ lifespan: 2,
+ weight: 350,
+ diet: ['grains', 'fruits', 'vegetables', 'meat'],
+ nocturnal: true,
+ },
+ mouse: {
+ name: 'House Mouse',
+ scientificName: 'Mus musculus',
+ lifespan: 1,
+ weight: 20,
+ diet: ['seeds', 'grains', 'fruits'],
+ nocturnal: true,
+ },
+ guineaPig: {
+ name: 'Guinea Pig',
+ scientificName: 'Cavia porcellus',
+ lifespan: 5,
+ weight: 1000,
+ diet: ['hay', 'vegetables', 'fruits'],
+ nocturnal: false,
+ },
+ }),
+ schema: z.object({
+ name: z.string(),
+ scientificName: z.string(),
+ lifespan: z.number().int().positive(),
+ weight: z.number().positive(),
+ diet: z.array(z.string()),
+ nocturnal: z.boolean(),
+ }),
+});
+
const cats = defineCollection({
loader: async function () {
return [
@@ -131,7 +184,7 @@ const increment = defineCollection({
data: {
lastValue: lastValue + 1,
lastUpdated: new Date(),
- refreshContextData
+ refreshContextData,
},
});
},
@@ -145,4 +198,14 @@ const increment = defineCollection({
},
});
-export const collections = { blog, dogs, cats, numbers, spacecraft, increment, images, probes };
+export const collections = {
+ blog,
+ dogs,
+ cats,
+ numbers,
+ spacecraft,
+ increment,
+ images,
+ probes,
+ rodents,
+};
diff --git a/packages/astro/test/fixtures/content-layer/src/pages/collections.json.js b/packages/astro/test/fixtures/content-layer/src/pages/collections.json.js
index aea8bfc9a..761ff7dba 100644
--- a/packages/astro/test/fixtures/content-layer/src/pages/collections.json.js
+++ b/packages/astro/test/fixtures/content-layer/src/pages/collections.json.js
@@ -20,6 +20,8 @@ export async function GET() {
const images = await getCollection('images');
+ const simpleLoaderObject = await getCollection('rodents')
+
const probes = await getCollection('probes');
return new Response(
devalue.stringify({
@@ -27,6 +29,7 @@ export async function GET() {
fileLoader,
dataEntry,
simpleLoader,
+ simpleLoaderObject,
entryWithReference,
entryWithImagePath,
referencedEntry,
diff --git a/packages/astro/types/content.d.ts b/packages/astro/types/content.d.ts
index 1715a30a4..8bcf23d8c 100644
--- a/packages/astro/types/content.d.ts
+++ b/packages/astro/types/content.d.ts
@@ -60,7 +60,13 @@ declare module 'astro:content' {
type ContentLayerConfig<S extends BaseSchema, TData extends { id: string } = { id: string }> = {
type?: 'content_layer';
schema?: S | ((context: SchemaContext) => S);
- loader: import('astro/loaders').Loader | (() => Array<TData> | Promise<Array<TData>>);
+ loader:
+ | import('astro/loaders').Loader
+ | (() =>
+ | Array<TData>
+ | Promise<Array<TData>>
+ | Record<string, Omit<TData, 'id'> & { id?: string }>
+ | Promise<Record<string, Omit<TData, 'id'> & { id?: string }>>);
};
type DataCollectionConfig<S extends BaseSchema> = {