mirror of
https://github.com/withastro/astro.git
synced 2025-03-10 23:01:26 -05:00
fix: correctly parse inline loader values (#12035)
This commit is contained in:
parent
ddc3a08e8f
commit
325a57c543
8 changed files with 252 additions and 21 deletions
5
.changeset/cold-bananas-hear.md
Normal file
5
.changeset/cold-bananas-hear.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Correctly parse values returned from inline loader
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
8
packages/astro/types/content.d.ts
vendored
8
packages/astro/types/content.d.ts
vendored
|
@ -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> = {
|
||||
|
|
Loading…
Add table
Reference in a new issue