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:
parent
4db2c6804e
commit
ff257df4e1
16 changed files with 856 additions and 840 deletions
9
.changeset/easy-vans-laugh.md
Normal file
9
.changeset/easy-vans-laugh.md
Normal 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.
|
7
.changeset/three-masks-see.md
Normal file
7
.changeset/three-masks-see.md
Normal 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
|
|
@ -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';
|
||||
|
|
|
@ -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']) {
|
||||
|
|
|
@ -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';
|
||||
|
|
7
packages/astro/src/core/config/schemas/README.md
Normal file
7
packages/astro/src/core/config/schemas/README.md
Normal 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)`.
|
|
@ -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);
|
||||
}
|
3
packages/astro/src/core/config/schemas/index.ts
Normal file
3
packages/astro/src/core/config/schemas/index.ts
Normal 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';
|
187
packages/astro/src/core/config/schemas/refined.ts
Normal file
187
packages/astro/src/core/config/schemas/refined.ts
Normal 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'],
|
||||
});
|
||||
}
|
||||
});
|
152
packages/astro/src/core/config/schemas/relative.ts
Normal file
152
packages/astro/src/core/config/schemas/relative.ts
Normal 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;
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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)),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue