Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
082fc52
feat: loader.getSchemaContext()
florian-lefebvre Nov 12, 2025
fc7a785
wip
florian-lefebvre Nov 12, 2025
865e64b
wip
florian-lefebvre Nov 12, 2025
144c791
wip
florian-lefebvre Nov 12, 2025
0ecc8d5
fix
florian-lefebvre Nov 13, 2025
85fb14a
chore: format
florian-lefebvre Nov 13, 2025
91a4d99
feat: error
florian-lefebvre Nov 13, 2025
a0e3f43
Discard changes to examples/blog/src/content.config.ts
florian-lefebvre Nov 13, 2025
652b78e
feat: backward compat
florian-lefebvre Nov 13, 2025
9a2f0ab
Merge branch 'next' into feat/content-loader-get-schema-context
florian-lefebvre Nov 13, 2025
6e26dcd
chore: remove unused dep
florian-lefebvre Nov 13, 2025
f998122
Merge branch 'feat/content-loader-get-schema-context' of https://gith…
florian-lefebvre Nov 13, 2025
67cb44f
fix: tests
florian-lefebvre Nov 13, 2025
b198c4c
test
florian-lefebvre Nov 13, 2025
6f3daaf
chore: todo
florian-lefebvre Nov 13, 2025
77b47e0
chore: changesets
florian-lefebvre Nov 13, 2025
b375c59
Discard changes to packages/astro/src/content/loaders/glob.ts
florian-lefebvre Nov 19, 2025
c7fd7a6
Discard changes to packages/astro/src/content/loaders/file.ts
florian-lefebvre Nov 19, 2025
09c6543
Apply suggestion from @ascorbic
florian-lefebvre Nov 19, 2025
9b53484
Merge branch 'next' into feat/content-loader-get-schema-context
florian-lefebvre Nov 19, 2025
a5a64cc
feedback
florian-lefebvre Nov 19, 2025
5bb7411
Apply suggestion from @florian-lefebvre
florian-lefebvre Nov 21, 2025
be1be6d
Merge branch 'next' into feat/content-loader-get-schema-context
florian-lefebvre Nov 21, 2025
8810bd3
fix
florian-lefebvre Nov 21, 2025
bde8ece
Merge branch 'next' into feat/content-loader-get-schema-context
florian-lefebvre Nov 27, 2025
c409b14
fix: test
florian-lefebvre Dec 3, 2025
d2c3fd8
feedback
florian-lefebvre Dec 3, 2025
d9b14c4
Apply suggestion from @florian-lefebvre
florian-lefebvre Dec 3, 2025
9057916
feat: rename getSchemaContext to createSchema
florian-lefebvre Dec 3, 2025
ad72298
Apply suggestions from code review
florian-lefebvre Dec 3, 2025
1e906b2
Update packages/astro/src/content/utils.ts
florian-lefebvre Dec 3, 2025
591eeb8
Update .changeset/fresh-rocks-sing.md
florian-lefebvre Dec 3, 2025
0c1b558
Apply suggestion from @florian-lefebvre
florian-lefebvre Dec 3, 2025
551dea9
Apply suggestion from @florian-lefebvre
florian-lefebvre Dec 3, 2025
e9f9ba7
Merge branch 'next' into feat/content-loader-get-schema-context
florian-lefebvre Dec 4, 2025
c0be1db
Merge branch 'next' into feat/content-loader-get-schema-context
florian-lefebvre Dec 4, 2025
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/deep-states-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': major
---

Updates how schema types are inferred for content loaders with schemas (Loader API) - ([v6 upgrade guidance](https://deploy-preview-12322--astro-docs-2.netlify.app/en/guides/upgrade-to/v6/TODO:))
5 changes: 5 additions & 0 deletions .changeset/fresh-rocks-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': major
---

Removes the ability for content loaders schemas to be functions and adds a new equivalent `getSchemaContext()` property (Loader API) - ([v6 upgrade guidance](https://deploy-preview-12322--astro-docs-2.netlify.app/en/guides/upgrade-to/v6/TODO:))
3 changes: 1 addition & 2 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,7 @@
"yargs-parser": "^21.1.1",
"yocto-spinner": "^0.2.3",
"zod": "^3.25.76",
"zod-to-json-schema": "^3.24.6",
"zod-to-ts": "^1.2.0"
"zod-to-json-schema": "^3.24.6"
},
"optionalDependencies": {
"sharp": "^0.34.0"
Expand Down
50 changes: 20 additions & 30 deletions packages/astro/src/content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,29 +71,6 @@ export type BaseSchema = ZodType;

export type SchemaContext = { image: ImageFunction };

type ContentLayerConfig<S extends BaseSchema, TData extends { id: string } = { id: string }> = {
type?: 'content_layer';
schema?: S | ((context: SchemaContext) => S);
loader:
| 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> = {
type: 'data';
schema?: S | ((context: SchemaContext) => S);
};

type ContentCollectionConfig<S extends BaseSchema> = {
type?: 'content';
schema?: S | ((context: SchemaContext) => S);
loader?: never;
};

export type LiveCollectionConfig<
L extends LiveLoader,
S extends BaseSchema | undefined = undefined,
Expand All @@ -103,10 +80,22 @@ export type LiveCollectionConfig<
loader: L;
};

export type CollectionConfig<S extends BaseSchema> =
| ContentCollectionConfig<S>
| DataCollectionConfig<S>
| ContentLayerConfig<S>;
type LoaderConstraint<TData extends { id: string }> =
| Loader
| (() =>
| Array<TData>
| Promise<Array<TData>>
| Record<string, Omit<TData, 'id'> & { id?: string }>
| Promise<Record<string, Omit<TData, 'id'> & { id?: string }>>);

export type CollectionConfig<
TSchema extends BaseSchema,
TLoader extends LoaderConstraint<{ id: string }>,
> = {
type?: 'content_layer';
schema?: TSchema | ((context: SchemaContext) => TSchema);
loader: TLoader;
};

export function defineLiveCollection<
L extends LiveLoader,
Expand Down Expand Up @@ -167,9 +156,10 @@ export function defineLiveCollection<
return config;
}

export function defineCollection<S extends BaseSchema>(
config: CollectionConfig<S>,
): CollectionConfig<S> {
export function defineCollection<
TSchema extends BaseSchema,
TLoader extends LoaderConstraint<{ id: string }>,
>(config: CollectionConfig<TSchema, TLoader>): CollectionConfig<TSchema, TLoader> {
const importerFilename = getImporterFilename();

if (importerFilename?.includes('live.config')) {
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/content/content-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,8 @@ class ContentLayer {

if (!schema && typeof collection.loader === 'object') {
schema = collection.loader.schema;
if (typeof schema === 'function') {
schema = await schema();
if (!schema && collection.loader.getSchemaContext) {
({ schema } = await collection.loader.getSchemaContext());
}
}

Expand Down
18 changes: 14 additions & 4 deletions packages/astro/src/content/loaders/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,24 @@ export interface LoaderContext {
entryTypes: Map<string, ContentEntryType>;
}

export interface Loader {
export type Loader = {
/** Unique name of the loader, e.g. the npm package name */
name: string;
/** Do the actual loading of the data */
load: (context: LoaderContext) => Promise<void>;
/** Optionally, define the schema of the data. Will be overridden by user-defined schema */
schema?: ZodSchema | Promise<ZodSchema> | (() => ZodSchema | Promise<ZodSchema>);
}
} & (
| {
/** Optionally, define the schema of the data. Will be overridden by user-defined schema */
schema?: ZodSchema;
}
| {
/** Optionally, provide a function to dynamically provide a schema. Will be overridden by user-defined schema */
getSchemaContext?: () => Promise<{
schema: ZodSchema;
types: string;
}>;
}
);

export interface LoadEntryContext<TEntryFilter = never> {
filter: TEntryFilter extends never ? { id: string } : TEntryFilter;
Expand Down
98 changes: 66 additions & 32 deletions packages/astro/src/content/types-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Logger } from '../core/logger/core.js';
import { isRelativePath } from '../core/path.js';
import type { AstroSettings } from '../types/astro.js';
import type { ContentEntryType } from '../types/public/content.js';
import type { InjectedType } from '../types/public/integrations.js';
import {
COLLECTIONS_DIR,
CONTENT_LAYER_TYPE,
Expand Down Expand Up @@ -342,58 +343,68 @@ function normalizeConfigPath(from: string, to: string) {
return `"${isRelativePath(configPath) ? '' : './'}${normalizedPath}"` as const;
}

const schemaCache = new Map<string, ZodSchema>();
const schemaContextResultCache = new Map<string, { schema: ZodSchema; types: string }>();

async function getContentLayerSchema<T extends keyof ContentConfig['collections']>(
async function getSchemaContextResult<T extends keyof ContentConfig['collections']>(
collection: ContentConfig['collections'][T],
collectionKey: T,
): Promise<ZodSchema | undefined> {
const cached = schemaCache.get(collectionKey);
) {
const cached = schemaContextResultCache.get(collectionKey);
if (cached) {
return cached;
}

if (
collection?.type === CONTENT_LAYER_TYPE &&
typeof collection.loader === 'object' &&
collection.loader.schema
!collection.loader.schema &&
collection.loader.getSchemaContext
) {
let schema = collection.loader.schema;
if (typeof schema === 'function') {
schema = await schema();
}
if (schema) {
schemaCache.set(collectionKey, await schema);
return schema;
}
const result = await collection.loader.getSchemaContext();
schemaContextResultCache.set(collectionKey, result);
return result;
}
}

async function getContentLayerSchema<T extends keyof ContentConfig['collections']>(
collection: ContentConfig['collections'][T],
collectionKey: T,
): Promise<ZodSchema | undefined> {
if (collection?.type !== CONTENT_LAYER_TYPE || typeof collection.loader === 'function') {
return;
}
if (collection.loader.schema) {
return collection.loader.schema;
}
const result = await getSchemaContextResult(collection, collectionKey);
return result?.schema;
}

async function typeForCollection<T extends keyof ContentConfig['collections']>(
collection: ContentConfig['collections'][T] | undefined,
collectionKey: T,
): Promise<string> {
): Promise<{ type: string; injectedType?: InjectedType }> {
if (collection?.schema) {
return `InferEntrySchema<${collectionKey}>`;
return { type: `InferEntrySchema<${collectionKey}>` };
}
if (!collection?.type) {
return 'any';
if (!collection?.type || typeof collection.loader === 'function') {
return { type: 'any' };
}
const schema = await getContentLayerSchema(collection, collectionKey);
if (!schema) {
return 'any';
if (collection.loader.schema) {
return { type: `InferLoaderSchema<${collectionKey}>` };
}
try {
const zodToTs = await import('zod-to-ts');
const ast = zodToTs.zodToTs(schema);
return zodToTs.printNode(ast.node);
} catch (err: any) {
// zod-to-ts is sad if we don't have TypeScript installed, but that's fine as we won't be needing types in that case
if (err.message.includes("Cannot find package 'typescript'")) {
return 'any';
}
throw err;
const result = await getSchemaContextResult(collection, collectionKey);
if (!result) {
return { type: 'any' };
}
const base = `loaders/${collectionKey.slice(1, -1)}`;
return {
type: `import("./${base}.js").Collection`,
injectedType: {
filename: `${base}.ts`,
content: result.types,
},
};
}

async function writeContentFiles({
Expand Down Expand Up @@ -460,7 +471,21 @@ async function writeContentFiles({
return;
}

const dataType = await typeForCollection(collectionConfig, collectionKey);
const { type: dataType, injectedType } = await typeForCollection(
collectionConfig,
collectionKey,
);

if (injectedType) {
if (settings.injectedTypes.some((t) => t.filename === CONTENT_TYPES_FILE)) {
// If it's the first time, we inject types the usual way. sync() will handle creating files and references. If it's not the first time, we just override the dts content
const url = new URL(injectedType.filename, settings.dotAstroDir);
await fs.promises.mkdir(path.dirname(fileURLToPath(url)), { recursive: true });
await fs.promises.writeFile(url, injectedType.content, 'utf-8');
} else {
settings.injectedTypes.push(injectedType);
}
}

dataTypesStr += `${collectionKey}: Record<string, {\n id: string;\n body?: string;\n collection: ${collectionKey};\n data: ${dataType};\n rendered?: RenderedContent;\n filePath?: string;\n}>;\n`;

Expand All @@ -487,7 +512,16 @@ async function writeContentFiles({
const key = JSON.parse(collectionKey);

contentCollectionManifest.collections.push({
hasSchema: Boolean(collectionConfig?.schema || schemaCache.has(collectionKey)),
hasSchema: Boolean(
// Is there a user provided schema or
collectionConfig?.schema ||
// Is it a loader object and
(typeof collectionConfig?.loader !== 'function' &&
// Is it a loader static schema or
(collectionConfig?.loader.schema ||
// is it a loader dynamic schema
schemaContextResultCache.has(collectionKey))),
),
name: key,
});

Expand Down
43 changes: 34 additions & 9 deletions packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import colors from 'piccolore';
import type { PluginContext } from 'rollup';
import type { ViteDevServer } from 'vite';
import xxhash from 'xxhash-wasm';
import { z } from 'zod';
import { type ZodSchema, z } from 'zod';
import { AstroError, AstroErrorData, errorMap, MarkdownError } from '../core/errors/index.js';
import { isYAMLException } from '../core/errors/utils.js';
import type { Logger } from '../core/logger/core.js';
Expand Down Expand Up @@ -60,14 +60,39 @@ const collectionConfigParser = z.union([
z.function(),
z.object({
name: z.string(),
load: z.function().args(z.custom<LoaderContext>()).returns(
z.custom<{
schema?: any;
types?: string;
} | void>(),
),
schema: z.any().optional(),
render: z.function(z.tuple([z.any()], z.unknown())).optional(),
load: z.function().args(z.custom<LoaderContext>()).returns(z.promise(z.void())),
schema: z
.any()
.transform((v) => {
if (typeof v === 'function') {
console.warn(
`Since Astro 6, a loader schema cannot be a function. It is ignored and will break in a future major. Report it to the loader author or check the docs: TODO:`,
);
return undefined;
}
return v;
})
.superRefine((v, ctx) => {
if (v !== undefined && !('_def' in v)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid Zod schema',
});
return z.NEVER;
}
})
.optional(),
getSchemaContext: z
.function()
.returns(
z.promise(
z.object({
schema: z.custom<ZodSchema>((v) => '_def' in v),
types: z.string(),
}),
),
)
.optional(),
}),
]),
}),
Expand Down
6 changes: 6 additions & 0 deletions packages/astro/templates/content/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ declare module 'astro:content' {
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type InferLoaderSchema<
C extends keyof DataEntryMap,
L = Required<ContentConfig['collections'][C]>['loader'],
> = L extends { schema: import('astro/zod').ZodSchema }
? import('astro/zod').infer<Required<ContentConfig['collections'][C]>['loader']['schema']>
: any;

type DataEntryMap = {
// @@DATA_ENTRY_MAP@@
Expand Down
Loading
Loading