Skip to content

Commit

Permalink
fix: correctly parse inline loader values (#12035)
Browse files Browse the repository at this point in the history
  • Loading branch information
ascorbic committed Sep 20, 2024
1 parent ddc3a08 commit 325a57c
Show file tree
Hide file tree
Showing 8 changed files with 252 additions and 21 deletions.
5 changes: 5 additions & 0 deletions .changeset/cold-bananas-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Correctly parse values returned from inline loader
48 changes: 44 additions & 4 deletions packages/astro/src/content/content-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
51 changes: 39 additions & 12 deletions packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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()),
),
Expand Down Expand Up @@ -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,
},
});
Expand Down
70 changes: 70 additions & 0 deletions packages/astro/src/core/errors/errors-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions packages/astro/test/content-layer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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 () => {
Expand Down
67 changes: 65 additions & 2 deletions packages/astro/test/fixtures/content-layer/src/content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -131,7 +184,7 @@ const increment = defineCollection({
data: {
lastValue: lastValue + 1,
lastUpdated: new Date(),
refreshContextData
refreshContextData,
},
});
},
Expand All @@ -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,
};
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ export async function GET() {

const images = await getCollection('images');

const simpleLoaderObject = await getCollection('rodents')

const probes = await getCollection('probes');
return new Response(
devalue.stringify({
customLoader,
fileLoader,
dataEntry,
simpleLoader,
simpleLoaderObject,
entryWithReference,
entryWithImagePath,
referencedEntry,
Expand Down
8 changes: 7 additions & 1 deletion packages/astro/types/content.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {
Expand Down

0 comments on commit 325a57c

Please sign in to comment.