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

feat: add defineIntegration utility #10892

Draft
wants to merge 18 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/green-deers-trade.md
@@ -0,0 +1,57 @@
---
"astro": minor
"@astrojs/db": minor
---

Adds a new `defineIntegration` helper and reworks typings for integrations authors extending Astro DB.

This release adds a new `defineIntegration` helper through the `astro/integration` import that allows to define a type-safe integration and handle options validation (not required):

```ts
import { defineIntegration } from "astro/integration"
import { z } from "astro/zod"

export const integration = defineIntegration({
name: "my-integration",
// optional
optionsSchema: z.object({ id: z.string() }),
setup({ options }) {
return {
hooks: {
"astro:config:setup": (params) => {
// ...
}
}
}
}
})
```

Astro DB `defineDbIntegration` has been removed in favor of a way that works with this new `defineIntegration` (but also the `AstroIntegration` type):

```ts
import {} from "astro"
import { defineIntegration } from "astro/integration"
import type { AstroDbHooks } from "@astrojs/db/types"

declare module "astro" {
interface AstroIntegrationHooks extends AstroDbHooks {}
}

export default defineIntegration({
name: "db-test-integration",
setup() {
return {
hooks: {
'astro:db:setup': ({ extendDb }) => {
extendDb({
configEntrypoint: './integration/config.ts',
seedEntrypoint: './integration/seed.ts',
});
},
},
}
}
})

```
2 changes: 2 additions & 0 deletions packages/astro/package.json
Expand Up @@ -53,6 +53,7 @@
"./client/*": "./dist/runtime/client/*",
"./components": "./components/index.ts",
"./components/*": "./components/*",
"./integration": "./dist/integrations/index.js",
"./toolbar": "./dist/toolbar/index.js",
"./assets": "./dist/assets/index.js",
"./assets/utils": "./dist/assets/utils/index.js",
Expand All @@ -69,6 +70,7 @@
"default": "./zod.mjs"
},
"./errors": "./dist/core/errors/userError.js",
"./errors/zod-error-map": "./dist/core/errors/zod-error-map.js",
"./middleware": {
"types": "./dist/core/middleware/index.d.ts",
"default": "./dist/core/middleware/index.js"
Expand Down
154 changes: 78 additions & 76 deletions packages/astro/src/@types/astro.ts
Expand Up @@ -1966,7 +1966,7 @@ export interface ResolvedInjectedRoute extends InjectedRoute {
* Resolved Astro Config
* Config with user settings along with all defaults filled in.
*/
export interface AstroConfig extends AstroConfigType {
export interface AstroConfig extends Omit<AstroConfigType, "integrations"> {
// Public:
// This is a more detailed type than zod validation gives us.
// TypeScript still confirms zod validation matches this type.
Expand Down Expand Up @@ -2716,87 +2716,89 @@ export interface SSRLoadedRenderer extends AstroRenderer {
}

export type HookParameters<
Hook extends keyof AstroIntegration['hooks'],
Fn = AstroIntegration['hooks'][Hook],
Hook extends keyof AstroIntegrationHooks,
Fn = AstroIntegrationHooks[Hook],
> = Fn extends (...args: any) => any ? Parameters<Fn>[0] : never;

export interface AstroIntegrationHooks {
'astro:config:setup'?: (options: {
config: AstroConfig;
command: 'dev' | 'build' | 'preview';
isRestart: boolean;
updateConfig: (newConfig: DeepPartial<AstroConfig>) => AstroConfig;
addRenderer: (renderer: AstroRenderer) => void;
addWatchFile: (path: URL | string) => void;
injectScript: (stage: InjectedScriptStage, content: string) => void;
injectRoute: (injectRoute: InjectedRoute) => void;
addClientDirective: (directive: ClientDirectiveConfig) => void;
/**
* @deprecated Use `addDevToolbarApp` instead.
* TODO: Fully remove in Astro 5.0
*/
addDevOverlayPlugin: (entrypoint: string) => void;
// TODO: Deprecate the `string` overload once a few apps have been migrated to the new API.
addDevToolbarApp: (entrypoint: DevToolbarAppEntry | string) => void;
addMiddleware: (mid: AstroIntegrationMiddleware) => void;
logger: AstroIntegrationLogger;
// TODO: Add support for `injectElement()` for full HTML element injection, not just scripts.
// This may require some refactoring of `scripts`, `styles`, and `links` into something
// more generalized. Consider the SSR use-case as well.
// injectElement: (stage: vite.HtmlTagDescriptor, element: string) => void;
}) => void | Promise<void>;
'astro:config:done'?: (options: {
config: AstroConfig;
setAdapter: (adapter: AstroAdapter) => void;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:server:setup'?: (options: {
server: vite.ViteDevServer;
logger: AstroIntegrationLogger;
toolbar: ReturnType<typeof getToolbarServerCommunicationHelpers>;
}) => void | Promise<void>;
'astro:server:start'?: (options: {
address: AddressInfo;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:server:done'?: (options: { logger: AstroIntegrationLogger }) => void | Promise<void>;
'astro:build:ssr'?: (options: {
manifest: SerializedSSRManifest;
/**
* This maps a {@link RouteData} to an {@link URL}, this URL represents
* the physical file you should import.
*/
entryPoints: Map<RouteData, URL>;
/**
* File path of the emitted middleware
*/
middlewareEntryPoint: URL | undefined;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:build:start'?: (options: { logger: AstroIntegrationLogger }) => void | Promise<void>;
'astro:build:setup'?: (options: {
vite: vite.InlineConfig;
pages: Map<string, PageBuildData>;
target: 'client' | 'server';
updateConfig: (newConfig: vite.InlineConfig) => void;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:build:generated'?: (options: {
dir: URL;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:build:done'?: (options: {
pages: { pathname: string }[];
dir: URL;
routes: RouteData[];
logger: AstroIntegrationLogger;
cacheManifest: boolean;
}) => void | Promise<void>;
}

export interface AstroIntegration {
/** The name of the integration. */
name: string;
/** The different hooks available to extend. */
hooks: {
'astro:config:setup'?: (options: {
config: AstroConfig;
command: 'dev' | 'build' | 'preview';
isRestart: boolean;
updateConfig: (newConfig: DeepPartial<AstroConfig>) => AstroConfig;
addRenderer: (renderer: AstroRenderer) => void;
addWatchFile: (path: URL | string) => void;
injectScript: (stage: InjectedScriptStage, content: string) => void;
injectRoute: (injectRoute: InjectedRoute) => void;
addClientDirective: (directive: ClientDirectiveConfig) => void;
/**
* @deprecated Use `addDevToolbarApp` instead.
* TODO: Fully remove in Astro 5.0
*/
addDevOverlayPlugin: (entrypoint: string) => void;
// TODO: Deprecate the `string` overload once a few apps have been migrated to the new API.
addDevToolbarApp: (entrypoint: DevToolbarAppEntry | string) => void;
addMiddleware: (mid: AstroIntegrationMiddleware) => void;
logger: AstroIntegrationLogger;
// TODO: Add support for `injectElement()` for full HTML element injection, not just scripts.
// This may require some refactoring of `scripts`, `styles`, and `links` into something
// more generalized. Consider the SSR use-case as well.
// injectElement: (stage: vite.HtmlTagDescriptor, element: string) => void;
}) => void | Promise<void>;
'astro:config:done'?: (options: {
config: AstroConfig;
setAdapter: (adapter: AstroAdapter) => void;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:server:setup'?: (options: {
server: vite.ViteDevServer;
logger: AstroIntegrationLogger;
toolbar: ReturnType<typeof getToolbarServerCommunicationHelpers>;
}) => void | Promise<void>;
'astro:server:start'?: (options: {
address: AddressInfo;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:server:done'?: (options: { logger: AstroIntegrationLogger }) => void | Promise<void>;
'astro:build:ssr'?: (options: {
manifest: SerializedSSRManifest;
/**
* This maps a {@link RouteData} to an {@link URL}, this URL represents
* the physical file you should import.
*/
entryPoints: Map<RouteData, URL>;
/**
* File path of the emitted middleware
*/
middlewareEntryPoint: URL | undefined;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:build:start'?: (options: { logger: AstroIntegrationLogger }) => void | Promise<void>;
'astro:build:setup'?: (options: {
vite: vite.InlineConfig;
pages: Map<string, PageBuildData>;
target: 'client' | 'server';
updateConfig: (newConfig: vite.InlineConfig) => void;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:build:generated'?: (options: {
dir: URL;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:build:done'?: (options: {
pages: { pathname: string }[];
dir: URL;
routes: RouteData[];
logger: AstroIntegrationLogger;
cacheManifest: boolean;
}) => void | Promise<void>;
};
hooks: AstroIntegrationHooks;
}

export type MiddlewareNext = () => Promise<Response>;
Expand Down
12 changes: 12 additions & 0 deletions packages/astro/src/core/errors/errors-data.ts
Expand Up @@ -1156,6 +1156,18 @@ export const i18nNotEnabled = {
hint: 'See https://docs.astro.build/en/guides/internationalization for a guide on setting up i18n.',
} satisfies ErrorData;

/**
* @docs
* @description
* Invalid options have been passed to the integration.
*/
export const AstroIntegrationInvalidOptions = {
name: 'AstroIntegrationInvalidOptions',
title: 'Astro Integration Invalid Options',
message: (name: string, error: string) =>
`Invalid options passed to "${name}" integration\n${error}`,
} satisfies ErrorData;

/**
* @docs
* @kind heading
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/errors/index.ts
Expand Up @@ -11,4 +11,4 @@ export {
export type { ErrorLocation, ErrorWithMetadata } from './errors.js';
export { codeFrame } from './printer.js';
export { createSafeError, positionAt } from './utils.js';
export { errorMap } from './zod-error-map.js';
export { errorMap } from './zod-error-map.js';
19 changes: 10 additions & 9 deletions packages/astro/src/core/errors/zod-error-map.ts
@@ -1,6 +1,6 @@
import type { ZodErrorMap } from 'zod';

type TypeOrLiteralErrByPathEntry = {
interface TypeOrLiteralErrByPathEntry {
code: 'invalid_type' | 'invalid_literal';
received: unknown;
expected: unknown[];
Expand All @@ -14,12 +14,13 @@ export const errorMap: ZodErrorMap = (baseError, ctx) => {
// raise a single error when `key` does not match:
// > Did not match union.
// > key: Expected `'tutorial' | 'blog'`, received 'foo'
let typeOrLiteralErrByPath = new Map<string, TypeOrLiteralErrByPathEntry>();
for (const unionError of baseError.unionErrors.map((e) => e.errors).flat()) {
const typeOrLiteralErrByPath = new Map<string, TypeOrLiteralErrByPathEntry>();
for (const unionError of baseError.unionErrors.flatMap((e) => e.errors)) {
if (unionError.code === 'invalid_type' || unionError.code === 'invalid_literal') {
const flattenedErrorPath = flattenErrorPath(unionError.path);
if (typeOrLiteralErrByPath.has(flattenedErrorPath)) {
typeOrLiteralErrByPath.get(flattenedErrorPath)!.expected.push(unionError.expected);
const typeOrLiteralErr = typeOrLiteralErrByPath.get(flattenedErrorPath);
if (typeOrLiteralErr) {
typeOrLiteralErr.expected.push(unionError.expected);
} else {
typeOrLiteralErrByPath.set(flattenedErrorPath, {
code: unionError.code,
Expand All @@ -29,7 +30,7 @@ export const errorMap: ZodErrorMap = (baseError, ctx) => {
}
}
}
let messages: string[] = [
const messages: string[] = [
prefix(
baseErrorPath,
typeOrLiteralErrByPath.size ? 'Did not match union:' : 'Did not match union.'
Expand All @@ -43,9 +44,9 @@ export const errorMap: ZodErrorMap = (baseError, ctx) => {
// filter it out. Can lead to confusing noise.
.filter(([, error]) => error.expected.length === baseError.unionErrors.length)
.map(([key, error]) =>
// Avoid printing the key again if it's a base error
key === baseErrorPath
? // Avoid printing the key again if it's a base error
`> ${getTypeOrLiteralMsg(error)}`
? `> ${getTypeOrLiteralMsg(error)}`
: `> ${prefix(key, getTypeOrLiteralMsg(error))}`
)
)
Expand Down Expand Up @@ -96,4 +97,4 @@ const unionExpectedVals = (expectedVals: Set<unknown>) =>
})
.join('');

const flattenErrorPath = (errorPath: (string | number)[]) => errorPath.join('.');
const flattenErrorPath = (errorPath: Array<string | number>) => errorPath.join('.');
60 changes: 60 additions & 0 deletions packages/astro/src/integrations/define-integration.ts
@@ -0,0 +1,60 @@
import type { AstroIntegration, AstroIntegrationHooks } from '../@types/astro.js';
import { AstroError, AstroErrorData, errorMap } from '../core/errors/index.js';
import { z } from 'zod';

type AstroIntegrationSetupFn<Options extends z.ZodTypeAny> = (params: {
name: string;
options: z.output<Options>;
}) => {
hooks: AstroIntegrationHooks;
};

/**
* Allows defining an integration in a type-safe way and optionally validate options.
* See [documentation](TODO:).
*/
export const defineIntegration = <
TOptionsSchema extends z.ZodTypeAny = z.ZodNever,
TSetup extends AstroIntegrationSetupFn<TOptionsSchema> = AstroIntegrationSetupFn<TOptionsSchema>,
>({
name,
optionsSchema,
setup,
}: {
name: string;
optionsSchema?: TOptionsSchema;
setup: TSetup;
}): ((
...args: [z.input<TOptionsSchema>] extends [never]
? []
: undefined extends z.input<TOptionsSchema>
? [options?: z.input<TOptionsSchema>]
: [options: z.input<TOptionsSchema>]
) => AstroIntegration & Omit<ReturnType<TSetup>, keyof AstroIntegration>) => {
return (...args) => {
const parsedOptions = (optionsSchema ?? z.never().optional()).safeParse(args[0], {
errorMap,
});

if (!parsedOptions.success) {
throw new AstroError({
...AstroErrorData.AstroIntegrationInvalidOptions,
message: AstroErrorData.AstroIntegrationInvalidOptions.message(
name,
parsedOptions.error.issues.map((i) => i.message).join('\n')
),
});
}

const options = parsedOptions.data as z.output<TOptionsSchema>;

const integration = setup({ name, options }) as ReturnType<TSetup>;

return {
name,
...integration,
};
};
};

// export const defineIntegration = () => {};
1 change: 1 addition & 0 deletions packages/astro/src/integrations/index.ts
@@ -0,0 +1 @@
export { defineIntegration } from "./define-integration.js"