diff --git a/packages/astro/src/config/content.ts b/packages/astro/src/config/content.ts new file mode 100644 index 000000000000..ffd3d8b094d7 --- /dev/null +++ b/packages/astro/src/config/content.ts @@ -0,0 +1,168 @@ +import type { Loader } from 'esbuild'; +import type { ZodLiteral, ZodNumber, ZodObject, ZodString, ZodType, ZodUnion } from 'zod'; +import { CONTENT_LAYER_TYPE, LIVE_CONTENT_TYPE } from '../content/consts.js'; +import type { LiveLoader } from '../content/loaders/types.js'; +import { AstroError, AstroErrorData, AstroUserError } from '../core/errors/index.js'; + +function getImporterFilename() { + // Find the first line in the stack trace that doesn't include 'defineCollection' or 'getImporterFilename' + const stackLine = new Error().stack + ?.split('\n') + .find( + (line) => + !line.includes('defineCollection') && + !line.includes('getImporterFilename') && + line !== 'Error', + ); + if (!stackLine) { + return undefined; + } + // Extract the relative path from the stack line + const match = /\/((?:src|chunks)\/.*?):\d+:\d+/.exec(stackLine); + + return match?.[1] ?? undefined; +} + +// This needs to be in sync with ImageMetadata +export type ImageFunction = () => ZodObject<{ + src: ZodString; + width: ZodNumber; + height: ZodNumber; + format: ZodUnion< + [ + ZodLiteral<'png'>, + ZodLiteral<'jpg'>, + ZodLiteral<'jpeg'>, + ZodLiteral<'tiff'>, + ZodLiteral<'webp'>, + ZodLiteral<'gif'>, + ZodLiteral<'svg'>, + ZodLiteral<'avif'>, + ] + >; +}>; + +export interface DataEntry { + id: string; + data: Record; + filePath?: string; + body?: string; +} + +export interface DataStore { + get: (key: string) => DataEntry; + entries: () => Array<[id: string, DataEntry]>; + set: (key: string, data: Record, body?: string, filePath?: string) => void; + values: () => Array; + keys: () => Array; + delete: (key: string) => void; + clear: () => void; + has: (key: string) => boolean; +} + +export interface MetaStore { + get: (key: string) => string | undefined; + set: (key: string, value: string) => void; + delete: (key: string) => void; + has: (key: string) => boolean; +} + +export type BaseSchema = ZodType; + +export type SchemaContext = { image: ImageFunction }; + +type ContentLayerConfig = { + type?: 'content_layer'; + schema?: S | ((context: SchemaContext) => S); + loader: + | Loader + | (() => + | Array + | Promise> + | Record & { id?: string }> + | Promise & { id?: string }>>); +}; + +type DataCollectionConfig = { + type: 'data'; + schema?: S | ((context: SchemaContext) => S); +}; + +type ContentCollectionConfig = { + type?: 'content'; + schema?: S | ((context: SchemaContext) => S); + loader?: never; +}; + +type LiveDataCollectionConfig = { + type: 'live'; + schema?: S; + loader: L; +}; + +export type CollectionConfig< + S extends BaseSchema, + TLiveLoader = never, +> = TLiveLoader extends LiveLoader + ? LiveDataCollectionConfig + : ContentCollectionConfig | DataCollectionConfig | ContentLayerConfig; + +export function defineCollection( + config: CollectionConfig, +): CollectionConfig { + const importerFilename = getImporterFilename(); + const isInLiveConfig = importerFilename?.includes('live.config'); + + if (config.type === LIVE_CONTENT_TYPE) { + if (!isInLiveConfig) { + throw new AstroError({ + ...AstroErrorData.LiveContentConfigError, + message: AstroErrorData.LiveContentConfigError.message( + 'Collections with type `live` must be defined in a `src/live.config.ts` file.', + importerFilename ?? 'your content config file', + ), + }); + } + if (!config.loader) { + throw new AstroError({ + ...AstroErrorData.LiveContentConfigError, + message: AstroErrorData.LiveContentConfigError.message( + 'Collections with type `live` must have a `loader` defined.', + importerFilename, + ), + }); + } + if (config.schema) { + if (typeof config.schema === 'function') { + throw new AstroError({ + ...AstroErrorData.LiveContentConfigError, + message: AstroErrorData.LiveContentConfigError.message( + 'The schema cannot be a function for live collections. Please use a schema object instead.', + importerFilename, + ), + }); + } + } + return config; + } + if (isInLiveConfig) { + throw new AstroError({ + ...AstroErrorData.LiveContentConfigError, + message: AstroErrorData.LiveContentConfigError.message( + 'Collections in a `live.config.ts` file must have a type of `live`.', + getImporterFilename(), + ), + }); + } + + if ('loader' in config) { + if (config.type && config.type !== CONTENT_LAYER_TYPE) { + throw new AstroUserError( + `Collections that use the Content Layer API must have a \`loader\` defined and no \`type\` set. Check your collection definitions in ${getImporterFilename() ?? 'your content config file'}.`, + ); + } + config.type = CONTENT_LAYER_TYPE; + } + if (!config.type) config.type = 'content'; + return config; +} diff --git a/packages/astro/src/config/entrypoint.ts b/packages/astro/src/config/entrypoint.ts index dc2f47242d8b..6cd8acf39deb 100644 --- a/packages/astro/src/config/entrypoint.ts +++ b/packages/astro/src/config/entrypoint.ts @@ -2,7 +2,16 @@ import type { SharpImageServiceConfig } from '../assets/services/sharp.js'; import type { ImageServiceConfig } from '../types/public/index.js'; - +export { + defineCollection, + type ImageFunction, + type DataEntry, + type DataStore, + type MetaStore, + type BaseSchema, + type SchemaContext, + type CollectionConfig, +} from './content.js'; export { defineConfig, getViteConfig } from './index.js'; export { envField } from '../env/config.js'; export { mergeConfig } from '../core/config/merge.js'; diff --git a/packages/astro/src/content/loaders/types.ts b/packages/astro/src/content/loaders/types.ts index 4385fb33f4cb..f04e50417d57 100644 --- a/packages/astro/src/content/loaders/types.ts +++ b/packages/astro/src/content/loaders/types.ts @@ -63,9 +63,9 @@ export interface LoadCollectionContext { } export interface LiveLoader< - TData extends Record = Record, - TEntryFilter extends Record | never = never, - TCollectionFilter extends Record | never = never, + TData extends Record = Record, + TEntryFilter extends Record | never = never, + TCollectionFilter extends Record | never = never, > { /** Unique name of the loader, e.g. the npm package name */ name: string; diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index 4589d0b2743f..36523562cb91 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -5,7 +5,8 @@ import pLimit from 'p-limit'; import { ZodIssueCode, z } from 'zod'; import type { GetImageResult, ImageMetadata } from '../assets/types.js'; import { imageSrcToImportId } from '../assets/utils/resolveImports.js'; -import { AstroError, AstroErrorData, AstroUserError } from '../core/errors/index.js'; +import { defineCollection as defineCollectionOrig } from '../config/content.js'; +import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { prependForwardSlash } from '../core/path.js'; import { @@ -20,7 +21,7 @@ import { unescapeHTML, } from '../runtime/server/index.js'; import type { LiveDataEntry } from '../types/public/content.js'; -import { CONTENT_LAYER_TYPE, IMAGE_IMPORT_PREFIX, LIVE_CONTENT_TYPE } from './consts.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'; @@ -34,74 +35,6 @@ type LiveCollectionConfigMap = Record< { loader: LiveLoader; type: typeof LIVE_CONTENT_TYPE; schema?: z.ZodType } >; -export function getImporterFilename() { - // The 4th line in the stack trace should be the importer filename - const stackLine = new Error().stack?.split('\n')?.[3]; - if (!stackLine) { - return undefined; - } - // Extract the relative path from the stack line - const match = /\/(src\/.*?):\d+:\d+/.exec(stackLine); - return match?.[1] ?? undefined; -} - -export function defineCollection(config: any) { - const isInLiveConfig = getImporterFilename()?.endsWith('/live.config.ts'); - - if (config.type === LIVE_CONTENT_TYPE) { - if (!isInLiveConfig) { - throw new AstroError({ - ...AstroErrorData.LiveContentConfigError, - message: AstroErrorData.LiveContentConfigError.message( - 'Collections with type `live` must be defined in a `src/live.config.ts` file.', - getImporterFilename() ?? 'your content config file', - ), - }); - } - if (!config.loader) { - throw new AstroError({ - ...AstroErrorData.LiveContentConfigError, - message: AstroErrorData.LiveContentConfigError.message( - 'Collections with type `live` must have a `loader` defined.', - getImporterFilename(), - ), - }); - } - if (config.schema) { - if (typeof config.schema === 'function') { - throw new AstroError({ - ...AstroErrorData.LiveContentConfigError, - message: AstroErrorData.LiveContentConfigError.message( - 'The schema cannot be a function for live collections. Please use a schema object instead.', - getImporterFilename(), - ), - }); - } - } - return config; - } - if (isInLiveConfig) { - throw new AstroError({ - ...AstroErrorData.LiveContentConfigError, - message: AstroErrorData.LiveContentConfigError.message( - 'Collections in a `live.config.ts` file must have a type of `live`.', - getImporterFilename(), - ), - }); - } - - if ('loader' in config) { - if (config.type && config.type !== CONTENT_LAYER_TYPE) { - throw new AstroUserError( - `Collections that use the Content Layer API must have a \`loader\` defined and no \`type\` set. Check your collection definitions in ${getImporterFilename() ?? 'your content config file'}.`, - ); - } - config.type = CONTENT_LAYER_TYPE; - } - if (!config.type) config.type = 'content'; - return config; -} - export function createCollectionToGlobResultMap({ globResult, contentDir, @@ -883,3 +816,15 @@ type PropagatedAssetsModule = { function isPropagatedAssetsModule(module: any): module is PropagatedAssetsModule { return typeof module === 'object' && module != null && '__astroPropagation' in module; } + +export function defineCollection(config: any) { + if (config.type === 'live') { + throw new AstroError({ + ...AstroErrorData.LiveContentConfigError, + message: AstroErrorData.LiveContentConfigError.message( + 'You must import defineCollection from "astro/config" to use live collections.', + ), + }); + } + return defineCollectionOrig(config); +} 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 70f132ac39e0..2a6d2910535c 100644 --- a/packages/astro/test/fixtures/live-loaders/src/live.config.ts +++ b/packages/astro/test/fixtures/live-loaders/src/live.config.ts @@ -1,24 +1,45 @@ -import { defineCollection, z } from 'astro:content'; +import { defineCollection } from 'astro/config'; +import { z } from 'astro/zod'; import type { LiveLoader } from 'astro/loaders'; type Entry = { title: string; + age?: number; }; +interface CollectionFilter { + addToAge?: number; +} + +type EntryFilter = { + id: keyof typeof entries; + addToAge?: number; +}; + + const entries = { - '123': { id: '123', data: { title: 'Page 123' } }, - '456': { id: '456', data: { title: 'Page 456' } }, - '789': { id: '789', data: { title: 'Page 789' } }, + '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 = { +const loader: LiveLoader = { name: 'test-loader', loadEntry: async (context) => { - if(!entries[context.filter.id]) { + const entry = entries[context.filter.id]; + if (!entry) { return; } return { - ...entries[context.filter.id], + ...entry, + data: { + ...entry.data, + age: context.filter?.addToAge + ? entry.data.age + ? entry.data.age + context.filter.addToAge + : context.filter.addToAge + : entry.data.age, + }, cacheHint: { tags: [`page:${context.filter.id}`], maxAge: 60, @@ -27,7 +48,15 @@ const loader: LiveLoader = { }, loadCollection: async (context) => { return { - entries: Object.values(entries), + entries: context.filter?.addToAge + ? Object.values(entries).map((entry) => ({ + ...entry, + data: { + ...entry.data, + age: entry.data.age ? entry.data.age + context.filter!.addToAge! : undefined, + }, + })) + : Object.values(entries), cacheHint: { tags: ['page'], maxAge: 60, diff --git a/packages/astro/test/fixtures/live-loaders/src/pages/api.ts b/packages/astro/test/fixtures/live-loaders/src/pages/api.ts new file mode 100644 index 000000000000..9a305820f7b4 --- /dev/null +++ b/packages/astro/test/fixtures/live-loaders/src/pages/api.ts @@ -0,0 +1,16 @@ +import type { APIRoute } from 'astro'; +import { getCollection, getEntry } 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 }); +}; 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 83a72f143c7c..2c36a57b6814 100644 --- a/packages/astro/test/fixtures/live-loaders/src/pages/index.astro +++ b/packages/astro/test/fixtures/live-loaders/src/pages/index.astro @@ -1,8 +1,8 @@ --- import { getCollection } from "astro:content"; - const collection = await getCollection("liveStuff") + export const prerender = false; --- @@ -18,7 +18,7 @@ export const prerender = false;

Astro

    - {collection.entries.map((item) => ( + {collection?.entries.map((item) => (
  • {item.data.title}
  • diff --git a/packages/astro/test/live-loaders.test.js b/packages/astro/test/live-loaders.test.js new file mode 100644 index 000000000000..1f59d19043f0 --- /dev/null +++ b/packages/astro/test/live-loaders.test.js @@ -0,0 +1,167 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { Writable } from 'node:stream'; +import { after, before, describe, it } from 'node:test'; +import { Logger } from '../dist/core/logger/core.js'; + +import testAdapter from './test-adapter.js'; +import { loadFixture } from './test-utils.js'; +describe('Live content loaders', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/live-loaders/', + adapter: testAdapter(), + }); + }); + describe('Dev', () => { + let devServer; + const logs = []; + before(async () => { + devServer = await fixture.startDevServer({ + logger: new Logger({ + level: 'info', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }), + }); + }); + + after(async () => { + devServer?.stop(); + }); + + it('can load live data', async () => { + const res = await fixture.fetch('/api/'); + assert.equal(res.status, 200); + const data = await res.json(); + assert.deepEqual(data.entryByString, { + id: '123', + data: { title: 'Page 123', age: 10 }, + cacheHint: { + tags: [`page:123`], + maxAge: 60, + }, + collection: 'liveStuff', + }); + assert.deepEqual(data.entryByObject, { + id: '456', + data: { title: 'Page 456', age: 20 }, + cacheHint: { + tags: [`page:456`], + maxAge: 60, + }, + collection: 'liveStuff', + }); + assert.deepEqual(data.collection, { + entries: [ + { + id: '123', + data: { title: 'Page 123', age: 10 }, + }, + { + id: '456', + data: { title: 'Page 456', age: 20 }, + }, + { + id: '789', + data: { title: 'Page 789', age: 30 }, + }, + ], + collection: 'liveStuff', + cacheHint: { + tags: ['page'], + maxAge: 60, + }, + }); + }); + + it('can load live data with dynamic filtering', async () => { + const res = await fixture.fetch('/api/?addToAge=5'); + assert.equal(res.status, 200); + const data = await res.json(); + assert.deepEqual( + data.entryByObject, + { + id: '456', + data: { title: 'Page 456', age: 25 }, + cacheHint: { + tags: [`page:456`], + maxAge: 60, + }, + collection: 'liveStuff', + }, + 'passes dynamic filter to getEntry', + ); + assert.deepEqual( + data.collection.entries, + [ + { + id: '123', + data: { title: 'Page 123', age: 15 }, + }, + { + id: '456', + data: { title: 'Page 456', age: 25 }, + }, + { + id: '789', + data: { title: 'Page 789', age: 35 }, + }, + ], + 'passes dynamic filter to getCollection', + ); + }); + }); + + describe('SSR', () => { + let app; + + before(async () => { + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('loads live data', async () => { + const req = new Request('http://example.com/api/'); + const response = await app.render(req); + assert.ok(response.ok); + assert.equal(response.status, 200); + const data = await response.json(); + assert.deepEqual(data.entryByString, { + id: '123', + data: { title: 'Page 123', age: 10 }, + cacheHint: { + tags: [`page:123`], + maxAge: 60, + }, + collection: 'liveStuff', + }); + }); + it('loads live data with dynamic filtering', async () => { + const request = new Request('http://example.com/api/?addToAge=5'); + const response = await app.render(request); + assert.ok(response.ok); + assert.equal(response.status, 200); + const data = await response.json(); + assert.deepEqual( + data.entryByObject, + { + id: '456', + data: { title: 'Page 456', age: 25 }, + cacheHint: { + tags: [`page:456`], + maxAge: 60, + }, + collection: 'liveStuff', + }, + 'passes dynamic filter to getEntry', + ); + }); + }); +}); diff --git a/packages/astro/types/content.d.ts b/packages/astro/types/content.d.ts index 2e49768d531b..322d8e494330 100644 --- a/packages/astro/types/content.d.ts +++ b/packages/astro/types/content.d.ts @@ -1,96 +1,17 @@ declare module 'astro:content' { export { z } from 'astro/zod'; - - // This needs to be in sync with ImageMetadata - export type ImageFunction = () => import('astro/zod').ZodObject<{ - src: import('astro/zod').ZodString; - width: import('astro/zod').ZodNumber; - height: import('astro/zod').ZodNumber; - format: import('astro/zod').ZodUnion< - [ - import('astro/zod').ZodLiteral<'png'>, - import('astro/zod').ZodLiteral<'jpg'>, - import('astro/zod').ZodLiteral<'jpeg'>, - import('astro/zod').ZodLiteral<'tiff'>, - import('astro/zod').ZodLiteral<'webp'>, - import('astro/zod').ZodLiteral<'gif'>, - import('astro/zod').ZodLiteral<'svg'>, - import('astro/zod').ZodLiteral<'avif'>, - ] - >; - }>; - - export interface DataEntry { - id: string; - data: Record; - filePath?: string; - body?: string; - } - - export interface DataStore { - get: (key: string) => DataEntry; - entries: () => Array<[id: string, DataEntry]>; - set: (key: string, data: Record, body?: string, filePath?: string) => void; - values: () => Array; - keys: () => Array; - delete: (key: string) => void; - clear: () => void; - has: (key: string) => boolean; - } - - export interface MetaStore { - get: (key: string) => string | undefined; - set: (key: string, value: string) => void; - delete: (key: string) => void; - has: (key: string) => boolean; - } - - export type BaseSchema = import('astro/zod').ZodType; - - export type SchemaContext = { image: ImageFunction }; - - type ContentLayerConfig = { - type?: 'content_layer'; - schema?: S | ((context: SchemaContext) => S); - loader: - | import('astro/loaders').Loader - | (() => - | Array - | Promise> - | Record & { id?: string }> - | Promise & { id?: string }>>); - }; - - type DataCollectionConfig = { - type: 'data'; - schema?: S | ((context: SchemaContext) => S); - }; - - type ContentCollectionConfig = { - type?: 'content'; - schema?: S | ((context: SchemaContext) => S); - loader?: never; - }; - - type LiveDataCollectionConfig< - S extends BaseSchema, - L extends import('astro/loaders').LiveLoader, - > = { - type: 'live'; - schema?: S; - loader: L; - }; - - export type CollectionConfig< - S extends BaseSchema, - TLiveLoader = never, - > = TLiveLoader extends import('astro/loaders').LiveLoader - ? LiveDataCollectionConfig - : ContentCollectionConfig | DataCollectionConfig | ContentLayerConfig; - - export function defineCollection( - input: CollectionConfig, - ): CollectionConfig; + export type { + ImageFunction, + DataEntry, + DataStore, + MetaStore, + BaseSchema, + SchemaContext, + } from 'astro/config'; + + export function defineCollection( + input: import('astro/config').CollectionConfig, + ): import('astro/config').CollectionConfig; /** Run `astro dev` or `astro sync` to generate high fidelity types */ export const getEntryBySlug: (...args: any[]) => any;