0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-04-14 23:51:49 -05:00

feat: move astro config validation after astro:config:setup (#13482)

* feat: move astro config validation after astro:config:setup

* feat: superRefine

* fix: test

* feat: update test

* feat: update test

* fix: test

* feat: feedback

* feat: improve logging

* feat: refactor

* fix: no spread

* feat: split schemas

* chore: changeset

* Update packages/astro/src/core/config/schemas/README.md

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Update packages/astro/src/integrations/hooks.ts

* Update packages/astro/src/core/config/schemas/README.md

* Update .changeset/easy-vans-laugh.md

* Update .changeset/easy-vans-laugh.md

* grammar nit in changeset

---------

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com>

Co-authored-by: ematipico <602478+ematipico@users.noreply.github.com>
Co-authored-by: delucis <357379+delucis@users.noreply.github.com>
Co-authored-by: sarah11918 <5098874+sarah11918@users.noreply.github.com>
This commit is contained in:
Florian Lefebvre 2025-04-02 12:09:16 +02:00 committed by GitHub
parent 4db2c6804e
commit ff257df4e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 856 additions and 840 deletions

View file

@ -0,0 +1,9 @@
---
'astro': minor
---
Updates Astro config validation to also run for the Integration API. An error log will specify which integration is failing the validation.
Now, Astro will first validate the user configuration, then validate the updated configuration after each integration `astro:config:setup` hook has run. This means `updateConfig()` calls will no longer accept invalid configuration.
This fixes a situation where integrations could potentially update a project with a malformed configuration. These issues should now be caught and logged so that you can update your integration to only set valid configurations.

View file

@ -0,0 +1,7 @@
---
'astro': minor
---
Improves integrations error handling
If an error is thrown from an integration hook, an error log will now provide information about the concerned integration and hook

View file

@ -1,7 +1,7 @@
import './polyfill.js';
import { posix } from 'node:path';
import { getDefaultClientDirectives } from '../core/client-directive/index.js';
import { ASTRO_CONFIG_DEFAULTS } from '../core/config/schema.js';
import { ASTRO_CONFIG_DEFAULTS } from '../core/config/schemas/index.js';
import { validateConfig } from '../core/config/validate.js';
import { createKey } from '../core/encryption.js';
import { Logger } from '../core/logger/core.js';

View file

@ -173,7 +173,7 @@ class AstroBuilder {
/** Run the build logic. build() is marked private because usage should go through ".run()" */
private async build({ viteConfig }: { viteConfig: vite.InlineConfig }) {
await runHookBuildStart({ config: this.settings.config, logging: this.logger });
await runHookBuildStart({ config: this.settings.config, logger: this.logger });
this.validateConfig();
this.logger.info('build', `output: ${blue('"' + this.settings.config.output + '"')}`);
@ -248,7 +248,7 @@ class AstroBuilder {
.flat()
.map((pageData) => pageData.route)
.concat(hasServerIslands ? getServerIslandRouteData(this.settings.config) : []),
logging: this.logger,
logger: this.logger,
});
if (this.logger.level && levels[this.logger.level()] <= levels['info']) {

View file

@ -6,6 +6,6 @@ export {
} from './config.js';
export { createNodeLogger } from './logging.js';
export { mergeConfig } from './merge.js';
export type { AstroConfigType } from './schema.js';
export type { AstroConfigType } from './schemas/index.js';
export { createSettings } from './settings.js';
export { loadTSConfig, updateTSConfigForFramework } from './tsconfig.js';

View file

@ -0,0 +1,7 @@
# Config schemas
There are 3 zod schemas needed to validate the Astro config properly:
- `AstroConfigSchema` (base): a schema that matches the `AstroConfig` type
- `createRelativeSchema` (relative): a function that uses the base schema, and adds transformations relative to the project root. Transformations occur at runtime, and the paths are resolved against the `cwd` of the CLI.
- `AstroConfigRefinedSchema` (refined): a schema that handles extra validations. Due to constraints imposed by the Astro architecture, refinements can't be done on the `AstroConfig` schema because integrations deal with the output of the `AstroConfig` schema. As a result, this schema runs after parsing the user config and after every integration `astro:config:setup` hook (to make sure `updateConfig() has been called with valid config)`.

View file

@ -1,6 +1,4 @@
import type { OutgoingHttpHeaders } from 'node:http';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type {
ShikiConfig,
RehypePlugin as _RehypePlugin,
@ -10,10 +8,9 @@ import type {
import { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-remark';
import { type BuiltinTheme, bundledThemes } from 'shiki';
import { z } from 'zod';
import type { SvgRenderMode } from '../../assets/utils/svg.js';
import { EnvSchema } from '../../env/schema.js';
import type { AstroUserConfig, ViteUserConfig } from '../../types/public/config.js';
import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } from '../path.js';
import type { SvgRenderMode } from '../../../assets/utils/svg.js';
import { EnvSchema } from '../../../env/schema.js';
import type { AstroUserConfig, ViteUserConfig } from '../../../types/public/config.js';
// The below types are required boilerplate to workaround a Zod issue since v3.21.2. Since that version,
// Zod's compiled TypeScript would "simplify" certain values to their base representation, causing references
@ -35,7 +32,7 @@ import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } f
// back to the issue again. The complexified type should be the base representation that we want to expose.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ComplexifyUnionObj {}
export interface ComplexifyUnionObj {}
type ComplexifyWithUnion<T> = T & ComplexifyUnionObj;
type ComplexifyWithOmit<T> = Omit<T, '__nonExistent'>;
@ -45,7 +42,7 @@ type ShikiTheme = ComplexifyWithUnion<NonNullable<ShikiConfig['theme']>>;
type ShikiTransformer = ComplexifyWithUnion<NonNullable<ShikiConfig['transformers']>[number]>;
type RehypePlugin = ComplexifyWithUnion<_RehypePlugin>;
type RemarkPlugin = ComplexifyWithUnion<_RemarkPlugin>;
type RemarkRehype = ComplexifyWithOmit<_RemarkRehype>;
export type RemarkRehype = ComplexifyWithOmit<_RemarkRehype>;
export const ASTRO_CONFIG_DEFAULTS = {
root: '.',
@ -180,20 +177,7 @@ export const AstroConfigSchema = z.object({
assetsPrefix: z
.string()
.optional()
.or(z.object({ fallback: z.string() }).and(z.record(z.string())).optional())
.refine(
(value) => {
if (value && typeof value !== 'string') {
if (!value.fallback) {
return false;
}
}
return true;
},
{
message: 'The `fallback` is mandatory when defining the option as an object.',
},
),
.or(z.object({ fallback: z.string() }).and(z.record(z.string())).optional()),
serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects),
inlineStylesheets: z
@ -281,22 +265,9 @@ export const AstroConfigSchema = z.object({
.array(
z.object({
protocol: z.string().optional(),
hostname: z
.string()
.refine(
(val) => !val.includes('*') || val.startsWith('*.') || val.startsWith('**.'),
{
message: 'wildcards can only be placed at the beginning of the hostname',
},
)
.optional(),
hostname: z.string().optional(),
port: z.string().optional(),
pathname: z
.string()
.refine((val) => !val.includes('*') || val.endsWith('/*') || val.endsWith('/**'), {
message: 'wildcards can only be placed at the end of a pathname',
})
.optional(),
pathname: z.string().optional(),
}),
)
.default([]),
@ -430,108 +401,16 @@ export const AstroConfigSchema = z.object({
routing: z
.literal('manual')
.or(
z
.object({
prefixDefaultLocale: z.boolean().optional().default(false),
redirectToDefaultLocale: z.boolean().optional().default(true),
fallbackType: z.enum(['redirect', 'rewrite']).optional().default('redirect'),
})
.refine(
({ prefixDefaultLocale, redirectToDefaultLocale }) => {
return !(prefixDefaultLocale === false && redirectToDefaultLocale === false);
},
{
message:
'The option `i18n.redirectToDefaultLocale` is only useful when the `i18n.prefixDefaultLocale` is set to `true`. Remove the option `i18n.redirectToDefaultLocale`, or change its value to `true`.',
},
),
z.object({
prefixDefaultLocale: z.boolean().optional().default(false),
redirectToDefaultLocale: z.boolean().optional().default(true),
fallbackType: z.enum(['redirect', 'rewrite']).optional().default('redirect'),
}),
)
.optional()
.default({}),
})
.optional()
.superRefine((i18n, ctx) => {
if (i18n) {
const { defaultLocale, locales: _locales, fallback, domains } = i18n;
const locales = _locales.map((locale) => {
if (typeof locale === 'string') {
return locale;
} else {
return locale.path;
}
});
if (!locales.includes(defaultLocale)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The default locale \`${defaultLocale}\` is not present in the \`i18n.locales\` array.`,
});
}
if (fallback) {
for (const [fallbackFrom, fallbackTo] of Object.entries(fallback)) {
if (!locales.includes(fallbackFrom)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The locale \`${fallbackFrom}\` key in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`,
});
}
if (fallbackFrom === defaultLocale) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `You can't use the default locale as a key. The default locale can only be used as value.`,
});
}
if (!locales.includes(fallbackTo)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The locale \`${fallbackTo}\` value in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`,
});
}
}
}
if (domains) {
const entries = Object.entries(domains);
const hasDomains = domains ? Object.keys(domains).length > 0 : false;
if (entries.length > 0 && !hasDomains) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `When specifying some domains, the property \`i18n.routingStrategy\` must be set to \`"domains"\`.`,
});
}
for (const [domainKey, domainValue] of entries) {
if (!locales.includes(domainKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The locale \`${domainKey}\` key in the \`i18n.domains\` record doesn't exist in the \`i18n.locales\` array.`,
});
}
if (!domainValue.startsWith('https') && !domainValue.startsWith('http')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"The domain value must be a valid URL, and it has to start with 'https' or 'http'.",
path: ['domains'],
});
} else {
try {
const domainUrl = new URL(domainValue);
if (domainUrl.pathname !== '/') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The URL \`${domainValue}\` must contain only the origin. A subsequent pathname isn't allowed here. Remove \`${domainUrl.pathname}\`.`,
path: ['domains'],
});
}
} catch {
// no need to catch the error
}
}
}
}
}
}),
.optional(),
),
security: z
.object({
@ -639,199 +518,3 @@ export const AstroConfigSchema = z.object({
});
export type AstroConfigType = z.infer<typeof AstroConfigSchema>;
export function createRelativeSchema(cmd: string, fileProtocolRoot: string) {
let originalBuildClient: string;
let originalBuildServer: string;
// We need to extend the global schema to add transforms that are relative to root.
// This is type checked against the global schema to make sure we still match.
const AstroConfigRelativeSchema = AstroConfigSchema.extend({
root: z
.string()
.default(ASTRO_CONFIG_DEFAULTS.root)
.transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
srcDir: z
.string()
.default(ASTRO_CONFIG_DEFAULTS.srcDir)
.transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
compressHTML: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.compressHTML),
publicDir: z
.string()
.default(ASTRO_CONFIG_DEFAULTS.publicDir)
.transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
outDir: z
.string()
.default(ASTRO_CONFIG_DEFAULTS.outDir)
.transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
cacheDir: z
.string()
.default(ASTRO_CONFIG_DEFAULTS.cacheDir)
.transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
build: z
.object({
format: z
.union([z.literal('file'), z.literal('directory'), z.literal('preserve')])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.format),
// NOTE: `client` and `server` are transformed relative to the default outDir first,
// later we'll fix this to be relative to the actual `outDir`
client: z
.string()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.client)
.transform((val) => {
originalBuildClient = val;
return resolveDirAsUrl(
val,
path.resolve(fileProtocolRoot, ASTRO_CONFIG_DEFAULTS.outDir),
);
}),
server: z
.string()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.server)
.transform((val) => {
originalBuildServer = val;
return resolveDirAsUrl(
val,
path.resolve(fileProtocolRoot, ASTRO_CONFIG_DEFAULTS.outDir),
);
}),
assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets),
assetsPrefix: z
.string()
.optional()
.or(z.object({ fallback: z.string() }).and(z.record(z.string())).optional())
.refine(
(value) => {
if (value && typeof value !== 'string') {
if (!value.fallback) {
return false;
}
}
return true;
},
{
message: 'The `fallback` is mandatory when defining the option as an object.',
},
),
serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects),
inlineStylesheets: z
.enum(['always', 'auto', 'never'])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets),
concurrency: z.number().min(1).optional().default(ASTRO_CONFIG_DEFAULTS.build.concurrency),
})
.optional()
.default({}),
server: z.preprocess(
// preprocess
(val) => {
if (typeof val === 'function') {
return val({ command: cmd === 'dev' ? 'dev' : 'preview' });
} else {
return val;
}
},
// validate
z
.object({
open: z
.union([z.string(), z.boolean()])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.server.open),
host: z
.union([z.string(), z.boolean()])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.server.host),
port: z.number().optional().default(ASTRO_CONFIG_DEFAULTS.server.port),
headers: z.custom<OutgoingHttpHeaders>().optional(),
streaming: z.boolean().optional().default(true),
allowedHosts: z
.union([z.array(z.string()), z.literal(true)])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.server.allowedHosts),
})
.optional()
.default({}),
),
})
.transform((config) => {
// If the user changed `outDir`, we need to also update `build.client` and `build.server`
// the be based on the correct `outDir`
if (
config.outDir.toString() !==
resolveDirAsUrl(ASTRO_CONFIG_DEFAULTS.outDir, fileProtocolRoot).toString()
) {
const outDirPath = fileURLToPath(config.outDir);
config.build.client = resolveDirAsUrl(originalBuildClient, outDirPath);
config.build.server = resolveDirAsUrl(originalBuildServer, outDirPath);
}
// Handle `base` and `image.endpoint.route` trailing slash based on `trailingSlash` config
if (config.trailingSlash === 'never') {
config.base = prependForwardSlash(removeTrailingForwardSlash(config.base));
config.image.endpoint.route = prependForwardSlash(
removeTrailingForwardSlash(config.image.endpoint.route),
);
} else if (config.trailingSlash === 'always') {
config.base = prependForwardSlash(appendForwardSlash(config.base));
config.image.endpoint.route = prependForwardSlash(
appendForwardSlash(config.image.endpoint.route),
);
} else {
config.base = prependForwardSlash(config.base);
config.image.endpoint.route = prependForwardSlash(config.image.endpoint.route);
}
return config;
})
.refine((obj) => !obj.outDir.toString().startsWith(obj.publicDir.toString()), {
message:
'The value of `outDir` must not point to a path within the folder set as `publicDir`, this will cause an infinite loop',
})
.superRefine((configuration, ctx) => {
const { site, i18n, output, image, experimental } = configuration;
const hasDomains = i18n?.domains ? Object.keys(i18n.domains).length > 0 : false;
if (hasDomains) {
if (!site) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"The option `site` isn't set. When using the 'domains' strategy for `i18n`, `site` is required to create absolute URLs for locales that aren't mapped to a domain.",
});
}
if (output !== 'server') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Domain support is only available when `output` is `"server"`.',
});
}
}
if (
!experimental.responsiveImages &&
(image.experimentalLayout ||
image.experimentalObjectFit ||
image.experimentalObjectPosition ||
image.experimentalBreakpoints)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'The `experimentalLayout`, `experimentalObjectFit`, `experimentalObjectPosition` and `experimentalBreakpoints` options are only available when `experimental.responsiveImages` is enabled.',
});
}
});
return AstroConfigRelativeSchema;
}
function resolveDirAsUrl(dir: string, root: string) {
let resolvedDir = path.resolve(root, dir);
if (!resolvedDir.endsWith(path.sep)) {
resolvedDir += path.sep;
}
return pathToFileURL(resolvedDir);
}

View file

@ -0,0 +1,3 @@
export { AstroConfigSchema, ASTRO_CONFIG_DEFAULTS, type AstroConfigType } from './base.js';
export { createRelativeSchema } from './relative.js';
export { AstroConfigRefinedSchema } from './refined.js';

View file

@ -0,0 +1,187 @@
import { z } from 'zod';
import type { AstroConfig } from '../../../types/public/config.js';
export const AstroConfigRefinedSchema = z.custom<AstroConfig>().superRefine((config, ctx) => {
if (
config.build.assetsPrefix &&
typeof config.build.assetsPrefix !== 'string' &&
!config.build.assetsPrefix.fallback
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'The `fallback` is mandatory when defining the option as an object.',
path: ['build', 'assetsPrefix'],
});
}
for (let i = 0; i < config.image.remotePatterns.length; i++) {
const { hostname, pathname } = config.image.remotePatterns[i];
if (
hostname &&
hostname.includes('*') &&
!(hostname.startsWith('*.') || hostname.startsWith('**.'))
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'wildcards can only be placed at the beginning of the hostname',
path: ['image', 'remotePatterns', i, 'hostname'],
});
}
if (
pathname &&
pathname.includes('*') &&
!(pathname.endsWith('/*') || pathname.endsWith('/**'))
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'wildcards can only be placed at the end of a pathname',
path: ['image', 'remotePatterns', i, 'pathname'],
});
}
}
if (
config.i18n &&
typeof config.i18n.routing !== 'string' &&
!config.i18n.routing.redirectToDefaultLocale &&
!config.i18n.routing.prefixDefaultLocale
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'The option `i18n.routing.redirectToDefaultLocale` is only useful when the `i18n.routing.prefixDefaultLocale` is set to `true`. Remove the option `i18n.routing.redirectToDefaultLocale`, or change its value to `true`.',
path: ['i18n', 'routing', 'redirectToDefaultLocale'],
});
}
if (config.outDir.toString().startsWith(config.publicDir.toString())) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'The value of `outDir` must not point to a path within the folder set as `publicDir`, this will cause an infinite loop',
path: ['outDir'],
});
}
if (config.i18n) {
const { defaultLocale, locales: _locales, fallback, domains } = config.i18n;
const locales = _locales.map((locale) => {
if (typeof locale === 'string') {
return locale;
} else {
return locale.path;
}
});
if (!locales.includes(defaultLocale)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The default locale \`${defaultLocale}\` is not present in the \`i18n.locales\` array.`,
path: ['i18n', 'locales'],
});
}
if (fallback) {
for (const [fallbackFrom, fallbackTo] of Object.entries(fallback)) {
if (!locales.includes(fallbackFrom)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The locale \`${fallbackFrom}\` key in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`,
path: ['i18n', 'fallbacks'],
});
}
if (fallbackFrom === defaultLocale) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `You can't use the default locale as a key. The default locale can only be used as value.`,
path: ['i18n', 'fallbacks'],
});
}
if (!locales.includes(fallbackTo)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The locale \`${fallbackTo}\` value in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`,
path: ['i18n', 'fallbacks'],
});
}
}
}
if (domains) {
const entries = Object.entries(domains);
const hasDomains = domains ? Object.keys(domains).length > 0 : false;
if (entries.length > 0 && !hasDomains) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `When specifying some domains, the property \`i18n.routing.strategy\` must be set to \`"domains"\`.`,
path: ['i18n', 'routing', 'strategy'],
});
}
if (hasDomains) {
if (!config.site) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"The option `site` isn't set. When using the 'domains' strategy for `i18n`, `site` is required to create absolute URLs for locales that aren't mapped to a domain.",
path: ['site'],
});
}
if (config.output !== 'server') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Domain support is only available when `output` is `"server"`.',
path: ['output'],
});
}
}
for (const [domainKey, domainValue] of entries) {
if (!locales.includes(domainKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The locale \`${domainKey}\` key in the \`i18n.domains\` record doesn't exist in the \`i18n.locales\` array.`,
path: ['i18n', 'domains'],
});
}
if (!domainValue.startsWith('https') && !domainValue.startsWith('http')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"The domain value must be a valid URL, and it has to start with 'https' or 'http'.",
path: ['i18n', 'domains'],
});
} else {
try {
const domainUrl = new URL(domainValue);
if (domainUrl.pathname !== '/') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The URL \`${domainValue}\` must contain only the origin. A subsequent pathname isn't allowed here. Remove \`${domainUrl.pathname}\`.`,
path: ['i18n', 'domains'],
});
}
} catch {
// no need to catch the error
}
}
}
}
}
if (
!config.experimental.responsiveImages &&
(config.image.experimentalLayout ||
config.image.experimentalObjectFit ||
config.image.experimentalObjectPosition ||
config.image.experimentalBreakpoints)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'The `experimentalLayout`, `experimentalObjectFit`, `experimentalObjectPosition` and `experimentalBreakpoints` options are only available when `experimental.responsiveImages` is enabled.',
path: ['experimental', 'responsiveImages'],
});
}
});

View file

@ -0,0 +1,152 @@
import type { OutgoingHttpHeaders } from 'node:http';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { z } from 'zod';
import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } from '../../path.js';
import { ASTRO_CONFIG_DEFAULTS, AstroConfigSchema } from './base.js';
function resolveDirAsUrl(dir: string, root: string) {
let resolvedDir = path.resolve(root, dir);
if (!resolvedDir.endsWith(path.sep)) {
resolvedDir += path.sep;
}
return pathToFileURL(resolvedDir);
}
export function createRelativeSchema(cmd: string, fileProtocolRoot: string) {
let originalBuildClient: string;
let originalBuildServer: string;
// We need to extend the global schema to add transforms that are relative to root.
// This is type checked against the global schema to make sure we still match.
const AstroConfigRelativeSchema = AstroConfigSchema.extend({
root: z
.string()
.default(ASTRO_CONFIG_DEFAULTS.root)
.transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
srcDir: z
.string()
.default(ASTRO_CONFIG_DEFAULTS.srcDir)
.transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
compressHTML: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.compressHTML),
publicDir: z
.string()
.default(ASTRO_CONFIG_DEFAULTS.publicDir)
.transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
outDir: z
.string()
.default(ASTRO_CONFIG_DEFAULTS.outDir)
.transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
cacheDir: z
.string()
.default(ASTRO_CONFIG_DEFAULTS.cacheDir)
.transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
build: z
.object({
format: z
.union([z.literal('file'), z.literal('directory'), z.literal('preserve')])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.format),
// NOTE: `client` and `server` are transformed relative to the default outDir first,
// later we'll fix this to be relative to the actual `outDir`
client: z
.string()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.client)
.transform((val) => {
originalBuildClient = val;
return resolveDirAsUrl(
val,
path.resolve(fileProtocolRoot, ASTRO_CONFIG_DEFAULTS.outDir),
);
}),
server: z
.string()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.server)
.transform((val) => {
originalBuildServer = val;
return resolveDirAsUrl(
val,
path.resolve(fileProtocolRoot, ASTRO_CONFIG_DEFAULTS.outDir),
);
}),
assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets),
assetsPrefix: z
.string()
.optional()
.or(z.object({ fallback: z.string() }).and(z.record(z.string())).optional()),
serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects),
inlineStylesheets: z
.enum(['always', 'auto', 'never'])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets),
concurrency: z.number().min(1).optional().default(ASTRO_CONFIG_DEFAULTS.build.concurrency),
})
.optional()
.default({}),
server: z.preprocess(
// preprocess
(val) => {
if (typeof val === 'function') {
return val({ command: cmd === 'dev' ? 'dev' : 'preview' });
} else {
return val;
}
},
// validate
z
.object({
open: z
.union([z.string(), z.boolean()])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.server.open),
host: z
.union([z.string(), z.boolean()])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.server.host),
port: z.number().optional().default(ASTRO_CONFIG_DEFAULTS.server.port),
headers: z.custom<OutgoingHttpHeaders>().optional(),
streaming: z.boolean().optional().default(true),
allowedHosts: z
.union([z.array(z.string()), z.literal(true)])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.server.allowedHosts),
})
.optional()
.default({}),
),
}).transform((config) => {
// If the user changed `outDir`, we need to also update `build.client` and `build.server`
// the be based on the correct `outDir`
if (
config.outDir.toString() !==
resolveDirAsUrl(ASTRO_CONFIG_DEFAULTS.outDir, fileProtocolRoot).toString()
) {
const outDirPath = fileURLToPath(config.outDir);
config.build.client = resolveDirAsUrl(originalBuildClient, outDirPath);
config.build.server = resolveDirAsUrl(originalBuildServer, outDirPath);
}
// Handle `base` and `image.endpoint.route` trailing slash based on `trailingSlash` config
if (config.trailingSlash === 'never') {
config.base = prependForwardSlash(removeTrailingForwardSlash(config.base));
config.image.endpoint.route = prependForwardSlash(
removeTrailingForwardSlash(config.image.endpoint.route),
);
} else if (config.trailingSlash === 'always') {
config.base = prependForwardSlash(appendForwardSlash(config.base));
config.image.endpoint.route = prependForwardSlash(
appendForwardSlash(config.image.endpoint.route),
);
} else {
config.base = prependForwardSlash(config.base);
config.image.endpoint.route = prependForwardSlash(config.image.endpoint.route);
}
return config;
});
return AstroConfigRelativeSchema;
}

View file

@ -1,6 +1,6 @@
import type { AstroConfig } from '../../types/public/config.js';
import { errorMap } from '../errors/index.js';
import { createRelativeSchema } from './schema.js';
import { AstroConfigRefinedSchema, createRelativeSchema } from './schemas/index.js';
/** Turn raw config values into normalized values */
export async function validateConfig(
@ -11,5 +11,16 @@ export async function validateConfig(
const AstroConfigRelativeSchema = createRelativeSchema(cmd, root);
// First-Pass Validation
return await AstroConfigRelativeSchema.parseAsync(userConfig, { errorMap });
return await validateConfigRefined(await AstroConfigRelativeSchema.parseAsync(userConfig, { errorMap }));
}
/**
* Used twice:
* - To validate the user config
* - To validate the config after all integrations (that may have updated it)
*/
export async function validateConfigRefined(
updatedConfig: AstroConfig,
): Promise<AstroConfig> {
return await AstroConfigRefinedSchema.parseAsync(updatedConfig, { errorMap });
}

View file

@ -1,4 +1,4 @@
import { AstroConfigSchema } from '../core/config/schema.js';
import { AstroConfigSchema } from '../core/config/schemas/index.js';
import type { AstroUserConfig } from '../types/public/config.js';
import type { AstroIntegration } from '../types/public/integrations.js';

View file

@ -34,19 +34,20 @@ import type {
} from '../types/public/integrations.js';
import type { RouteData } from '../types/public/internal.js';
import { validateSupportedFeatures } from './features-validation.js';
import { validateConfigRefined } from '../core/config/validate.js';
async function withTakingALongTimeMsg<T>({
name,
hookName,
hookResult,
timeoutMs = 3000,
hookFn,
logger,
integrationLogger,
}: {
name: string;
hookName: keyof BaseIntegrationHooks;
hookResult: T | Promise<T>;
timeoutMs?: number;
hookFn: () => T | Promise<T>;
logger: Logger;
integrationLogger: AstroIntegrationLogger;
}): Promise<T> {
const timeout = setTimeout(() => {
logger.info(
@ -55,10 +56,43 @@ async function withTakingALongTimeMsg<T>({
JSON.stringify(hookName),
)}...`,
);
}, timeoutMs);
const result = await hookResult;
clearTimeout(timeout);
return result;
}, 3000);
try {
return await hookFn();
} catch (err) {
integrationLogger.error(
`An unhandled error occurred while running the ${bold(JSON.stringify(hookName))} hook`,
);
throw err;
} finally {
clearTimeout(timeout);
}
}
/** Executes the specified hook of the integration if present, and handles loggers */
async function runHookInternal<THook extends keyof BaseIntegrationHooks>({
integration,
hookName,
logger,
params,
}: {
integration: AstroIntegration;
hookName: THook;
logger: Logger;
params: () => Omit<HookParameters<NoInfer<THook>>, 'logger'>;
}) {
const hook = integration?.hooks?.[hookName];
const integrationLogger = getLogger(integration, logger);
if (hook) {
await withTakingALongTimeMsg({
name: integration.name,
hookName,
hookFn: () => hook(Object.assign(params(), { logger: integrationLogger }) as any),
logger,
integrationLogger,
});
}
return { integrationLogger };
}
// Used internally to store instances of loggers.
@ -171,133 +205,140 @@ export async function runHookConfigSetup({
* ]
* ```
*/
if (integration.hooks?.['astro:config:setup']) {
const integrationLogger = getLogger(integration, logger);
const hooks: HookParameters<'astro:config:setup'> = {
config: updatedConfig,
command,
isRestart,
addRenderer(renderer: AstroRenderer) {
if (!renderer.name) {
throw new Error(`Integration ${bold(integration.name)} has an unnamed renderer.`);
}
const { integrationLogger } = await runHookInternal({
integration,
hookName: 'astro:config:setup',
logger,
params: () => {
const hooks: Omit<HookParameters<'astro:config:setup'>, 'logger'> = {
config: updatedConfig,
command,
isRestart,
addRenderer(renderer: AstroRenderer) {
if (!renderer.name) {
throw new Error(`Integration ${bold(integration.name)} has an unnamed renderer.`);
}
if (!renderer.serverEntrypoint) {
throw new Error(`Renderer ${bold(renderer.name)} does not provide a serverEntrypoint.`);
}
if (!renderer.serverEntrypoint) {
throw new Error(
`Renderer ${bold(renderer.name)} does not provide a serverEntrypoint.`,
);
}
if (renderer.name === 'astro:jsx') {
astroJSXRenderer = renderer;
} else {
updatedSettings.renderers.push(renderer);
}
},
injectScript: (stage, content) => {
updatedSettings.scripts.push({ stage, content });
},
updateConfig: (newConfig) => {
updatedConfig = mergeConfig(updatedConfig, newConfig);
return { ...updatedConfig };
},
injectRoute: (injectRoute) => {
if (injectRoute.entrypoint == null && 'entryPoint' in injectRoute) {
logger.warn(
null,
`The injected route "${injectRoute.pattern}" by ${integration.name} specifies the entry point with the "entryPoint" property. This property is deprecated, please use "entrypoint" instead.`,
if (renderer.name === 'astro:jsx') {
astroJSXRenderer = renderer;
} else {
updatedSettings.renderers.push(renderer);
}
},
injectScript: (stage, content) => {
updatedSettings.scripts.push({ stage, content });
},
updateConfig: (newConfig) => {
updatedConfig = mergeConfig(updatedConfig, newConfig);
return { ...updatedConfig };
},
injectRoute: (injectRoute) => {
if (injectRoute.entrypoint == null && 'entryPoint' in injectRoute) {
logger.warn(
null,
`The injected route "${injectRoute.pattern}" by ${integration.name} specifies the entry point with the "entryPoint" property. This property is deprecated, please use "entrypoint" instead.`,
);
injectRoute.entrypoint = injectRoute.entryPoint as string;
}
updatedSettings.injectedRoutes.push({ ...injectRoute, origin: 'external' });
},
addWatchFile: (path) => {
updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path);
},
addDevToolbarApp: (entrypoint) => {
updatedSettings.devToolbarApps.push(entrypoint);
},
addClientDirective: ({ name, entrypoint }) => {
if (updatedSettings.clientDirectives.has(name) || addedClientDirectives.has(name)) {
throw new Error(
`The "${integration.name}" integration is trying to add the "${name}" client directive, but it already exists.`,
);
}
// TODO: this should be performed after astro:config:done
addedClientDirectives.set(
name,
buildClientDirectiveEntrypoint(name, entrypoint, settings.config.root),
);
injectRoute.entrypoint = injectRoute.entryPoint as string;
}
updatedSettings.injectedRoutes.push({ ...injectRoute, origin: 'external' });
},
addWatchFile: (path) => {
updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path);
},
addDevToolbarApp: (entrypoint) => {
updatedSettings.devToolbarApps.push(entrypoint);
},
addClientDirective: ({ name, entrypoint }) => {
if (updatedSettings.clientDirectives.has(name) || addedClientDirectives.has(name)) {
throw new Error(
`The "${integration.name}" integration is trying to add the "${name}" client directive, but it already exists.`,
},
addMiddleware: ({ order, entrypoint }) => {
if (typeof updatedSettings.middlewares[order] === 'undefined') {
throw new Error(
`The "${integration.name}" integration is trying to add middleware but did not specify an order.`,
);
}
logger.debug(
'middleware',
`The integration ${integration.name} has added middleware that runs ${
order === 'pre' ? 'before' : 'after'
} any application middleware you define.`,
);
}
// TODO: this should be performed after astro:config:done
addedClientDirectives.set(
name,
buildClientDirectiveEntrypoint(name, entrypoint, settings.config.root),
);
},
addMiddleware: ({ order, entrypoint }) => {
if (typeof updatedSettings.middlewares[order] === 'undefined') {
throw new Error(
`The "${integration.name}" integration is trying to add middleware but did not specify an order.`,
updatedSettings.middlewares[order].push(
typeof entrypoint === 'string' ? entrypoint : fileURLToPath(entrypoint),
);
}
logger.debug(
'middleware',
`The integration ${integration.name} has added middleware that runs ${
order === 'pre' ? 'before' : 'after'
} any application middleware you define.`,
},
createCodegenDir: () => {
const codegenDir = new URL(normalizeCodegenDir(integration.name), settings.dotAstroDir);
fs.mkdirSync(codegenDir, { recursive: true });
return codegenDir;
},
};
// Public, intentionally undocumented hooks - not subject to semver.
// Intended for internal integrations (ex. `@astrojs/mdx`),
// though accessible to integration authors if discovered.
function addPageExtension(...input: (string | string[])[]) {
const exts = (input.flat(Infinity) as string[]).map(
(ext) => `.${ext.replace(/^\./, '')}`,
);
updatedSettings.middlewares[order].push(
typeof entrypoint === 'string' ? entrypoint : fileURLToPath(entrypoint),
);
},
createCodegenDir: () => {
const codegenDir = new URL(normalizeCodegenDir(integration.name), settings.dotAstroDir);
fs.mkdirSync(codegenDir, { recursive: true });
return codegenDir;
},
logger: integrationLogger,
};
updatedSettings.pageExtensions.push(...exts);
}
// ---
// Public, intentionally undocumented hooks - not subject to semver.
// Intended for internal integrations (ex. `@astrojs/mdx`),
// though accessible to integration authors if discovered.
function addContentEntryType(contentEntryType: ContentEntryType) {
updatedSettings.contentEntryTypes.push(contentEntryType);
}
function addPageExtension(...input: (string | string[])[]) {
const exts = (input.flat(Infinity) as string[]).map((ext) => `.${ext.replace(/^\./, '')}`);
updatedSettings.pageExtensions.push(...exts);
}
function addDataEntryType(dataEntryType: DataEntryType) {
updatedSettings.dataEntryTypes.push(dataEntryType);
}
function addContentEntryType(contentEntryType: ContentEntryType) {
updatedSettings.contentEntryTypes.push(contentEntryType);
}
Object.defineProperty(hooks, 'addPageExtension', {
value: addPageExtension,
writable: false,
enumerable: false,
});
Object.defineProperty(hooks, 'addContentEntryType', {
value: addContentEntryType,
writable: false,
enumerable: false,
});
Object.defineProperty(hooks, 'addDataEntryType', {
value: addDataEntryType,
writable: false,
enumerable: false,
});
function addDataEntryType(dataEntryType: DataEntryType) {
updatedSettings.dataEntryTypes.push(dataEntryType);
}
return hooks;
},
});
Object.defineProperty(hooks, 'addPageExtension', {
value: addPageExtension,
writable: false,
enumerable: false,
});
Object.defineProperty(hooks, 'addContentEntryType', {
value: addContentEntryType,
writable: false,
enumerable: false,
});
Object.defineProperty(hooks, 'addDataEntryType', {
value: addDataEntryType,
writable: false,
enumerable: false,
});
// ---
// Add custom client directives to settings, waiting for compiled code by esbuild
for (const [name, compiled] of addedClientDirectives) {
updatedSettings.clientDirectives.set(name, await compiled);
}
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:config:setup',
hookResult: integration.hooks['astro:config:setup'](hooks),
logger,
});
// Add custom client directives to settings, waiting for compiled code by esbuild
for (const [name, compiled] of addedClientDirectives) {
updatedSettings.clientDirectives.set(name, await compiled);
}
try {
updatedConfig = await validateConfigRefined(updatedConfig);
} catch (error) {
integrationLogger.error('An error occurred while updating the config');
throw error;
}
}
@ -320,56 +361,53 @@ export async function runHookConfigDone({
command?: 'dev' | 'build' | 'preview' | 'sync';
}) {
for (const integration of settings.config.integrations) {
if (integration?.hooks?.['astro:config:done']) {
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:config:done',
hookResult: integration.hooks['astro:config:done']({
config: settings.config,
setAdapter(adapter) {
validateSetAdapter(logger, settings, adapter, integration.name, command);
await runHookInternal({
integration,
hookName: 'astro:config:done',
logger,
params: () => ({
config: settings.config,
setAdapter(adapter) {
validateSetAdapter(logger, settings, adapter, integration.name, command);
if (adapter.adapterFeatures?.buildOutput !== 'static') {
settings.buildOutput = 'server';
}
if (adapter.adapterFeatures?.buildOutput !== 'static') {
settings.buildOutput = 'server';
}
if (!adapter.supportedAstroFeatures) {
throw new Error(
`The adapter ${adapter.name} doesn't provide a feature map. It is required in Astro 4.0.`,
);
} else {
validateSupportedFeatures(
adapter.name,
adapter.supportedAstroFeatures,
settings,
logger,
);
}
settings.adapter = adapter;
},
injectTypes(injectedType) {
const normalizedFilename = normalizeInjectedTypeFilename(
injectedType.filename,
integration.name,
if (!adapter.supportedAstroFeatures) {
throw new Error(
`The adapter ${adapter.name} doesn't provide a feature map. It is required in Astro 4.0.`,
);
} else {
validateSupportedFeatures(
adapter.name,
adapter.supportedAstroFeatures,
settings,
logger,
);
}
settings.adapter = adapter;
},
injectTypes(injectedType) {
const normalizedFilename = normalizeInjectedTypeFilename(
injectedType.filename,
integration.name,
);
settings.injectedTypes.push({
filename: normalizedFilename,
content: injectedType.content,
});
settings.injectedTypes.push({
filename: normalizedFilename,
content: injectedType.content,
});
// It must be relative to dotAstroDir here and not inside normalizeInjectedTypeFilename
// because injectedTypes are handled relatively to the dotAstroDir already
return new URL(normalizedFilename, settings.dotAstroDir);
},
logger: getLogger(integration, logger),
get buildOutput() {
return settings.buildOutput!; // settings.buildOutput is always set at this point
},
}),
logger,
});
}
// It must be relative to dotAstroDir here and not inside normalizeInjectedTypeFilename
// because injectedTypes are handled relatively to the dotAstroDir already
return new URL(normalizedFilename, settings.dotAstroDir);
},
get buildOutput() {
return settings.buildOutput!; // settings.buildOutput is always set at this point
},
}),
});
}
// Session config is validated after all integrations have had a chance to
// register a default session driver, and we know the output type.
@ -404,19 +442,16 @@ export async function runHookServerSetup({
};
for (const integration of config.integrations) {
if (integration?.hooks?.['astro:server:setup']) {
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:server:setup',
hookResult: integration.hooks['astro:server:setup']({
server,
logger: getLogger(integration, logger),
toolbar: getToolbarServerCommunicationHelpers(server),
refreshContent,
}),
logger,
});
}
await runHookInternal({
integration,
hookName: 'astro:server:setup',
logger,
params: () => ({
server,
toolbar: getToolbarServerCommunicationHelpers(server),
refreshContent,
}),
});
}
}
@ -430,17 +465,12 @@ export async function runHookServerStart({
logger: Logger;
}) {
for (const integration of config.integrations) {
if (integration?.hooks?.['astro:server:start']) {
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:server:start',
hookResult: integration.hooks['astro:server:start']({
address,
logger: getLogger(integration, logger),
}),
logger,
});
}
await runHookInternal({
integration,
hookName: 'astro:server:start',
logger,
params: () => ({ address }),
});
}
}
@ -452,37 +482,29 @@ export async function runHookServerDone({
logger: Logger;
}) {
for (const integration of config.integrations) {
if (integration?.hooks?.['astro:server:done']) {
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:server:done',
hookResult: integration.hooks['astro:server:done']({
logger: getLogger(integration, logger),
}),
logger,
});
}
await runHookInternal({
integration,
hookName: 'astro:server:done',
logger,
params: () => ({}),
});
}
}
export async function runHookBuildStart({
config,
logging,
logger,
}: {
config: AstroConfig;
logging: Logger;
logger: Logger;
}) {
for (const integration of config.integrations) {
if (integration?.hooks?.['astro:build:start']) {
const logger = getLogger(integration, logging);
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:build:start',
hookResult: integration.hooks['astro:build:start']({ logger }),
logger: logging,
});
}
await runHookInternal({
integration,
hookName: 'astro:build:start',
logger,
params: () => ({}),
});
}
}
@ -502,23 +524,20 @@ export async function runHookBuildSetup({
let updatedConfig = vite;
for (const integration of config.integrations) {
if (integration?.hooks?.['astro:build:setup']) {
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:build:setup',
hookResult: integration.hooks['astro:build:setup']({
vite,
pages,
target,
updateConfig: (newConfig) => {
updatedConfig = mergeViteConfig(updatedConfig, newConfig);
return { ...updatedConfig };
},
logger: getLogger(integration, logger),
}),
logger,
});
}
await runHookInternal({
integration,
hookName: 'astro:build:setup',
logger,
params: () => ({
vite,
pages,
target,
updateConfig: (newConfig) => {
updatedConfig = mergeViteConfig(updatedConfig, newConfig);
return { ...updatedConfig };
},
}),
});
}
return updatedConfig;
@ -544,19 +563,16 @@ export async function runHookBuildSsr({
entryPointsMap.set(toIntegrationRouteData(key), value);
}
for (const integration of config.integrations) {
if (integration?.hooks?.['astro:build:ssr']) {
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:build:ssr',
hookResult: integration.hooks['astro:build:ssr']({
manifest,
entryPoints: entryPointsMap,
middlewareEntryPoint,
logger: getLogger(integration, logger),
}),
logger,
});
}
await runHookInternal({
integration,
hookName: 'astro:build:ssr',
logger,
params: () => ({
manifest,
entryPoints: entryPointsMap,
middlewareEntryPoint,
}),
});
}
}
@ -571,17 +587,12 @@ export async function runHookBuildGenerated({
settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir;
for (const integration of settings.config.integrations) {
if (integration?.hooks?.['astro:build:generated']) {
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:build:generated',
hookResult: integration.hooks['astro:build:generated']({
dir,
logger: getLogger(integration, logger),
}),
logger,
});
}
await runHookInternal({
integration,
hookName: 'astro:build:generated',
logger,
params: () => ({ dir }),
});
}
}
@ -589,33 +600,29 @@ type RunHookBuildDone = {
settings: AstroSettings;
pages: string[];
routes: RouteData[];
logging: Logger;
logger: Logger;
};
export async function runHookBuildDone({ settings, pages, routes, logging }: RunHookBuildDone) {
export async function runHookBuildDone({ settings, pages, routes, logger }: RunHookBuildDone) {
const dir =
settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir;
await fsMod.promises.mkdir(dir, { recursive: true });
const integrationRoutes = routes.map(toIntegrationRouteData);
for (const integration of settings.config.integrations) {
if (integration?.hooks?.['astro:build:done']) {
const logger = getLogger(integration, logging);
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:build:done',
hookResult: integration.hooks['astro:build:done']({
pages: pages.map((p) => ({ pathname: p })),
dir,
routes: integrationRoutes,
assets: new Map(
routes.filter((r) => r.distURL !== undefined).map((r) => [r.route, r.distURL!]),
),
logger,
}),
logger: logging,
});
}
for (const integration of settings.config.integrations) {
await runHookInternal({
integration,
hookName: 'astro:build:done',
logger,
params: () => ({
pages: pages.map((p) => ({ pathname: p })),
dir,
routes: integrationRoutes,
assets: new Map(
routes.filter((r) => r.distURL !== undefined).map((r) => [r.route, r.distURL!]),
),
}),
});
}
}
@ -631,23 +638,15 @@ export async function runHookRouteSetup({
const prerenderChangeLogs: { integrationName: string; value: boolean | undefined }[] = [];
for (const integration of settings.config.integrations) {
if (integration?.hooks?.['astro:route:setup']) {
const originalRoute = { ...route };
const integrationLogger = getLogger(integration, logger);
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:route:setup',
hookResult: integration.hooks['astro:route:setup']({
route,
logger: integrationLogger,
}),
logger,
});
if (route.prerender !== originalRoute.prerender) {
prerenderChangeLogs.push({ integrationName: integration.name, value: route.prerender });
}
const originalRoute = { ...route };
await runHookInternal({
integration,
hookName: 'astro:route:setup',
logger,
params: () => ({ route }),
});
if (route.prerender !== originalRoute.prerender) {
prerenderChangeLogs.push({ integrationName: integration.name, value: route.prerender });
}
}
@ -666,19 +665,14 @@ export async function runHookRoutesResolved({
logger,
}: { routes: Array<RouteData>; settings: AstroSettings; logger: Logger }) {
for (const integration of settings.config.integrations) {
if (integration?.hooks?.['astro:routes:resolved']) {
const integrationLogger = getLogger(integration, logger);
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:routes:resolved',
hookResult: integration.hooks['astro:routes:resolved']({
routes: routes.map((route) => toIntegrationResolvedRoute(route)),
logger: integrationLogger,
}),
logger,
});
}
await runHookInternal({
integration,
hookName: 'astro:routes:resolved',
logger,
params: () => ({
routes: routes.map((route) => toIntegrationResolvedRoute(route)),
}),
});
}
}

View file

@ -12,7 +12,7 @@ import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions
import type { ImageFit, ImageLayout } from '../../assets/types.js';
import type { SvgRenderMode } from '../../assets/utils/svg.js';
import type { AssetsPrefix } from '../../core/app/types.js';
import type { AstroConfigType } from '../../core/config/schema.js';
import type { AstroConfigType } from '../../core/config/schemas/index.js';
import type { REDIRECT_STATUS_CODES } from '../../core/constants.js';
import type { AstroCookieSetOptions } from '../../core/cookies/cookies.js';
import type { Logger, LoggerLevel } from '../../core/logger/core.js';

View file

@ -1,23 +1,32 @@
// @ts-check
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { stripVTControlCharacters } from 'node:util';
import { z } from 'zod';
import { validateConfig } from '../../../dist/core/config/validate.js';
import { validateConfig as _validateConfig } from '../../../dist/core/config/validate.js';
import { formatConfigErrorMessage } from '../../../dist/core/messages.js';
import { envField } from '../../../dist/env/config.js';
/**
*
* @param {any} userConfig
*/
async function validateConfig(userConfig) {
return _validateConfig(userConfig, process.cwd(), '');
}
describe('Config Validation', () => {
it('empty user config is valid', async () => {
assert.doesNotThrow(() => validateConfig({}, process.cwd()).catch((err) => err));
assert.doesNotThrow(() => validateConfig({}).catch((err) => err));
});
it('Zod errors are returned when invalid config is used', async () => {
const configError = await validateConfig({ site: 42 }, process.cwd()).catch((err) => err);
const configError = await validateConfig({ site: 42 }).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
});
it('A validation error can be formatted correctly', async () => {
const configError = await validateConfig({ site: 42 }, process.cwd()).catch((err) => err);
const configError = await validateConfig({ site: 42 }).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
const formattedError = stripVTControlCharacters(formatConfigErrorMessage(configError));
assert.equal(
@ -33,7 +42,7 @@ describe('Config Validation', () => {
integrations: [42],
build: { format: 'invalid' },
};
const configError = await validateConfig(veryBadConfig, process.cwd()).catch((err) => err);
const configError = await validateConfig(veryBadConfig).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
const formattedError = stripVTControlCharacters(formatConfigErrorMessage(configError));
assert.equal(
@ -48,21 +57,17 @@ describe('Config Validation', () => {
});
it('ignores falsey "integration" values', async () => {
const result = await validateConfig(
{ integrations: [0, false, null, undefined] },
process.cwd(),
);
const result = await validateConfig({ integrations: [0, false, null, undefined] });
assert.deepEqual(result.integrations, []);
});
it('normalizes "integration" values', async () => {
const result = await validateConfig({ integrations: [{ name: '@astrojs/a' }] }, process.cwd());
const result = await validateConfig({ integrations: [{ name: '@astrojs/a' }] });
assert.deepEqual(result.integrations, [{ name: '@astrojs/a', hooks: {} }]);
});
it('flattens array "integration" values', async () => {
const result = await validateConfig(
{ integrations: [{ name: '@astrojs/a' }, [{ name: '@astrojs/b' }, { name: '@astrojs/c' }]] },
process.cwd(),
);
const result = await validateConfig({
integrations: [{ name: '@astrojs/a' }, [{ name: '@astrojs/b' }, { name: '@astrojs/c' }]],
});
assert.deepEqual(result.integrations, [
{ name: '@astrojs/a', hooks: {} },
{ name: '@astrojs/b', hooks: {} },
@ -70,16 +75,13 @@ describe('Config Validation', () => {
]);
});
it('ignores null or falsy "integration" values', async () => {
const configError = await validateConfig(
{ integrations: [null, undefined, false, '', ``] },
process.cwd(),
).catch((err) => err);
const configError = await validateConfig({
integrations: [null, undefined, false, '', ``],
}).catch((err) => err);
assert.equal(configError instanceof Error, false);
});
it('Error when outDir is placed within publicDir', async () => {
const configError = await validateConfig({ outDir: './public/dist' }, process.cwd()).catch(
(err) => err,
);
const configError = await validateConfig({ outDir: './public/dist' }).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -89,15 +91,12 @@ describe('Config Validation', () => {
describe('i18n', async () => {
it('defaultLocale is not in locales', async () => {
const configError = await validateConfig(
{
i18n: {
defaultLocale: 'en',
locales: ['es'],
},
const configError = await validateConfig({
i18n: {
defaultLocale: 'en',
locales: ['es'],
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -106,21 +105,18 @@ describe('Config Validation', () => {
});
it('errors if codes are empty', async () => {
const configError = await validateConfig(
{
i18n: {
defaultLocale: 'uk',
locales: [
'es',
{
path: 'something',
codes: [],
},
],
},
const configError = await validateConfig({
i18n: {
defaultLocale: 'uk',
locales: [
'es',
{
path: 'something',
codes: [],
},
],
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -129,21 +125,18 @@ describe('Config Validation', () => {
});
it('errors if the default locale is not in path', async () => {
const configError = await validateConfig(
{
i18n: {
defaultLocale: 'uk',
locales: [
'es',
{
path: 'something',
codes: ['en-UK'],
},
],
},
const configError = await validateConfig({
i18n: {
defaultLocale: 'uk',
locales: [
'es',
{
path: 'something',
codes: ['en-UK'],
},
],
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -152,18 +145,15 @@ describe('Config Validation', () => {
});
it('errors if a fallback value does not exist', async () => {
const configError = await validateConfig(
{
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
fallback: {
es: 'it',
},
const configError = await validateConfig({
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
fallback: {
es: 'it',
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -172,18 +162,15 @@ describe('Config Validation', () => {
});
it('errors if a fallback key does not exist', async () => {
const configError = await validateConfig(
{
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
fallback: {
it: 'en',
},
const configError = await validateConfig({
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
fallback: {
it: 'en',
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -192,18 +179,15 @@ describe('Config Validation', () => {
});
it('errors if a fallback key contains the default locale', async () => {
const configError = await validateConfig(
{
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
fallback: {
en: 'es',
},
const configError = await validateConfig({
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
fallback: {
en: 'es',
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -212,40 +196,35 @@ describe('Config Validation', () => {
});
it('errors if `i18n.prefixDefaultLocale` is `false` and `i18n.redirectToDefaultLocale` is `true`', async () => {
const configError = await validateConfig(
{
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
routing: {
prefixDefaultLocale: false,
redirectToDefaultLocale: false,
},
const configError = await validateConfig({
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
routing: {
prefixDefaultLocale: false,
redirectToDefaultLocale: false,
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
'The option `i18n.redirectToDefaultLocale` is only useful when the `i18n.prefixDefaultLocale` is set to `true`. Remove the option `i18n.redirectToDefaultLocale`, or change its value to `true`.',
'The option `i18n.routing.redirectToDefaultLocale` is only useful when the `i18n.routing.prefixDefaultLocale` is set to `true`. Remove the option `i18n.routing.redirectToDefaultLocale`, or change its value to `true`.',
);
});
it('errors if a domains key does not exist', async () => {
const configError = await validateConfig(
{
output: 'server',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
lorem: 'https://example.com',
},
const configError = await validateConfig({
output: 'server',
site: 'https://www.example.com',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
lorem: 'https://example.com',
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -254,19 +233,17 @@ describe('Config Validation', () => {
});
it('errors if a domains value is not an URL', async () => {
const configError = await validateConfig(
{
output: 'server',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
en: 'www.example.com',
},
const configError = await validateConfig({
output: 'server',
site: 'https://www.example.com',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
en: 'www.example.com',
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -275,19 +252,17 @@ describe('Config Validation', () => {
});
it('errors if a domains value is not an URL with incorrect protocol', async () => {
const configError = await validateConfig(
{
output: 'server',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
en: 'tcp://www.example.com',
},
const configError = await validateConfig({
output: 'server',
site: 'https://www.example.com',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
en: 'tcp://www.example.com',
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -296,19 +271,17 @@ describe('Config Validation', () => {
});
it('errors if a domain is a URL with a pathname that is not the home', async () => {
const configError = await validateConfig(
{
output: 'server',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
en: 'https://www.example.com/blog/page/',
},
const configError = await validateConfig({
output: 'server',
site: 'https://www.example.com',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
en: 'https://www.example.com/blog/page/',
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -317,19 +290,16 @@ describe('Config Validation', () => {
});
it('errors if domains is enabled but site is not provided', async () => {
const configError = await validateConfig(
{
output: 'server',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
en: 'https://www.example.com/',
},
const configError = await validateConfig({
output: 'server',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
en: 'https://www.example.com/',
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -338,20 +308,17 @@ describe('Config Validation', () => {
});
it('errors if domains is enabled but the `output` is not "server"', async () => {
const configError = await validateConfig(
{
output: 'static',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
en: 'https://www.example.com/',
},
const configError = await validateConfig({
output: 'static',
i18n: {
defaultLocale: 'en',
locales: ['es', 'en'],
domains: {
en: 'https://www.example.com/',
},
site: 'https://foo.org',
},
process.cwd(),
).catch((err) => err);
site: 'https://foo.org',
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -363,43 +330,34 @@ describe('Config Validation', () => {
describe('env', () => {
it('Should allow not providing a schema', () => {
assert.doesNotThrow(() =>
validateConfig(
{
env: {
schema: undefined,
},
validateConfig({
env: {
schema: undefined,
},
process.cwd(),
).catch((err) => err),
}),
);
});
it('Should allow schema variables with numbers', () => {
assert.doesNotThrow(() =>
validateConfig(
{
env: {
schema: {
ABC123: envField.string({ access: 'public', context: 'server' }),
},
validateConfig({
env: {
schema: {
ABC123: envField.string({ access: 'public', context: 'server' }),
},
},
process.cwd(),
).catch((err) => err),
}),
);
});
it('Should not allow schema variables starting with a number', async () => {
const configError = await validateConfig(
{
env: {
schema: {
'123ABC': envField.string({ access: 'public', context: 'server' }),
},
const configError = await validateConfig({
env: {
schema: {
'123ABC': envField.string({ access: 'public', context: 'server' }),
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message,
@ -408,16 +366,14 @@ describe('Config Validation', () => {
});
it('Should provide a useful error for access/context invalid combinations', async () => {
const configError = await validateConfig(
{
env: {
schema: {
BAR: envField.string({ access: 'secret', context: 'client' }),
},
const configError = await validateConfig({
env: {
schema: {
// @ts-expect-error we test an invalid combination
BAR: envField.string({ access: 'secret', context: 'client' }),
},
},
process.cwd(),
).catch((err) => err);
}).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.equal(
configError.errors[0].message.includes(

View file

@ -12,6 +12,13 @@ import { createFixture, defaultLogger, runInContainer } from '../test-utils.js';
const defaultConfig = {
root: new URL('./', import.meta.url),
srcDir: new URL('src/', import.meta.url),
build: {},
image: {
remotePatterns: [],
},
outDir: new URL('./dist/', import.meta.url),
publicDir: new URL('./public/', import.meta.url),
experimental: {},
};
const dotAstroDir = new URL('./.astro/', defaultConfig.root);