diff --git a/packages/astro/src/config/content.ts b/packages/astro/src/config/content.ts index a9bc36a9ba59..c486988cbf11 100644 --- a/packages/astro/src/config/content.ts +++ b/packages/astro/src/config/content.ts @@ -99,7 +99,10 @@ type LiveDataCollectionConfig = { loader: L; }; -export type BaseCollectionConfig = ContentCollectionConfig | DataCollectionConfig | ContentLayerConfig +export type BaseCollectionConfig = + | ContentCollectionConfig + | DataCollectionConfig + | ContentLayerConfig; export type CollectionConfig< S extends BaseSchema, diff --git a/packages/astro/src/config/entrypoint.ts b/packages/astro/src/config/entrypoint.ts index 64af9412e43a..b67d0d8622f8 100644 --- a/packages/astro/src/config/entrypoint.ts +++ b/packages/astro/src/config/entrypoint.ts @@ -11,7 +11,7 @@ export { type BaseSchema, type SchemaContext, type CollectionConfig, - type BaseCollectionConfig + type BaseCollectionConfig, } from './content.js'; export { defineConfig, getViteConfig } from './index.js'; export { envField } from '../env/config.js'; diff --git a/packages/astro/src/content/loaders/errors.ts b/packages/astro/src/content/loaders/errors.ts new file mode 100644 index 000000000000..aaa16ac8fe60 --- /dev/null +++ b/packages/astro/src/content/loaders/errors.ts @@ -0,0 +1,62 @@ +import type { ZodError } from "zod"; + +export class LiveCollectionError extends Error { + constructor( + public readonly collection: string, + public readonly message: string, + public readonly cause?: Error, + ) { + super(message); + this.name = 'LiveCollectionError'; + } + static is(error: unknown): error is LiveCollectionError { + return error instanceof LiveCollectionError; + } +} + +export class LiveEntryNotFoundError extends LiveCollectionError { + constructor(collection: string, entryFilter: string | Record) { + super( + collection, + `Entry ${collection} → ${typeof entryFilter === 'string' ? entryFilter : JSON.stringify(entryFilter)} was not found.`, + ); + this.name = 'LiveEntryNotFoundError'; + } + static is(error: unknown): error is LiveEntryNotFoundError { + return (error as any)?.name === 'LiveEntryNotFoundError'; + } +} + +export class LiveCollectionValidationError extends LiveCollectionError { + constructor(collection: string, entryId: string, error: ZodError) { + super( + collection, + [ + `**${collection} → ${entryId}** data does not match the collection schema.\n`, + ...error.errors.map((zodError) => ` **${zodError.path.join('.')}**: ${zodError.message}`), + '', + ].join('\n'), + ); + this.name = 'LiveCollectionValidationError'; + } + static is(error: unknown): error is LiveCollectionValidationError { + return (error as any)?.name === 'LiveCollectionValidationError'; + } +} + +export class LiveCollectionCacheHintError extends LiveCollectionError { + constructor(collection: string, entryId: string | undefined, error: ZodError) { + super( + collection, + [ + `**${String(collection)}${entryId ? ` → ${String(entryId)}` : ''}** returned an invalid cache hint.\n`, + ...error.errors.map((zodError) => ` **${zodError.path.join('.')}**: ${zodError.message}`), + '', + ].join('\n'), + ); + this.name = 'LiveCollectionCacheHintError'; + } + static is(error: unknown): error is LiveCollectionCacheHintError { + return (error as any)?.name === 'LiveCollectionCacheHintError'; + } +} diff --git a/packages/astro/src/content/loaders/types.ts b/packages/astro/src/content/loaders/types.ts index f04e50417d57..cc4ea0cb53a1 100644 --- a/packages/astro/src/content/loaders/types.ts +++ b/packages/astro/src/content/loaders/types.ts @@ -66,13 +66,16 @@ export interface LiveLoader< TData extends Record = Record, TEntryFilter extends Record | never = never, TCollectionFilter extends Record | never = never, + TError extends Error = Error, > { /** Unique name of the loader, e.g. the npm package name */ name: string; /** Load a single entry */ - loadEntry: (context: LoadEntryContext) => Promise | undefined>; + loadEntry: ( + context: LoadEntryContext, + ) => Promise | undefined | { error: TError }>; /** Load a collection of entries */ loadCollection: ( context: LoadCollectionContext, - ) => Promise>; + ) => Promise | { error: TError }>; } diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index 36523562cb91..f2e02c6e1a81 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -20,12 +20,27 @@ import { render as serverRender, unescapeHTML, } from '../runtime/server/index.js'; -import type { LiveDataEntry } from '../types/public/content.js'; +import type { + LiveDataCollectionResult, + LiveDataEntry, + LiveDataEntryResult, +} from '../types/public/content.js'; import { IMAGE_IMPORT_PREFIX, type LIVE_CONTENT_TYPE } from './consts.js'; import { type DataEntry, globalDataStore } from './data-store.js'; import type { LiveLoader } from './loaders/types.js'; import type { ContentLookupMap } from './utils.js'; - +import { + LiveCollectionError, + LiveCollectionCacheHintError, + LiveEntryNotFoundError, + LiveCollectionValidationError, +} from './loaders/errors.js'; +export { + LiveCollectionError, + LiveCollectionCacheHintError, + LiveEntryNotFoundError, + LiveCollectionValidationError, +}; type LazyImport = () => Promise; type GlobResult = Record; type CollectionToEntryMap = Record; @@ -63,37 +78,39 @@ async function parseLiveEntry( entry: LiveDataEntry, schema: z.ZodType, collection: string, -): Promise { - const parsed = await schema.safeParseAsync(entry.data); - if (!parsed.success) { - throw new AstroError({ - ...AstroErrorData.InvalidContentEntryDataError, - message: AstroErrorData.InvalidContentEntryDataError.message( - collection, - entry.id, - parsed.error, - ), - }); - } - if (entry.cacheHint) { - const cacheHint = cacheHintSchema.safeParse(entry.cacheHint); +): Promise<{ entry?: LiveDataEntry; error?: LiveCollectionError }> { + try { + const parsed = await schema.safeParseAsync(entry.data); + if (!parsed.success) { + return { + error: new LiveCollectionValidationError(collection, entry.id, parsed.error), + }; + } + if (entry.cacheHint) { + const cacheHint = cacheHintSchema.safeParse(entry.cacheHint); - if (!cacheHint.success) { - throw new AstroError({ - ...AstroErrorData.InvalidCacheHintError, - message: AstroErrorData.InvalidCacheHintError.message( - collection, - entry.id, - cacheHint.error, - ), - }); + if (!cacheHint.success) { + return { + error: new LiveCollectionCacheHintError(collection, entry.id, cacheHint.error), + }; + } + entry.cacheHint = cacheHint.data; } - entry.cacheHint = cacheHint.data; + return { + entry: { + ...entry, + data: parsed.data, + }, + }; + } catch (error) { + return { + error: new LiveCollectionError( + collection, + `Unexpected error parsing entry ${entry.id} in collection ${collection}`, + error as Error, + ), + }; } - return { - ...entry, - data: parsed.data, - }; } export function createGetCollection({ @@ -114,49 +131,10 @@ export function createGetCollection({ filter?: ((entry: any) => unknown) | Record, ) { if (collection in liveCollections) { - if (typeof filter === 'function') { - throw new AstroError({ - ...AstroErrorData.UnknownContentCollectionError, - message: `The filter function is not supported for live collections. Please use a filter object instead.`, - }); - } - - const context = { - filter, - }; - - const response = await ( - liveCollections[collection].loader as LiveLoader> - )?.loadCollection?.(context); - - const { schema } = liveCollections[collection]; - - if (schema) { - response.entries = await Promise.all( - response.entries.map((entry) => parseLiveEntry(entry, schema, collection)), - ); - } - - if (response.cacheHint) { - const cacheHint = cacheHintSchema.safeParse(response.cacheHint); - - if (!cacheHint.success) { - throw new AstroError({ - ...AstroErrorData.InvalidCacheHintError, - message: AstroErrorData.InvalidCacheHintError.message( - collection, - undefined, - cacheHint.error, - ), - }); - } - response.cacheHint = cacheHint.data; - } - - return { - ...response, - collection, - }; + throw new AstroError({ + ...AstroErrorData.UnknownContentCollectionError, + message: `Collection "${collection}" is a live collection. Use getLiveCollection() instead of getCollection().`, + }); } const hasFilter = typeof filter === 'function'; @@ -406,37 +384,10 @@ export function createGetEntry({ } if (collection in liveCollections) { - if (!lookup) { - throw new AstroError({ - ...AstroErrorData.UnknownContentCollectionError, - message: '`getEntry()` requires an entry identifier as the second argument.', - }); - } - - const lookupObject = { - filter: typeof lookup === 'string' ? { id: lookup } : lookup, - }; - - let entry = await ( - liveCollections[collection].loader as LiveLoader< - Record, - Record - > - )?.loadEntry?.(lookupObject); - - if (!entry) { - return; - } - - const { schema } = liveCollections[collection]; - if (schema) { - entry = await parseLiveEntry(entry, schema, collection); - } - - return { - ...entry, - collection, - }; + throw new AstroError({ + ...AstroErrorData.UnknownContentCollectionError, + message: `Collection "${collection}" is a live collection. Use getLiveEntry() instead of getEntry().`, + }); } if (typeof lookupId === 'object') { throw new AstroError({ @@ -511,6 +462,186 @@ export function createGetEntries(getEntry: ReturnType) { }; } +export function createGetLiveCollection({ + liveCollections, +}: { + liveCollections: LiveCollectionConfigMap; +}) { + return async function getLiveCollection( + collection: string, + filter?: Record, + ): Promise { + if (!(collection in liveCollections)) { + return { + error: new LiveCollectionError( + collection, + `Collection "${collection}" is not a live collection. Use getCollection() instead of getLiveCollection() to load regular content collections.`, + ), + }; + } + + try { + const context = { + filter, + }; + + const response = await ( + liveCollections[collection].loader as LiveLoader> + )?.loadCollection?.(context); + + // Check if loader returned an error + if (response && 'error' in response) { + return { error: response.error }; + } + + const { schema } = liveCollections[collection]; + + let processedEntries = response.entries; + if (schema) { + const entryResults = await Promise.all( + response.entries.map((entry) => parseLiveEntry(entry, schema, collection)), + ); + + // Check for parsing errors + for (const result of entryResults) { + if (result.error) { + // Return early on the first error + return { error: result.error }; + } + } + + processedEntries = entryResults.map((result) => result.entry!); + } + + let cacheHint = response.cacheHint; + if (cacheHint) { + const cacheHintResult = cacheHintSchema.safeParse(cacheHint); + + if (!cacheHintResult.success) { + return { + error: new LiveCollectionCacheHintError(collection, undefined, cacheHintResult.error), + }; + } + cacheHint = cacheHintResult.data; + } + + // Aggregate cache hints from individual entries if any + if (processedEntries.length > 0) { + const entryTags = new Set(); + let minMaxAge: number | undefined; + + for (const entry of processedEntries) { + if (entry.cacheHint) { + if (entry.cacheHint.tags) { + entry.cacheHint.tags.forEach((tag) => entryTags.add(tag)); + } + if (typeof entry.cacheHint.maxAge === 'number') { + minMaxAge = + minMaxAge === undefined + ? entry.cacheHint.maxAge + : Math.min(minMaxAge, entry.cacheHint.maxAge); + } + } + } + + // Merge collection and entry cache hints + if (entryTags.size > 0 || minMaxAge !== undefined || cacheHint) { + const mergedCacheHint: any = {}; + if (cacheHint?.tags || entryTags.size > 0) { + mergedCacheHint.tags = [...(cacheHint?.tags || []), ...entryTags]; + } + if (cacheHint?.maxAge !== undefined || minMaxAge !== undefined) { + mergedCacheHint.maxAge = + cacheHint?.maxAge !== undefined && minMaxAge !== undefined + ? Math.min(cacheHint.maxAge, minMaxAge) + : (cacheHint?.maxAge ?? minMaxAge); + } + cacheHint = mergedCacheHint; + } + } + + return { + entries: processedEntries, + cacheHint, + }; + } catch (error) { + return { + error: new LiveCollectionError( + collection, + `Unexpected error loading collection ${collection}`, + error as Error, + ), + }; + } + }; +} + +export function createGetLiveEntry({ + liveCollections, +}: { + liveCollections: LiveCollectionConfigMap; +}) { + return async function getLiveEntry( + collection: string, + lookup: string | Record, + ): Promise { + if (!(collection in liveCollections)) { + return { + error: new LiveCollectionError( + collection, + `Collection "${collection}" is not a live collection. Use getCollection() instead of getLiveEntry() to load regular content collections.`, + ), + }; + } + + try { + const lookupObject = { + filter: typeof lookup === 'string' ? { id: lookup } : lookup, + }; + + let entry = await ( + liveCollections[collection].loader as LiveLoader< + Record, + Record + > + )?.loadEntry?.(lookupObject); + + // Check if loader returned an error + if (entry && 'error' in entry) { + return { error: entry.error }; + } + + if (!entry) { + return { + error: new LiveEntryNotFoundError(collection, lookup), + }; + } + + const { schema } = liveCollections[collection]; + if (schema) { + const result = await parseLiveEntry(entry, schema, collection); + if (result.error) { + return { error: result.error }; + } + entry = result.entry!; + } + + return { + entry: entry, + cacheHint: entry.cacheHint, + }; + } catch (error) { + return { + error: new LiveCollectionError( + collection, + `Unexpected error loading entry ${collection} → ${typeof lookup === 'string' ? lookup : JSON.stringify(lookup)}`, + error as Error, + ), + }; + } + }; +} + type RenderResult = { Content: AstroComponentFactory; headings: MarkdownHeading[]; diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index c69b99d0184a..840805394a38 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1690,31 +1690,6 @@ export const ContentEntryDataError = { hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.', } satisfies ErrorData; -/** - * @docs - * @message - * **Example error message:**
- * **blog** → **post** returned an invalid cache hint.
- * **maxAge**: Expected number, received string - * @description - * The loader for a live content collection returned an invalid cache hint. - * Make sure that `cacheHint` is an object with the correct properties, or is undefined. - * @see - * - [Experimental live content collections](https://astro.build/en/reference/experimental-flags/live-content-collections/) - */ -export const InvalidCacheHintError = { - name: 'InvalidCacheHintError', - title: 'Invalid cache hint.', - message(collection: string, entryId: string | undefined, error: ZodError) { - return [ - `**${String(collection)}${entryId ? ` → ${String(entryId)}` : ''}** returned an invalid cache hint.\n`, - ...error.errors.map((zodError) => ` **${zodError.path.join('.')}**: ${zodError.message}`), - '', - ].join('\n'); - }, - hint: 'See https://docs.astro.build/en/reference/experimental-flags/live-content-collections/ for more information.', -} satisfies ErrorData; - /** * @docs * @message diff --git a/packages/astro/src/types/public/content.ts b/packages/astro/src/types/public/content.ts index b1fac5712c38..f81e6b37775a 100644 --- a/packages/astro/src/types/public/content.ts +++ b/packages/astro/src/types/public/content.ts @@ -3,6 +3,7 @@ import type * as rollup from 'rollup'; import type { DataEntry, RenderedContent } from '../../content/data-store.js'; import type { AstroComponentFactory } from '../../runtime/server/index.js'; import type { AstroConfig } from './config.js'; +import type { LiveCollectionError } from '../../content/loaders/errors.js'; export interface AstroInstance { file: string; @@ -148,3 +149,21 @@ export interface LiveDataCollection< /** A hint for how to cache this collection. Individual entries can also have cache hints */ cacheHint?: CacheHint; } + +export interface LiveDataCollectionResult< + TData extends Record = Record, + TError extends Error = Error, +> { + entries?: Array>; + error?: TError | LiveCollectionError + cacheHint?: CacheHint; +} + +export interface LiveDataEntryResult< + TData extends Record = Record, + TError extends Error = Error, +> { + entry?: LiveDataEntry; + error?: TError | LiveCollectionError; + cacheHint?: CacheHint; +} diff --git a/packages/astro/templates/content/module.mjs b/packages/astro/templates/content/module.mjs index 3d1c8123f0cb..1baac47c7159 100644 --- a/packages/astro/templates/content/module.mjs +++ b/packages/astro/templates/content/module.mjs @@ -6,6 +6,8 @@ import { createGetEntries, createGetEntry, createGetEntryBySlug, + createGetLiveCollection, + createGetLiveEntry, createReference, } from 'astro/content/runtime'; @@ -84,3 +86,11 @@ export const getDataEntryById = createGetDataEntryById({ export const getEntries = createGetEntries(getEntry); export const reference = createReference({ lookupMap }); + +export const getLiveCollection = createGetLiveCollection({ + liveCollections, +}); + +export const getLiveEntry = createGetLiveEntry({ + liveCollections, +}); diff --git a/packages/astro/templates/content/types.d.ts b/packages/astro/templates/content/types.d.ts index fc7a3743e9c0..d441097d8b93 100644 --- a/packages/astro/templates/content/types.d.ts +++ b/packages/astro/templates/content/types.d.ts @@ -77,10 +77,10 @@ declare module 'astro:content' { filter?: (entry: CollectionEntry) => unknown, ): Promise[]>; - export function getCollection( + export function getLiveCollection( collection: C, filter?: LiveLoaderCollectionFilterType, - ): Promise>>; + ): Promise, LiveLoaderErrorType>>; export function getEntry< C extends keyof ContentEntryMap, @@ -118,10 +118,10 @@ declare module 'astro:content' { ? Promise | undefined : Promise : Promise | undefined>; - export function getEntry( + export function getLiveEntry( collection: C, filter: string | LiveLoaderEntryFilterType, - ): Promise>>; + ): Promise, LiveLoaderErrorType>>; /** Resolve an array of entry references from the same collection */ export function getEntries( @@ -130,9 +130,6 @@ declare module 'astro:content' { export function getEntries( entries: ReferenceDataEntry[], ): Promise[]>; - export function getEntries( - entries: ReferenceLiveEntry[], - ): Promise>[]>; export function render( entry: AnyEntryMap[C][string], @@ -171,13 +168,15 @@ declare module 'astro:content' { type ExtractLoaderTypes = T extends import('astro/loaders').LiveLoader< infer TData, infer TEntryFilter, - infer TCollectionFilter + infer TCollectionFilter, + infer TError > - ? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter } - : { data: never; entryFilter: never; collectionFilter: never }; + ? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError } + : { data: never; entryFilter: never; collectionFilter: never; error: never }; type ExtractDataType = ExtractLoaderTypes['data']; type ExtractEntryFilterType = ExtractLoaderTypes['entryFilter']; type ExtractCollectionFilterType = ExtractLoaderTypes['collectionFilter']; + type ExtractErrorType = ExtractLoaderTypes['error']; type LiveLoaderDataType = LiveContentConfig['collections'][C]['schema'] extends undefined @@ -189,6 +188,8 @@ declare module 'astro:content' { ExtractEntryFilterType; type LiveLoaderCollectionFilterType = ExtractCollectionFilterType; + type LiveLoaderErrorType = + ExtractErrorType; export type ContentConfig = '@@CONTENT_CONFIG_TYPE@@'; export type LiveContentConfig = '@@LIVE_CONTENT_CONFIG_TYPE@@'; diff --git a/packages/astro/test/fixtures/live-loaders/src/live.config.ts b/packages/astro/test/fixtures/live-loaders/src/live.config.ts index 2a6d2910535c..9fde1c154200 100644 --- a/packages/astro/test/fixtures/live-loaders/src/live.config.ts +++ b/packages/astro/test/fixtures/live-loaders/src/live.config.ts @@ -9,6 +9,7 @@ type Entry = { interface CollectionFilter { addToAge?: number; + returnInvalid?: boolean; } type EntryFilter = { @@ -16,44 +17,52 @@ type EntryFilter = { addToAge?: number; }; - const entries = { '123': { id: '123', data: { title: 'Page 123', age: 10 } }, '456': { id: '456', data: { title: 'Page 456', age: 20 } }, '789': { id: '789', data: { title: 'Page 789', age: 30 } }, }; -const loader: LiveLoader = { +class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } +} + +const loader: LiveLoader = { name: 'test-loader', - loadEntry: async (context) => { - const entry = entries[context.filter.id]; + loadEntry: async ({ filter }) => { + const entry = entries[filter.id]; if (!entry) { - return; + return { + error: new CustomError(`Entry ${filter.id} not found`), + }; } return { ...entry, data: { - ...entry.data, - age: context.filter?.addToAge + title: entry.data.title, + age: filter?.addToAge ? entry.data.age - ? entry.data.age + context.filter.addToAge - : context.filter.addToAge + ? entry.data.age + filter.addToAge + : filter.addToAge : entry.data.age, }, cacheHint: { - tags: [`page:${context.filter.id}`], + tags: [`page:${filter.id}`], maxAge: 60, }, }; }, - loadCollection: async (context) => { + loadCollection: async ({filter}) => { return { - entries: context.filter?.addToAge + entries: filter?.addToAge ? Object.values(entries).map((entry) => ({ ...entry, data: { - ...entry.data, - age: entry.data.age ? entry.data.age + context.filter!.addToAge! : undefined, + title: filter.returnInvalid ? 99 as any : entry.data.title, + age: entry.data.age ? entry.data.age + filter!.addToAge! : undefined, }, })) : Object.values(entries), diff --git a/packages/astro/test/fixtures/live-loaders/src/pages/api.ts b/packages/astro/test/fixtures/live-loaders/src/pages/api.ts index 9a305820f7b4..86690ed8b8a9 100644 --- a/packages/astro/test/fixtures/live-loaders/src/pages/api.ts +++ b/packages/astro/test/fixtures/live-loaders/src/pages/api.ts @@ -1,16 +1,23 @@ import type { APIRoute } from 'astro'; -import { getCollection, getEntry } from 'astro:content'; +import { getLiveCollection, getLiveEntry } from 'astro:content'; export const prerender = false; -export const GET: APIRoute = async ({ request }) => { - const addToAge = new URL(request.url).searchParams.get('addToAge'); - const filter = addToAge ? { addToAge: parseInt(addToAge) } : undefined; - const collection = await getCollection('liveStuff', filter); - const entryByString = await getEntry('liveStuff', '123'); - const entryByObject = await getEntry( - 'liveStuff', - { id: '456', ...filter }, - ); - return Response.json({ collection, entryByObject, entryByString }); +export const GET: APIRoute = async ({ url }) => { + const addToAge = url.searchParams.get('addToAge'); + const returnInvalid = url.searchParams.has('returnInvalid'); + const filter = addToAge ? { addToAge: parseInt(addToAge), returnInvalid } : undefined; + const { error, entries, cacheHint } = await getLiveCollection('liveStuff', filter); + const entryByString = await getLiveEntry('liveStuff', '123'); + const entryByObject = await getLiveEntry('liveStuff', { id: '456', ...filter }); + + return Response.json({ + collection: { + cacheHint, + entries, + error: error ? { ...error, message: error.message } : undefined, + }, + entryByObject, + entryByString, + }); }; diff --git a/packages/astro/test/fixtures/live-loaders/src/pages/index.astro b/packages/astro/test/fixtures/live-loaders/src/pages/index.astro index 2c36a57b6814..0347a3334dfd 100644 --- a/packages/astro/test/fixtures/live-loaders/src/pages/index.astro +++ b/packages/astro/test/fixtures/live-loaders/src/pages/index.astro @@ -1,7 +1,11 @@ --- -import { getCollection } from "astro:content"; +import { getLiveCollection } from "astro:content"; -const collection = await getCollection("liveStuff") +const collection = await getLiveCollection("liveStuff") + +if(collection.error) { + throw collection.error; +} export const prerender = false; @@ -18,7 +22,7 @@ export const prerender = false;

Astro

    - {collection?.entries.map((item) => ( + {collection.entries?.map((item) => (
  • {item.data.title}
  • diff --git a/packages/astro/test/fixtures/live-loaders/src/pages/more.astro b/packages/astro/test/fixtures/live-loaders/src/pages/more.astro index e57b915157d5..52088b5e118c 100644 --- a/packages/astro/test/fixtures/live-loaders/src/pages/more.astro +++ b/packages/astro/test/fixtures/live-loaders/src/pages/more.astro @@ -1,7 +1,7 @@ --- -import { getEntry } from "astro:content"; +import { getLiveEntry } from "astro:content"; -const data = await getEntry("liveStuff", "123") +const { entry } = await getLiveEntry("liveStuff", "123") export const prerender = false; --- @@ -15,7 +15,6 @@ export const prerender = false; Astro -

    {data.data.title}

    - +

    {entry?.data.title}

    diff --git a/packages/astro/test/fixtures/live-loaders/src/pages/test-old-api.ts b/packages/astro/test/fixtures/live-loaders/src/pages/test-old-api.ts new file mode 100644 index 000000000000..419f53f866c5 --- /dev/null +++ b/packages/astro/test/fixtures/live-loaders/src/pages/test-old-api.ts @@ -0,0 +1,21 @@ +import type { APIRoute } from 'astro'; +import { getCollection } from 'astro:content'; + +export const prerender = false; + +export const GET: APIRoute = async () => { + try { + // @ts-ignore This should throw an error because liveStuff is a live collection + const collection = await getCollection('liveStuff'); + return Response.json({ collection }); + } catch (error: any) { + return Response.json( + { + error: error.message + }, + { + status: 500, + }, + ); + } +}; diff --git a/packages/astro/test/live-loaders.test.js b/packages/astro/test/live-loaders.test.js index df6e29eb3172..5e8480459d37 100644 --- a/packages/astro/test/live-loaders.test.js +++ b/packages/astro/test/live-loaders.test.js @@ -41,22 +41,32 @@ describe('Live content collections', () => { assert.equal(res.status, 200); const data = await res.json(); assert.deepEqual(data.entryByString, { - id: '123', - data: { title: 'Page 123', age: 10 }, + entry: { + id: '123', + data: { title: 'Page 123', age: 10 }, + cacheHint: { + tags: [`page:123`], + maxAge: 60, + }, + }, cacheHint: { tags: [`page:123`], maxAge: 60, }, - collection: 'liveStuff', }); assert.deepEqual(data.entryByObject, { - id: '456', - data: { title: 'Page 456', age: 20 }, + entry: { + id: '456', + data: { title: 'Page 456', age: 20 }, + cacheHint: { + tags: [`page:456`], + maxAge: 60, + }, + }, cacheHint: { tags: [`page:456`], maxAge: 60, }, - collection: 'liveStuff', }); assert.deepEqual(data.collection, { entries: [ @@ -73,7 +83,6 @@ describe('Live content collections', () => { data: { title: 'Page 789', age: 30 }, }, ], - collection: 'liveStuff', cacheHint: { tags: ['page'], maxAge: 60, @@ -88,13 +97,18 @@ describe('Live content collections', () => { assert.deepEqual( data.entryByObject, { - id: '456', - data: { title: 'Page 456', age: 25 }, + entry: { + id: '456', + data: { title: 'Page 456', age: 25 }, + cacheHint: { + tags: [`page:456`], + maxAge: 60, + }, + }, cacheHint: { tags: [`page:456`], maxAge: 60, }, - collection: 'liveStuff', }, 'passes dynamic filter to getEntry', ); @@ -117,6 +131,19 @@ describe('Live content collections', () => { 'passes dynamic filter to getCollection', ); }); + + it('returns an error for invalid data', async () => { + const res = await fixture.fetch('/api/?returnInvalid=true&addToAge=1'); + const data = await res.json(); + assert.ok(data.collection.error.message.includes('data does not match the collection schema')); + assert.equal(data.collection.error.name, 'LiveCollectionValidationError'); + }); + + it('old API throws helpful errors for live collections', async () => { + const response = await fixture.fetch('/test-old-api'); + const data = await response.json(); + assert.ok(data.error.includes('Use getLiveCollection() instead of getCollection()')); + }); }); describe('SSR', () => { @@ -134,13 +161,18 @@ describe('Live content collections', () => { assert.equal(response.status, 200); const data = await response.json(); assert.deepEqual(data.entryByString, { - id: '123', - data: { title: 'Page 123', age: 10 }, + entry: { + id: '123', + data: { title: 'Page 123', age: 10 }, + cacheHint: { + tags: [`page:123`], + maxAge: 60, + }, + }, cacheHint: { tags: [`page:123`], maxAge: 60, }, - collection: 'liveStuff', }); }); it('loads live data with dynamic filtering', async () => { @@ -152,16 +184,29 @@ describe('Live content collections', () => { assert.deepEqual( data.entryByObject, { - id: '456', - data: { title: 'Page 456', age: 25 }, + entry: { + id: '456', + data: { title: 'Page 456', age: 25 }, + cacheHint: { + tags: [`page:456`], + maxAge: 60, + }, + }, cacheHint: { tags: [`page:456`], maxAge: 60, }, - collection: 'liveStuff', }, 'passes dynamic filter to getEntry', ); }); + + it('old API throws helpful errors for live collections', async () => { + const request = new Request('http://example.com/test-old-api'); + const response = await app.render(request); + assert.equal(response.status, 500); + const data = await response.json(); + assert.ok(data.error.includes('Use getLiveCollection() instead of getCollection()')); + }); }); }); diff --git a/packages/astro/types/content.d.ts b/packages/astro/types/content.d.ts index f18d495679c9..78b3ca61684e 100644 --- a/packages/astro/types/content.d.ts +++ b/packages/astro/types/content.d.ts @@ -38,4 +38,8 @@ declare module 'astro:content' { export type ContentConfig = any; /** Run `astro dev` or `astro sync` to generate high fidelity types */ export const render: (entry: any) => any; + /** Run `astro dev` or `astro sync` to generate high fidelity types */ + export const getLiveCollection: (...args: any[]) => any; + /** Run `astro dev` or `astro sync` to generate high fidelity types */ + export const getLiveEntry: (...args: any[]) => any; }