Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: correctly parse inline loader values #12035

Merged
merged 1 commit into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading