diff --git a/packages/astro/src/core/config/schemas/refined-validators.ts b/packages/astro/src/core/config/schemas/refined-validators.ts new file mode 100644 index 000000000000..9a9b9f30ea28 --- /dev/null +++ b/packages/astro/src/core/config/schemas/refined-validators.ts @@ -0,0 +1,251 @@ +import type { AstroConfig } from '../../../types/public/config.js'; + +export interface ConfigValidationIssue { + message: string; + path: (string | number)[]; +} + +type I18nConfig = NonNullable; + +/** + * Validates that `build.assetsPrefix`, when specified as an object, includes a `fallback` key. + */ +export function validateAssetsPrefix(config: Pick): ConfigValidationIssue[] { + if ( + config.build.assetsPrefix && + typeof config.build.assetsPrefix !== 'string' && + !config.build.assetsPrefix.fallback + ) { + return [ + { + message: 'The `fallback` is mandatory when defining the option as an object.', + path: ['build', 'assetsPrefix'], + }, + ]; + } + return []; +} + +/** + * Validates that remote pattern wildcards are only at the start of hostnames + * and at the end of pathnames. + */ +export function validateRemotePatterns( + remotePatterns: AstroConfig['image']['remotePatterns'], +): ConfigValidationIssue[] { + const issues: ConfigValidationIssue[] = []; + for (let i = 0; i < remotePatterns.length; i++) { + const { hostname, pathname } = remotePatterns[i]; + + if ( + hostname && + hostname.includes('*') && + !(hostname.startsWith('*.') || hostname.startsWith('**.')) + ) { + issues.push({ + 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('/**')) + ) { + issues.push({ + message: 'wildcards can only be placed at the end of a pathname', + path: ['image', 'remotePatterns', i, 'pathname'], + }); + } + } + return issues; +} + +/** + * Validates that `redirectToDefaultLocale` is not `true` when + * `prefixDefaultLocale` is `false`, which would cause infinite redirects. + */ +export function validateI18nRedirectToDefaultLocale( + i18n: AstroConfig['i18n'], +): ConfigValidationIssue[] { + if ( + i18n && + typeof i18n.routing !== 'string' && + i18n.routing.prefixDefaultLocale === false && + i18n.routing.redirectToDefaultLocale === true + ) { + return [ + { + message: + 'The option `i18n.routing.redirectToDefaultLocale` can be used only when `i18n.routing.prefixDefaultLocale` is set to `true`; otherwise, redirects might cause infinite loops. Remove the option `i18n.routing.redirectToDefaultLocale`, or change its value to `false`.', + path: ['i18n', 'routing', 'redirectToDefaultLocale'], + }, + ]; + } + return []; +} + +/** + * Validates that `outDir` is not inside `publicDir`, which would cause an infinite loop. + */ +export function validateOutDirNotInPublicDir( + outDir: AstroConfig['outDir'], + publicDir: AstroConfig['publicDir'], +): ConfigValidationIssue[] { + if (outDir.toString().startsWith(publicDir.toString())) { + return [ + { + 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'], + }, + ]; + } + return []; +} + +/** + * Validates that the default locale is present in the locales array. + */ +export function validateI18nDefaultLocale( + i18n: Pick, +): ConfigValidationIssue[] { + const locales = i18n.locales.map((locale) => (typeof locale === 'string' ? locale : locale.path)); + if (!locales.includes(i18n.defaultLocale)) { + return [ + { + message: `The default locale \`${i18n.defaultLocale}\` is not present in the \`i18n.locales\` array.`, + path: ['i18n', 'locales'], + }, + ]; + } + return []; +} + +/** + * Validates i18n fallback entries: keys and values must exist in locales, + * and the default locale cannot be used as a key. + */ +export function validateI18nFallback( + i18n: Pick, +): ConfigValidationIssue[] { + const issues: ConfigValidationIssue[] = []; + const { defaultLocale, fallback } = i18n; + if (!fallback) return []; + + const locales = i18n.locales.map((locale) => (typeof locale === 'string' ? locale : locale.path)); + + for (const [fallbackFrom, fallbackTo] of Object.entries(fallback)) { + if (!locales.includes(fallbackFrom)) { + issues.push({ + message: `The locale \`${fallbackFrom}\` key in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`, + path: ['i18n', 'fallbacks'], + }); + } + + if (fallbackFrom === defaultLocale) { + issues.push({ + 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)) { + issues.push({ + message: `The locale \`${fallbackTo}\` value in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`, + path: ['i18n', 'fallbacks'], + }); + } + } + return issues; +} + +/** + * Validates i18n domain entries: locale keys must exist, domain values must be + * valid origin URLs, site must be set, and output must be 'server'. + */ +export function validateI18nDomains( + config: Pick, +): ConfigValidationIssue[] { + const issues: ConfigValidationIssue[] = []; + const i18n = config.i18n; + if (!i18n?.domains) return []; + + const entries = Object.entries(i18n.domains); + const hasDomains = Object.keys(i18n.domains).length > 0; + + if (entries.length > 0 && !hasDomains) { + issues.push({ + message: `When specifying some domains, the property \`i18n.routing.strategy\` must be set to \`"domains"\`.`, + path: ['i18n', 'routing', 'strategy'], + }); + } + + if (hasDomains) { + if (!config.site) { + issues.push({ + 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') { + issues.push({ + message: 'Domain support is only available when `output` is `"server"`.', + path: ['output'], + }); + } + } + + const locales = i18n.locales.map((locale) => (typeof locale === 'string' ? locale : locale.path)); + + for (const [domainKey, domainValue] of entries) { + if (!locales.includes(domainKey)) { + issues.push({ + 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')) { + issues.push({ + 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 !== '/') { + issues.push({ + 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 + } + } + } + return issues; +} + +/** + * Validates that font `cssVariable` values start with `--` and don't contain + * spaces or colons. + */ +export function validateFontsCssVariables( + fonts: NonNullable, +): ConfigValidationIssue[] { + const issues: ConfigValidationIssue[] = []; + for (let i = 0; i < fonts.length; i++) { + const { cssVariable } = fonts[i]; + if (!cssVariable.startsWith('--') || cssVariable.includes(' ') || cssVariable.includes(':')) { + issues.push({ + message: `**cssVariable** property "${cssVariable}" contains invalid characters for CSS variable generation. It must start with -- and be a valid indent: https://developer.mozilla.org/en-US/docs/Web/CSS/ident.`, + path: ['fonts', i, 'cssVariable'], + }); + } + } + return issues; +} diff --git a/packages/astro/src/core/config/schemas/refined.ts b/packages/astro/src/core/config/schemas/refined.ts index 6280fdd7d92f..98ed6968ef4c 100644 --- a/packages/astro/src/core/config/schemas/refined.ts +++ b/packages/astro/src/core/config/schemas/refined.ts @@ -1,189 +1,43 @@ import * as z from 'zod/v4'; import type { AstroConfig } from '../../../types/public/config.js'; +import { + type ConfigValidationIssue, + validateAssetsPrefix, + validateFontsCssVariables, + validateI18nDefaultLocale, + validateI18nDomains, + validateI18nFallback, + validateI18nRedirectToDefaultLocale, + validateOutDirNotInPublicDir, + validateRemotePatterns, +} from './refined-validators.js'; export const AstroConfigRefinedSchema = z.custom().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'], - }); - } + let issues: ConfigValidationIssue[] = []; + issues = issues.concat( + validateAssetsPrefix(config), + validateRemotePatterns(config.image.remotePatterns), + validateI18nRedirectToDefaultLocale(config.i18n), + validateOutDirNotInPublicDir(config.outDir, config.publicDir), + ); - 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) { + issues = issues.concat( + validateI18nDefaultLocale(config.i18n), + validateI18nFallback(config.i18n), + validateI18nDomains(config), + ); } - if ( - config.i18n && - typeof config.i18n.routing !== 'string' && - config.i18n.routing.prefixDefaultLocale === false && - config.i18n.routing.redirectToDefaultLocale === true - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - 'The option `i18n.routing.redirectToDefaultLocale` can be used only when `i18n.routing.prefixDefaultLocale` is set to `true`; otherwise, redirects might cause infinite loops. Remove the option `i18n.routing.redirectToDefaultLocale`, or change its value to `false`.', - path: ['i18n', 'routing', 'redirectToDefaultLocale'], - }); + if (config.fonts && config.fonts.length > 0) { + issues = issues.concat(validateFontsCssVariables(config.fonts)); } - if (config.outDir.toString().startsWith(config.publicDir.toString())) { + for (const issue of issues) { 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'], + message: issue.message, + path: issue.path, }); } - - 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.fonts && config.fonts.length > 0) { - for (let i = 0; i < config.fonts.length; i++) { - const { cssVariable } = config.fonts[i]; - - // Checks if the name starts with --, doesn't include a space nor a colon. - // We are not trying to recreate the full CSS spec about indents: - // https://developer.mozilla.org/en-US/docs/Web/CSS/ident - if (!cssVariable.startsWith('--') || cssVariable.includes(' ') || cssVariable.includes(':')) { - ctx.addIssue({ - code: 'custom', - message: `**cssVariable** property "${cssVariable}" contains invalid characters for CSS variable generation. It must start with -- and be a valid indent: https://developer.mozilla.org/en-US/docs/Web/CSS/ident.`, - path: ['fonts', i, 'cssVariable'], - }); - } - } - } }); diff --git a/packages/astro/src/integrations/features-validation.ts b/packages/astro/src/integrations/features-validation.ts index af52a673dd16..0534c6f43f79 100644 --- a/packages/astro/src/integrations/features-validation.ts +++ b/packages/astro/src/integrations/features-validation.ts @@ -96,7 +96,7 @@ export function validateSupportedFeatures( return validationResult; } -function unwrapSupportKind(supportKind?: AdapterSupport): AdapterSupportsKind | undefined { +export function unwrapSupportKind(supportKind?: AdapterSupport): AdapterSupportsKind | undefined { if (!supportKind) { return undefined; } @@ -104,7 +104,7 @@ function unwrapSupportKind(supportKind?: AdapterSupport): AdapterSupportsKind | return typeof supportKind === 'object' ? supportKind.support : supportKind; } -function getSupportMessage(supportKind: AdapterSupport): string | undefined { +export function getSupportMessage(supportKind: AdapterSupport): string | undefined { return typeof supportKind === 'object' ? supportKind.message : undefined; } diff --git a/packages/astro/src/vite-plugin-astro-server/base.ts b/packages/astro/src/vite-plugin-astro-server/base.ts index aea2a29f9865..93d46f775145 100644 --- a/packages/astro/src/vite-plugin-astro-server/base.ts +++ b/packages/astro/src/vite-plugin-astro-server/base.ts @@ -8,17 +8,85 @@ import { notFoundTemplate, subpathNotUsedTemplate } from '../template/4xx.js'; import type { AstroSettings } from '../types/astro.js'; import { writeHtmlResponse } from './response.js'; +/** + * Outcome of the base-URL evaluation for a dev-server request. + * + * - **`rewrite`** — The request URL starts with the configured `base` path. + * Strip the base prefix so downstream handlers see a root-relative URL + * (e.g. `/docs/about` → `/about` when `base: '/docs'`). + * - **`not-found-subpath`** — The user navigated to `/` or `/index.html` but + * the project has a non-root `base`. Respond with a 404 explaining that the + * site lives under the base path, so the developer knows to update the URL. + * - **`not-found`** — The URL doesn't start with the base and the browser + * expects HTML (`Accept: text/html`). Respond with a generic 404 page. + * - **`check-public`** — The URL doesn't match the base and the browser is + * requesting a non-HTML asset (image, script, font, etc.). The middleware + * must do an async `fs.stat` to decide whether the file exists in + * `publicDir` (and show a helpful base-path hint) or just pass through. + * This variant cannot be resolved purely. + */ +export type BaseRewriteDecision = + | { action: 'rewrite'; newUrl: string } + | { action: 'not-found-subpath'; pathname: string; devRoot: string } + | { action: 'not-found'; pathname: string } + | { action: 'check-public' }; + +/** + * Computes the `devRoot` path used to match and strip the base prefix. + * + * The `devRoot` is the pathname portion of the base URL (resolved against the + * `site` if present, otherwise against `http://localhost`). For example: + * - `base: '/docs'`, no site → `/docs` + * - `base: '/docs'`, `site: 'https://example.com'` → `/docs` + * - `base: '/'` → `/` + */ +export function resolveDevRoot(base: string, site?: string) { + const effectiveBase = base || '/'; + const siteUrl = site ? new URL(effectiveBase, site) : undefined; + const devRootURL = new URL(effectiveBase, 'http://localhost'); + const devRoot = siteUrl ? siteUrl.pathname : devRootURL.pathname; + const devRootReplacement = devRoot.endsWith('/') ? '/' : ''; + return { devRoot, devRootReplacement }; +} + +/** + * Pure decision function for base-URL dev-server rewriting. + * + * Evaluates whether the incoming `url` starts with the project's `base` path + * and returns the action the middleware should take. The async `fs.stat` branch + * (checking `publicDir`) is represented as `check-public` and must be handled + * by the caller. + */ +export function evaluateBaseRewrite( + url: string, + pathname: string, + acceptHeader: string | undefined, + devRoot: string, + devRootReplacement: string, +): BaseRewriteDecision { + if (pathname.startsWith(devRoot)) { + let newUrl = url.replace(devRoot, devRootReplacement); + if (!newUrl.startsWith('/')) newUrl = prependForwardSlash(newUrl); + return { action: 'rewrite', newUrl }; + } + + if (pathname === '/' || pathname === '/index.html') { + return { action: 'not-found-subpath', pathname, devRoot }; + } + + if (acceptHeader?.includes('text/html')) { + return { action: 'not-found', pathname }; + } + + return { action: 'check-public' }; +} + export function baseMiddleware( settings: AstroSettings, logger: Logger, ): vite.Connect.NextHandleFunction { const { config } = settings; - // The base may be an empty string by now, causing the URL creation to fail. We provide a default instead - const base = config.base || '/'; - const site = config.site ? new URL(base, config.site) : undefined; - const devRootURL = new URL(base, 'http://localhost'); - const devRoot = site ? site.pathname : devRootURL.pathname; - const devRootReplacement = devRoot.endsWith('/') ? '/' : ''; + const { devRoot, devRootReplacement } = resolveDevRoot(config.base, config.site); return function devBaseMiddleware(req, res, next) { const url = req.url!; @@ -30,42 +98,49 @@ export function baseMiddleware( return next(e); } - if (pathname.startsWith(devRoot)) { - req.url = url.replace(devRoot, devRootReplacement); - if (!req.url.startsWith('/')) req.url = prependForwardSlash(req.url); - return next(); - } + const decision = evaluateBaseRewrite( + url, + pathname, + req.headers.accept, + devRoot, + devRootReplacement, + ); - if (pathname === '/' || pathname === '/index.html') { - const html = subpathNotUsedTemplate(devRoot, pathname); - return writeHtmlResponse(res, 404, html); - } - - if (req.headers.accept?.includes('text/html')) { - const html = notFoundTemplate(pathname); - return writeHtmlResponse(res, 404, html); - } - - // Check to see if it's in public and if so 404 - const publicPath = new URL('.' + req.url, config.publicDir); - fs.stat(publicPath, (_err, stats) => { - if (stats) { - const publicDir = appendForwardSlash( - path.posix.relative(config.root.pathname, config.publicDir.pathname), - ); - const expectedLocation = new URL(devRootURL.pathname + url, devRootURL).pathname; - - logger.error( - 'router', - `Request URLs for ${colors.bold( - publicDir, - )} assets must also include your base. "${expectedLocation}" expected, but received "${url}".`, - ); - const html = subpathNotUsedTemplate(devRoot, pathname); + switch (decision.action) { + case 'rewrite': + req.url = decision.newUrl; + return next(); + case 'not-found-subpath': { + const html = subpathNotUsedTemplate(decision.devRoot, decision.pathname); return writeHtmlResponse(res, 404, html); - } else { - next(); } - }); + case 'not-found': { + const html = notFoundTemplate(decision.pathname); + return writeHtmlResponse(res, 404, html); + } + case 'check-public': { + const publicPath = new URL('.' + req.url, config.publicDir); + fs.stat(publicPath, (_err, stats) => { + if (stats) { + const publicDir = appendForwardSlash( + path.posix.relative(config.root.pathname, config.publicDir.pathname), + ); + const devRootURL = new URL(devRoot, 'http://localhost'); + const expectedLocation = new URL(devRootURL.pathname + url, devRootURL).pathname; + + logger.error( + 'router', + `Request URLs for ${colors.bold( + publicDir, + )} assets must also include your base. "${expectedLocation}" expected, but received "${url}".`, + ); + const html = subpathNotUsedTemplate(devRoot, pathname); + return writeHtmlResponse(res, 404, html); + } else { + next(); + } + }); + } + } }; } diff --git a/packages/astro/src/vite-plugin-astro-server/trailing-slash.ts b/packages/astro/src/vite-plugin-astro-server/trailing-slash.ts index 0fafcfc3513b..2166ab02d83e 100644 --- a/packages/astro/src/vite-plugin-astro-server/trailing-slash.ts +++ b/packages/astro/src/vite-plugin-astro-server/trailing-slash.ts @@ -8,6 +8,57 @@ import { trailingSlashMismatchTemplate } from '../template/4xx.js'; import type { AstroSettings } from '../types/astro.js'; import { writeHtmlResponse, writeRedirectResponse } from './response.js'; +/** + * Outcome of the trailing-slash evaluation for a dev-server request. + * + * - **`next`** — The URL is acceptable. Pass the request through to the next + * middleware / route handler unchanged. + * - **`redirect`** — The URL contains duplicate trailing slashes (e.g. + * `/about//`). The client should be permanently redirected (301) to the + * collapsed form (`/about/`) so crawlers and browsers update their links. + * - **`reject`** — The URL's trailing-slash style conflicts with the project's + * `trailingSlash` config (`'always'` or `'never'`). The dev server responds + * with a 404 and a human-readable error page explaining the mismatch, giving + * the developer immediate feedback that their link is wrong before it reaches + * production. + */ +export type TrailingSlashDecision = + | { action: 'next' } + | { action: 'redirect'; status: 301; location: string } + | { action: 'reject'; status: 404; pathname: string; }; + +/** + * Pure decision function for trailing-slash dev-server behavior. + * + * Evaluates a decoded `pathname`, the query-string portion (including leading + * `?`), and the project's `trailingSlash` config and returns the action the + * middleware should take. The middleware is responsible for translating the + * decision into an HTTP response. + */ +export function evaluateTrailingSlash( + pathname: string, + search: string, + trailingSlash: 'always' | 'never' | 'ignore', +): TrailingSlashDecision { + if (isInternalPath(pathname)) { + return { action: 'next' }; + } + + const collapsed = collapseDuplicateTrailingSlashes(pathname, true); + if (pathname && collapsed !== pathname) { + return { action: 'redirect', status: 301, location: `${collapsed}${search}` }; + } + + if ( + (trailingSlash === 'never' && pathname.endsWith('/') && pathname !== '/') || + (trailingSlash === 'always' && !pathname.endsWith('/') && !hasFileExtension(pathname)) + ) { + return { action: 'reject', status: 404, pathname }; + } + + return { action: 'next' }; +} + export function trailingSlashMiddleware(settings: AstroSettings): vite.Connect.NextHandleFunction { const { trailingSlash } = settings.config; @@ -20,22 +71,18 @@ export function trailingSlashMiddleware(settings: AstroSettings): vite.Connect.N /* malformed uri */ return next(e); } - if (isInternalPath(pathname)) { - return next(); - } - const destination = collapseDuplicateTrailingSlashes(pathname, true); - if (pathname && destination !== pathname) { - return writeRedirectResponse(res, 301, `${destination}${url.search}`); - } + const decision = evaluateTrailingSlash(pathname, url.search, trailingSlash); - if ( - (trailingSlash === 'never' && pathname.endsWith('/') && pathname !== '/') || - (trailingSlash === 'always' && !pathname.endsWith('/') && !hasFileExtension(pathname)) - ) { - const html = trailingSlashMismatchTemplate(pathname, trailingSlash); - return writeHtmlResponse(res, 404, html); + switch (decision.action) { + case 'redirect': + return writeRedirectResponse(res, decision.status, decision.location); + case 'reject': { + const html = trailingSlashMismatchTemplate(decision.pathname, trailingSlash); + return writeHtmlResponse(res, decision.status, html); + } + case 'next': + return next(); } - return next(); }; } diff --git a/packages/astro/test/units/assets/utils.test.ts b/packages/astro/test/units/assets/utils.test.ts new file mode 100644 index 000000000000..a5c499ac2b33 --- /dev/null +++ b/packages/astro/test/units/assets/utils.test.ts @@ -0,0 +1,270 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getAssetsPrefix } from '../../../dist/assets/utils/getAssetsPrefix.js'; +import { etag } from '../../../dist/assets/utils/etag.js'; +import { deterministicString } from '../../../dist/assets/utils/deterministic-string.js'; +import { getOrigQueryParams } from '../../../dist/assets/utils/queryParams.js'; +import { createPlaceholderURL, stringifyPlaceholderURL } from '../../../dist/assets/utils/url.js'; +import { isESMImportedImage, isRemoteImage } from '../../../dist/assets/utils/imageKind.js'; +import { dropAttributes } from '../../../dist/assets/runtime.js'; + +// #region getAssetsPrefix +describe('getAssetsPrefix', () => { + it('returns empty string when no prefix configured', () => { + assert.equal(getAssetsPrefix('.css', undefined), ''); + }); + + it('returns the string prefix directly', () => { + assert.equal(getAssetsPrefix('.css', 'https://cdn.example.com'), 'https://cdn.example.com'); + }); + + it('returns per-type prefix for matching extension', () => { + const prefix = { + js: 'https://js.cdn.com', + css: 'https://css.cdn.com', + fallback: 'https://cdn.com', + }; + assert.equal(getAssetsPrefix('.css', prefix), 'https://css.cdn.com'); + assert.equal(getAssetsPrefix('.js', prefix), 'https://js.cdn.com'); + }); + + it('returns fallback for unknown extension', () => { + const prefix = { js: 'https://js.cdn.com', fallback: 'https://cdn.com' }; + assert.equal(getAssetsPrefix('.webp', prefix), 'https://cdn.com'); + }); + + it('strips leading dot from extension when looking up', () => { + const prefix = { mjs: 'https://mjs.cdn.com', fallback: 'https://cdn.com' }; + assert.equal(getAssetsPrefix('.mjs', prefix), 'https://mjs.cdn.com'); + }); +}); +// #endregion + +// #region etag +describe('etag', () => { + it('returns a deterministic hash for the same input', () => { + const a = etag('hello world'); + const b = etag('hello world'); + assert.equal(a, b); + }); + + it('returns different hashes for different inputs', () => { + assert.notEqual(etag('hello'), etag('world')); + }); + + it('wraps in double quotes by default (strong etag)', () => { + const result = etag('test'); + assert.ok(result.startsWith('"')); + assert.ok(result.endsWith('"')); + }); + + it('wraps with W/ prefix for weak etags', () => { + const result = etag('test', true); + assert.ok(result.startsWith('W/"')); + assert.ok(result.endsWith('"')); + }); + + it('produces different output for strong vs weak', () => { + assert.notEqual(etag('test', false), etag('test', true)); + }); +}); +// #endregion + +// #region deterministicString +describe('deterministicString', () => { + it('orders object keys deterministically', () => { + const a = deterministicString({ b: 2, a: 1 }); + const b = deterministicString({ a: 1, b: 2 }); + assert.equal(a, b); + }); + + it('handles nested objects', () => { + const result = deterministicString({ outer: { z: 1, a: 2 } }); + assert.ok(result.includes('"a"')); + assert.ok(result.includes('"z"')); + }); + + it('handles strings', () => { + assert.equal(deterministicString('hello'), '"hello"'); + }); + + it('handles numbers', () => { + assert.equal(deterministicString(42), '42'); + }); + + it('handles booleans', () => { + assert.equal(deterministicString(true), 'true'); + assert.equal(deterministicString(false), 'false'); + }); + + it('handles null and undefined', () => { + assert.equal(deterministicString(null), 'null'); + assert.equal(deterministicString(undefined), 'undefined'); + }); + + it('handles arrays', () => { + const result = deterministicString([1, 'two', 3]); + assert.ok(result.includes('Array')); + }); + + it('handles Date objects', () => { + const d = new Date('2024-01-01T00:00:00Z'); + const result = deterministicString(d); + assert.ok(result.includes('Date')); + assert.ok(result.includes(String(d.getTime()))); + }); + + it('handles Map', () => { + const m = new Map([ + ['b', 2], + ['a', 1], + ]); + const result = deterministicString(m); + assert.ok(result.includes('Map')); + }); + + it('handles Set', () => { + const s = new Set([3, 1, 2]); + const result = deterministicString(s); + assert.ok(result.includes('Set')); + }); + + it('handles RegExp', () => { + const result = deterministicString(/foo/gi); + assert.ok(result.includes('RegExp')); + assert.ok(result.includes('foo')); + }); + + it('handles bigint', () => { + assert.equal(deterministicString(BigInt(42)), '42n'); + }); +}); +// #endregion + +// #region getOrigQueryParams +describe('getOrigQueryParams', () => { + it('returns parsed width, height, format when all present', () => { + const params = new URLSearchParams('origWidth=800&origHeight=600&origFormat=png'); + const result = getOrigQueryParams(params); + assert.deepEqual(result, { width: 800, height: 600, format: 'png' }); + }); + + it('returns undefined when width is missing', () => { + const params = new URLSearchParams('origHeight=600&origFormat=png'); + assert.equal(getOrigQueryParams(params), undefined); + }); + + it('returns undefined when height is missing', () => { + const params = new URLSearchParams('origWidth=800&origFormat=png'); + assert.equal(getOrigQueryParams(params), undefined); + }); + + it('returns undefined when format is missing', () => { + const params = new URLSearchParams('origWidth=800&origHeight=600'); + assert.equal(getOrigQueryParams(params), undefined); + }); + + it('returns undefined for empty params', () => { + assert.equal(getOrigQueryParams(new URLSearchParams()), undefined); + }); +}); +// #endregion + +// #region createPlaceholderURL / stringifyPlaceholderURL +describe('placeholder URL utilities', () => { + it('createPlaceholderURL creates URL from relative path', () => { + const url = createPlaceholderURL('/images/photo.jpg'); + assert.ok(url instanceof URL); + assert.equal(url.pathname, '/images/photo.jpg'); + }); + + it('createPlaceholderURL preserves query params', () => { + const url = createPlaceholderURL('/img.jpg?w=100'); + assert.equal(url.searchParams.get('w'), '100'); + }); + + it('stringifyPlaceholderURL removes placeholder base', () => { + const url = createPlaceholderURL('/images/photo.jpg'); + const str = stringifyPlaceholderURL(url); + assert.equal(str, '/images/photo.jpg'); + assert.ok(!str.includes('astro://')); + }); + + it('roundtrips path with query and hash', () => { + const url = createPlaceholderURL('/img.jpg?w=100#frag'); + const str = stringifyPlaceholderURL(url); + assert.equal(str, '/img.jpg?w=100#frag'); + }); +}); +// #endregion + +// #region isESMImportedImage / isRemoteImage +describe('image kind detection', () => { + it('isESMImportedImage returns true for objects', () => { + assert.equal( + isESMImportedImage({ src: '/img.jpg', width: 100, height: 100, format: 'jpg' }), + true, + ); + }); + + it('isESMImportedImage returns false for strings', () => { + assert.equal(isESMImportedImage('https://example.com/img.jpg'), false); + }); + + it('isRemoteImage returns true for strings', () => { + assert.equal(isRemoteImage('https://example.com/img.jpg'), true); + }); + + it('isRemoteImage returns false for objects', () => { + assert.equal(isRemoteImage({ src: '/img.jpg', width: 100, height: 100, format: 'jpg' }), false); + }); +}); +// #endregion + +// #region dropAttributes +describe('dropAttributes', () => { + it('removes xmlns, xmlns:xlink, and version', () => { + const attrs = { + xmlns: 'http://www.w3.org/2000/svg', + 'xmlns:xlink': 'http://www.w3.org/1999/xlink', + version: '1.1', + viewBox: '0 0 100 100', + fill: 'red', + }; + const result = dropAttributes(attrs); + assert.equal(result.xmlns, undefined); + assert.equal(result['xmlns:xlink'], undefined); + assert.equal(result.version, undefined); + }); + + it('preserves other attributes', () => { + const attrs = { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 100 100', + fill: 'red', + class: 'icon', + }; + const result = dropAttributes(attrs); + assert.equal(result.viewBox, '0 0 100 100'); + assert.equal(result.fill, 'red'); + assert.equal(result.class, 'icon'); + }); + + it('handles empty object', () => { + const result = dropAttributes({}); + assert.deepEqual(result, {}); + }); + + it('handles object without any droppable attributes', () => { + const attrs = { viewBox: '0 0 50 50', fill: 'blue' }; + const result = dropAttributes(attrs); + assert.deepEqual(result, { viewBox: '0 0 50 50', fill: 'blue' }); + }); + + it('mutates and returns the same object', () => { + const attrs = { xmlns: 'test', fill: 'red' }; + const result = dropAttributes(attrs); + assert.equal(result, attrs); + }); +}); +// #endregion diff --git a/packages/astro/test/units/config/refined-validators.test.ts b/packages/astro/test/units/config/refined-validators.test.ts new file mode 100644 index 000000000000..f2a37e65d82d --- /dev/null +++ b/packages/astro/test/units/config/refined-validators.test.ts @@ -0,0 +1,444 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { AstroConfig } from '../../../dist/types/public/config.js'; +import { + validateAssetsPrefix, + validateFontsCssVariables, + validateI18nDefaultLocale, + validateI18nDomains, + validateI18nFallback, + validateI18nRedirectToDefaultLocale, + validateOutDirNotInPublicDir, + validateRemotePatterns, +} from '../../../dist/core/config/schemas/refined-validators.js'; + +/** Cast partial test data to a strict Pick type via `unknown`. */ +const build = (v: unknown) => ({ build: v }) as Pick; +const i18n = (v: unknown) => v as NonNullable; +const domains = (v: unknown) => v as Pick; +const font = (v: unknown) => v as NonNullable[number]; + +// #region validateAssetsPrefix +describe('validateAssetsPrefix', () => { + it('returns no issues for a string prefix', () => { + const issues = validateAssetsPrefix(build({ assetsPrefix: 'https://cdn.example.com' })); + assert.equal(issues.length, 0); + }); + + it('returns no issues when assetsPrefix is undefined', () => { + const issues = validateAssetsPrefix(build({})); + assert.equal(issues.length, 0); + }); + + it('returns no issues for an object with fallback', () => { + const issues = validateAssetsPrefix( + build({ assetsPrefix: { css: 'https://css.cdn.com', fallback: 'https://cdn.com' } }), + ); + assert.equal(issues.length, 0); + }); + + it('returns an issue for an object without fallback', () => { + const issues = validateAssetsPrefix(build({ assetsPrefix: { css: 'https://css.cdn.com' } })); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /fallback/i); + assert.deepEqual(issues[0].path, ['build', 'assetsPrefix']); + }); +}); +// #endregion + +// #region validateRemotePatterns +describe('validateRemotePatterns', () => { + it('returns no issues for empty array', () => { + const issues = validateRemotePatterns([]); + assert.equal(issues.length, 0); + }); + + it('returns no issues for valid hostname wildcard at start', () => { + const issues = validateRemotePatterns([{ hostname: '*.example.com' }]); + assert.equal(issues.length, 0); + }); + + it('returns no issues for double-star hostname wildcard at start', () => { + const issues = validateRemotePatterns([{ hostname: '**.example.com' }]); + assert.equal(issues.length, 0); + }); + + it('returns an issue for wildcard in the middle of hostname', () => { + const issues = validateRemotePatterns([{ hostname: 'cdn.*.example.com' }]); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /beginning of the hostname/); + assert.deepEqual(issues[0].path, ['image', 'remotePatterns', 0, 'hostname']); + }); + + it('returns an issue for wildcard at the end of hostname', () => { + const issues = validateRemotePatterns([{ hostname: 'example.*' }]); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /beginning of the hostname/); + }); + + it('returns no issues for valid pathname wildcard at end', () => { + const issues = validateRemotePatterns([{ pathname: '/images/*' }]); + assert.equal(issues.length, 0); + }); + + it('returns no issues for double-star pathname wildcard at end', () => { + const issues = validateRemotePatterns([{ pathname: '/images/**' }]); + assert.equal(issues.length, 0); + }); + + it('returns an issue for wildcard at the start of pathname', () => { + const issues = validateRemotePatterns([{ pathname: '/*/images' }]); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /end of a pathname/); + assert.deepEqual(issues[0].path, ['image', 'remotePatterns', 0, 'pathname']); + }); + + it('returns issues for multiple invalid patterns', () => { + const issues = validateRemotePatterns([ + { hostname: 'cdn.*.example.com' }, + { hostname: '*.valid.com' }, + { pathname: '/*/bad' }, + ]); + assert.equal(issues.length, 2); + }); + + it('returns no issues for patterns without wildcards', () => { + const issues = validateRemotePatterns([{ hostname: 'example.com', pathname: '/images' }]); + assert.equal(issues.length, 0); + }); +}); +// #endregion + +// #region validateI18nRedirectToDefaultLocale +describe('validateI18nRedirectToDefaultLocale', () => { + it('returns no issues when i18n is undefined', () => { + const issues = validateI18nRedirectToDefaultLocale(undefined); + assert.equal(issues.length, 0); + }); + + it('returns no issues when prefixDefaultLocale is true and redirectToDefaultLocale is true', () => { + const issues = validateI18nRedirectToDefaultLocale( + i18n({ + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: true, + fallbackType: 'redirect', + }, + }), + ); + assert.equal(issues.length, 0); + }); + + it('returns no issues when prefixDefaultLocale is false and redirectToDefaultLocale is false', () => { + const issues = validateI18nRedirectToDefaultLocale( + i18n({ + routing: { + prefixDefaultLocale: false, + redirectToDefaultLocale: false, + fallbackType: 'redirect', + }, + }), + ); + assert.equal(issues.length, 0); + }); + + it('returns an issue when prefixDefaultLocale is false and redirectToDefaultLocale is true', () => { + const issues = validateI18nRedirectToDefaultLocale( + i18n({ + routing: { + prefixDefaultLocale: false, + redirectToDefaultLocale: true, + fallbackType: 'redirect', + }, + }), + ); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /redirectToDefaultLocale/); + assert.match(issues[0].message, /prefixDefaultLocale/); + assert.deepEqual(issues[0].path, ['i18n', 'routing', 'redirectToDefaultLocale']); + }); + + it('returns no issues when routing is manual', () => { + const issues = validateI18nRedirectToDefaultLocale(i18n({ routing: 'manual' })); + assert.equal(issues.length, 0); + }); +}); +// #endregion + +// #region validateOutDirNotInPublicDir +describe('validateOutDirNotInPublicDir', () => { + it('returns no issues when outDir is outside publicDir', () => { + const issues = validateOutDirNotInPublicDir( + new URL('file:///project/dist/'), + new URL('file:///project/public/'), + ); + assert.equal(issues.length, 0); + }); + + it('returns an issue when outDir equals publicDir', () => { + const issues = validateOutDirNotInPublicDir( + new URL('file:///project/public/'), + new URL('file:///project/public/'), + ); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /outDir/); + assert.match(issues[0].message, /publicDir/); + assert.deepEqual(issues[0].path, ['outDir']); + }); + + it('returns an issue when outDir is inside publicDir', () => { + const issues = validateOutDirNotInPublicDir( + new URL('file:///project/public/dist/'), + new URL('file:///project/public/'), + ); + assert.equal(issues.length, 1); + }); +}); +// #endregion + +// #region validateI18nDefaultLocale +describe('validateI18nDefaultLocale', () => { + it('returns no issues when defaultLocale is in locales', () => { + const issues = validateI18nDefaultLocale({ + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }); + assert.equal(issues.length, 0); + }); + + it('returns an issue when defaultLocale is not in locales', () => { + const issues = validateI18nDefaultLocale({ + defaultLocale: 'es', + locales: ['en', 'fr', 'de'], + }); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /es/); + assert.match(issues[0].message, /not present/); + assert.deepEqual(issues[0].path, ['i18n', 'locales']); + }); + + it('handles object locales (uses path property)', () => { + const issues = validateI18nDefaultLocale({ + defaultLocale: 'english', + locales: [{ path: 'english', codes: ['en'] }, 'fr'], + }); + assert.equal(issues.length, 0); + }); + + it('returns an issue when defaultLocale is missing from object locales', () => { + const issues = validateI18nDefaultLocale({ + defaultLocale: 'en', + locales: [{ path: 'english', codes: ['en'] }, 'fr'], + }); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /en/); + }); +}); +// #endregion + +// #region validateI18nFallback +describe('validateI18nFallback', () => { + it('returns no issues when fallback is undefined', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr'], + }); + assert.equal(issues.length, 0); + }); + + it('returns no issues for valid fallback entries', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + fallback: { fr: 'en', de: 'en' }, + }); + assert.equal(issues.length, 0); + }); + + it('returns an issue when fallback key is not in locales', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr'], + fallback: { es: 'en' }, + }); + assert.ok(issues.some((i) => i.message.includes('es') && i.message.includes('key'))); + }); + + it('returns an issue when fallback value is not in locales', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr'], + fallback: { fr: 'de' }, + }); + assert.ok(issues.some((i) => i.message.includes('de') && i.message.includes('value'))); + }); + + it('returns an issue when default locale is used as a fallback key', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr'], + fallback: { en: 'fr' }, + }); + assert.ok(issues.some((i) => i.message.includes('default locale'))); + }); + + it('returns multiple issues for multiple invalid entries', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr'], + fallback: { es: 'de', en: 'fr' }, + }); + // es not in locales (key issue), de not in locales (value issue), en is default locale + assert.ok(issues.length >= 3); + }); +}); +// #endregion + +// #region validateI18nDomains +describe('validateI18nDomains', () => { + it('returns no issues when i18n is undefined', () => { + const issues = validateI18nDomains(domains({ i18n: undefined })); + assert.equal(issues.length, 0); + }); + + it('returns no issues when domains is undefined', () => { + const issues = validateI18nDomains(domains({ i18n: { locales: ['en'], defaultLocale: 'en' } })); + assert.equal(issues.length, 0); + }); + + it('returns an issue when site is not set', () => { + const issues = validateI18nDomains( + domains({ + site: undefined, + output: 'server', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { fr: 'https://fr.example.com' }, + }, + }), + ); + assert.ok(issues.some((i) => i.message.includes('site'))); + }); + + it('returns an issue when output is not server', () => { + const issues = validateI18nDomains( + domains({ + site: 'https://example.com', + output: 'static', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { fr: 'https://fr.example.com' }, + }, + }), + ); + assert.ok(issues.some((i) => i.message.includes('output') && i.message.includes('server'))); + }); + + it('returns an issue when domain locale key is not in locales', () => { + const issues = validateI18nDomains( + domains({ + site: 'https://example.com', + output: 'server', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { de: 'https://de.example.com' }, + }, + }), + ); + assert.ok(issues.some((i) => i.message.includes('de'))); + }); + + it('returns an issue when domain value is not a URL', () => { + const issues = validateI18nDomains( + domains({ + site: 'https://example.com', + output: 'server', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { fr: 'not-a-url' }, + }, + }), + ); + assert.ok(issues.some((i) => i.message.includes('http'))); + }); + + it('returns an issue when domain URL has a pathname', () => { + const issues = validateI18nDomains( + domains({ + site: 'https://example.com', + output: 'server', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { fr: 'https://fr.example.com/blog' }, + }, + }), + ); + assert.ok(issues.some((i) => i.message.includes('/blog'))); + }); + + it('returns no issues for valid domain configuration', () => { + const issues = validateI18nDomains( + domains({ + site: 'https://example.com', + output: 'server', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { fr: 'https://fr.example.com' }, + }, + }), + ); + assert.equal(issues.length, 0); + }); +}); +// #endregion + +// #region validateFontsCssVariables +describe('validateFontsCssVariables', () => { + it('returns no issues for valid CSS variable names', () => { + const issues = validateFontsCssVariables([ + font({ cssVariable: '--font-body' }), + font({ cssVariable: '--heading-font' }), + ]); + assert.equal(issues.length, 0); + }); + + it('returns an issue when cssVariable does not start with --', () => { + const issues = validateFontsCssVariables([font({ cssVariable: 'font-body' })]); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /cssVariable/); + assert.deepEqual(issues[0].path, ['fonts', 0, 'cssVariable']); + }); + + it('returns an issue when cssVariable contains a space', () => { + const issues = validateFontsCssVariables([font({ cssVariable: '--font body' })]); + assert.equal(issues.length, 1); + }); + + it('returns an issue when cssVariable contains a colon', () => { + const issues = validateFontsCssVariables([font({ cssVariable: '--font:body' })]); + assert.equal(issues.length, 1); + }); + + it('returns issues for multiple invalid entries', () => { + const issues = validateFontsCssVariables([ + font({ cssVariable: '--valid' }), + font({ cssVariable: 'no-prefix' }), + font({ cssVariable: '--has space' }), + ]); + assert.equal(issues.length, 2); + assert.deepEqual(issues[0].path, ['fonts', 1, 'cssVariable']); + assert.deepEqual(issues[1].path, ['fonts', 2, 'cssVariable']); + }); + + it('returns no issues for empty array', () => { + const issues = validateFontsCssVariables([]); + assert.equal(issues.length, 0); + }); +}); +// #endregion diff --git a/packages/astro/test/units/dev/base-rewrite.test.ts b/packages/astro/test/units/dev/base-rewrite.test.ts new file mode 100644 index 000000000000..925f1a49a091 --- /dev/null +++ b/packages/astro/test/units/dev/base-rewrite.test.ts @@ -0,0 +1,160 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + evaluateBaseRewrite, + resolveDevRoot, +} from '../../../dist/vite-plugin-astro-server/base.js'; + +// #region resolveDevRoot +describe('resolveDevRoot', () => { + it('resolves /docs base without site', () => { + const { devRoot, devRootReplacement } = resolveDevRoot('/docs'); + assert.equal(devRoot, '/docs'); + assert.equal(devRootReplacement, ''); + }); + + it('resolves /docs/ base with trailing slash', () => { + const { devRoot, devRootReplacement } = resolveDevRoot('/docs/'); + assert.equal(devRoot, '/docs/'); + assert.equal(devRootReplacement, '/'); + }); + + it('resolves / base (root)', () => { + const { devRoot, devRootReplacement } = resolveDevRoot('/'); + assert.equal(devRoot, '/'); + assert.equal(devRootReplacement, '/'); + }); + + it('resolves empty base as /', () => { + const { devRoot, devRootReplacement } = resolveDevRoot(''); + assert.equal(devRoot, '/'); + assert.equal(devRootReplacement, '/'); + }); + + it('uses site pathname when site is provided', () => { + const { devRoot } = resolveDevRoot('/docs/', 'https://example.com'); + assert.equal(devRoot, '/docs/'); + }); + + it('absolute base overrides site pathname', () => { + // `/app/` is absolute, so the site's `/prefix/` pathname is irrelevant + const { devRoot } = resolveDevRoot('/app/', 'https://example.com/prefix/'); + assert.equal(devRoot, '/app/'); + }); +}); +// #endregion + +// #region evaluateBaseRewrite — rewrite +describe('evaluateBaseRewrite — rewrite', () => { + it('rewrites URL starting with base by stripping base', () => { + const result = evaluateBaseRewrite('/docs/about', '/docs/about', undefined, '/docs/', '/'); + assert.equal(result.action, 'rewrite'); + if (result.action === 'rewrite') { + assert.equal(result.newUrl, '/about'); + } + }); + + it('rewrites root base request to /', () => { + const result = evaluateBaseRewrite('/docs/', '/docs/', undefined, '/docs/', '/'); + assert.equal(result.action, 'rewrite'); + if (result.action === 'rewrite') { + assert.equal(result.newUrl, '/'); + } + }); + + it('preserves query params after rewrite', () => { + const result = evaluateBaseRewrite( + '/docs/page?foo=bar', + '/docs/page', + undefined, + '/docs/', + '/', + ); + assert.equal(result.action, 'rewrite'); + if (result.action === 'rewrite') { + assert.equal(result.newUrl, '/page?foo=bar'); + } + }); + + it('ensures rewritten URL starts with /', () => { + // devRootReplacement is '' (no trailing slash on devRoot), so stripping + // '/docs' from '/docs/about' yields '/about' which already starts with / + const result = evaluateBaseRewrite('/docs/about', '/docs/about', undefined, '/docs', ''); + assert.equal(result.action, 'rewrite'); + if (result.action === 'rewrite') { + assert.ok(result.newUrl.startsWith('/')); + } + }); + + it('rewrites exact base match (no trailing content)', () => { + const result = evaluateBaseRewrite('/docs', '/docs', undefined, '/docs', ''); + assert.equal(result.action, 'rewrite'); + if (result.action === 'rewrite') { + assert.equal(result.newUrl, '/'); + } + }); +}); +// #endregion + +// #region evaluateBaseRewrite — not-found-subpath +describe('evaluateBaseRewrite — not-found-subpath', () => { + it('returns not-found-subpath for / when base is not /', () => { + const result = evaluateBaseRewrite('/', '/', undefined, '/docs/', '/'); + assert.equal(result.action, 'not-found-subpath'); + if (result.action === 'not-found-subpath') { + assert.equal(result.pathname, '/'); + assert.equal(result.devRoot, '/docs/'); + } + }); + + it('returns not-found-subpath for /index.html', () => { + const result = evaluateBaseRewrite('/index.html', '/index.html', undefined, '/docs/', '/'); + assert.equal(result.action, 'not-found-subpath'); + if (result.action === 'not-found-subpath') { + assert.equal(result.pathname, '/index.html'); + } + }); +}); +// #endregion + +// #region evaluateBaseRewrite — not-found (HTML) +describe('evaluateBaseRewrite — not-found', () => { + it('returns not-found for non-base URL with text/html accept', () => { + const result = evaluateBaseRewrite('/other', '/other', 'text/html', '/docs/', '/'); + assert.equal(result.action, 'not-found'); + if (result.action === 'not-found') { + assert.equal(result.pathname, '/other'); + } + }); + + it('returns not-found when accept includes text/html among others', () => { + const result = evaluateBaseRewrite( + '/other', + '/other', + 'text/html, application/xhtml+xml', + '/docs/', + '/', + ); + assert.equal(result.action, 'not-found'); + }); +}); +// #endregion + +// #region evaluateBaseRewrite — check-public +describe('evaluateBaseRewrite — check-public', () => { + it('returns check-public for non-base URL without HTML accept', () => { + const result = evaluateBaseRewrite('/favicon.ico', '/favicon.ico', 'image/*', '/docs/', '/'); + assert.equal(result.action, 'check-public'); + }); + + it('returns check-public when accept header is undefined', () => { + const result = evaluateBaseRewrite('/script.js', '/script.js', undefined, '/docs/', '/'); + assert.equal(result.action, 'check-public'); + }); + + it('returns check-public for non-HTML accept types', () => { + const result = evaluateBaseRewrite('/api/data', '/api/data', 'application/json', '/docs/', '/'); + assert.equal(result.action, 'check-public'); + }); +}); +// #endregion diff --git a/packages/astro/test/units/dev/trailing-slash-decision.test.ts b/packages/astro/test/units/dev/trailing-slash-decision.test.ts new file mode 100644 index 000000000000..374c4383c81f --- /dev/null +++ b/packages/astro/test/units/dev/trailing-slash-decision.test.ts @@ -0,0 +1,150 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { evaluateTrailingSlash } from '../../../dist/vite-plugin-astro-server/trailing-slash.js'; + +// #region internal paths +describe('evaluateTrailingSlash — internal paths', () => { + it('passes through /@vite/client', () => { + const result = evaluateTrailingSlash('/@vite/client', '', 'never'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('passes through /@fs/ paths', () => { + const result = evaluateTrailingSlash('/@fs/project/src/main.ts', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('passes through /@id/ paths', () => { + const result = evaluateTrailingSlash('/@id/module', '', 'never'); + assert.deepEqual(result, { action: 'next' }); + }); +}); +// #endregion + +// #region duplicate trailing slashes +describe('evaluateTrailingSlash — duplicate trailing slashes', () => { + it('redirects /about// to /about/', () => { + const result = evaluateTrailingSlash('/about//', '', 'ignore'); + assert.equal(result.action, 'redirect'); + if (result.action === 'redirect') { + assert.equal(result.status, 301); + assert.equal(result.location, '/about/'); + } + }); + + it('redirects /about/// to /about/', () => { + const result = evaluateTrailingSlash('/about///', '', 'ignore'); + assert.equal(result.action, 'redirect'); + if (result.action === 'redirect') { + assert.equal(result.location, '/about/'); + } + }); + + it('preserves query string in redirect', () => { + const result = evaluateTrailingSlash('/about//', '?foo=bar', 'ignore'); + assert.equal(result.action, 'redirect'); + if (result.action === 'redirect') { + assert.equal(result.location, '/about/?foo=bar'); + } + }); + + it('collapses only trailing slashes, not internal ones', () => { + const result = evaluateTrailingSlash('/blog//post//', '', 'ignore'); + assert.equal(result.action, 'redirect'); + if (result.action === 'redirect') { + // collapseDuplicateTrailingSlashes only collapses trailing slashes + assert.equal(result.location, '/blog//post/'); + } + }); +}); +// #endregion + +// #region trailingSlash: 'never' +describe('evaluateTrailingSlash — trailingSlash: "never"', () => { + it('rejects /about/ (has trailing slash)', () => { + const result = evaluateTrailingSlash('/about/', '', 'never'); + assert.equal(result.action, 'reject'); + if (result.action === 'reject') { + assert.equal(result.status, 404); + assert.equal(result.pathname, '/about/'); + } + }); + + it('passes /about (no trailing slash)', () => { + const result = evaluateTrailingSlash('/about', '', 'never'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('exempts root path / (always allowed)', () => { + const result = evaluateTrailingSlash('/', '', 'never'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('rejects /blog/post/ (nested with trailing slash)', () => { + const result = evaluateTrailingSlash('/blog/post/', '', 'never'); + assert.equal(result.action, 'reject'); + }); +}); +// #endregion + +// #region trailingSlash: 'always' +describe('evaluateTrailingSlash — trailingSlash: "always"', () => { + it('rejects /about (no trailing slash)', () => { + const result = evaluateTrailingSlash('/about', '', 'always'); + assert.equal(result.action, 'reject'); + if (result.action === 'reject') { + assert.equal(result.status, 404); + assert.equal(result.pathname, '/about'); + } + }); + + it('passes /about/ (has trailing slash)', () => { + const result = evaluateTrailingSlash('/about/', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('exempts paths with file extension', () => { + const result = evaluateTrailingSlash('/styles.css', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('exempts .html file extension', () => { + const result = evaluateTrailingSlash('/page.html', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('exempts .js file extension', () => { + const result = evaluateTrailingSlash('/script.js', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('passes root path /', () => { + const result = evaluateTrailingSlash('/', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); +}); +// #endregion + +// #region trailingSlash: 'ignore' +describe('evaluateTrailingSlash — trailingSlash: "ignore"', () => { + it('passes /about', () => { + const result = evaluateTrailingSlash('/about', '', 'ignore'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('passes /about/', () => { + const result = evaluateTrailingSlash('/about/', '', 'ignore'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('passes /', () => { + const result = evaluateTrailingSlash('/', '', 'ignore'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('still redirects duplicate slashes', () => { + const result = evaluateTrailingSlash('/about//', '', 'ignore'); + assert.equal(result.action, 'redirect'); + }); +}); +// #endregion diff --git a/packages/astro/test/units/errors/zod-error-map.test.ts b/packages/astro/test/units/errors/zod-error-map.test.ts new file mode 100644 index 000000000000..622858a24792 --- /dev/null +++ b/packages/astro/test/units/errors/zod-error-map.test.ts @@ -0,0 +1,193 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { errorMap } from '../../../dist/core/errors/zod-error-map.js'; + +/** Extract the message string from errorMap's return value. */ +function getMessage(result: ReturnType): string { + if (typeof result === 'string') return result; + if (result && typeof result === 'object' && 'message' in result) return result.message; + throw new Error(`Expected a message, got ${JSON.stringify(result)}`); +} + +// #region invalid_type +describe('errorMap — invalid_type', () => { + it('formats expected vs received message', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_type', + expected: 'string', + input: 42, + path: [], + message: '', + }), + ); + assert.match(msg, /Expected type `"string"`/); + assert.match(msg, /received `"number"`/); + }); + + it('includes bold path prefix for nested paths', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_type', + expected: 'boolean', + input: 'hello', + path: ['config', 'enabled'], + message: '', + }), + ); + assert.match(msg, /\*\*config\.enabled\*\*/); + assert.match(msg, /Expected type `"boolean"`/); + }); + + it('shows "Required" when received is undefined', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_type', + expected: 'string', + input: undefined, + path: ['name'], + message: 'Required', + }), + ); + assert.match(msg, /Required/); + }); + + it('handles root-level path (empty path)', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_type', + expected: 'object', + input: 'bad', + path: [], + message: '', + }), + ); + // No bold prefix when path is empty + assert.ok(!msg.includes('**')); + assert.match(msg, /Expected type `"object"`/); + }); +}); +// #endregion + +// #region invalid_union +describe('errorMap — invalid_union', () => { + it('deduplicates common type errors across union members', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_union', + input: 123, + path: [], + message: '', + errors: [ + [ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + input: 123, + path: ['key'], + message: '', + } as any, + ], + [ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + input: 123, + path: ['key'], + message: '', + } as any, + ], + ], + }), + ); + assert.match(msg, /Did not match union/); + assert.match(msg, /\*\*key\*\*/); + assert.match(msg, /Expected type/); + assert.match(msg, /received/); + }); + + it('shows expected shapes when type errors differ across union members', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_union', + input: { wrong: true }, + path: [], + message: '', + errors: [ + [ + { + code: 'invalid_type', + expected: 'string', + input: { wrong: true }, + path: ['a'], + message: '', + }, + ], + [ + { + code: 'invalid_type', + expected: 'number', + input: { wrong: true }, + path: ['b'], + message: '', + }, + ], + ], + }), + ); + assert.match(msg, /Did not match union/); + assert.match(msg, /Expected type/); + }); + + it('handles nested path for union error', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_union', + input: 'bad', + path: ['items', 0], + message: '', + errors: [ + [ + { + code: 'invalid_type', + expected: 'string', + input: 'bad', + path: ['items', 0, 'type'], + message: '', + }, + ], + ], + }), + ); + assert.match(msg, /\*\*items\.0\*\*/); + }); +}); +// #endregion + +// #region fallback +describe('errorMap — fallback behavior', () => { + it('returns message with path prefix for issues with a message', () => { + const msg = getMessage( + errorMap({ + code: 'custom' as any, + path: ['setting'], + message: 'Invalid value', + input: undefined, + }), + ); + assert.match(msg, /\*\*setting\*\*: Invalid value/); + }); + + it('returns undefined for unknown code without message', () => { + const result = errorMap({ + code: 'custom' as any, + path: [], + input: undefined, + message: undefined as any, + }); + assert.equal(result, undefined); + }); +}); +// #endregion diff --git a/packages/astro/test/units/integrations/hooks.test.js b/packages/astro/test/units/integrations/hooks.test.js new file mode 100644 index 000000000000..2b3d5d47459a --- /dev/null +++ b/packages/astro/test/units/integrations/hooks.test.js @@ -0,0 +1,308 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + normalizeCodegenDir, + normalizeInjectedTypeFilename, + toIntegrationResolvedRoute, +} from '../../../dist/integrations/hooks.js'; +import { + getAdapterStaticRecommendation, + getSupportMessage, + unwrapSupportKind, +} from '../../../dist/integrations/features-validation.js'; +import { resolveMiddlewareMode } from '../../../dist/integrations/adapter-utils.js'; +import { createRouteData } from '../mocks.js'; +import { dynamicPart, makeRoute, spreadPart, staticPart } from '../routing/test-helpers.js'; + +// #region normalizeCodegenDir +describe('normalizeCodegenDir', () => { + it('preserves alphanumeric, dots, and hyphens', () => { + assert.equal(normalizeCodegenDir('my-integration'), './integrations/my-integration/'); + }); + + it('replaces slashes', () => { + assert.equal(normalizeCodegenDir('@scope/plugin'), './integrations/_scope_plugin/'); + }); + + it('replaces spaces and special characters', () => { + assert.equal(normalizeCodegenDir('has space!@#$'), './integrations/has_space____/'); + }); + + it('preserves dots in name', () => { + assert.equal(normalizeCodegenDir('my.integration.v2'), './integrations/my.integration.v2/'); + }); + + it('handles empty string', () => { + assert.equal(normalizeCodegenDir(''), './integrations//'); + }); + + it('replaces unicode characters', () => { + assert.equal(normalizeCodegenDir('cafe\u0301'), './integrations/cafe_/'); + }); +}); +// #endregion + +// #region normalizeInjectedTypeFilename +describe('normalizeInjectedTypeFilename', () => { + it('throws when filename does not end with .d.ts', () => { + assert.throws( + () => normalizeInjectedTypeFilename('types.ts', 'my-integration'), + /does not end with/, + ); + }); + + it('throws for plain filename without extension', () => { + assert.throws( + () => normalizeInjectedTypeFilename('types', 'my-integration'), + /does not end with/, + ); + }); + + it('does not throw for valid .d.ts filename', () => { + assert.doesNotThrow(() => normalizeInjectedTypeFilename('types.d.ts', 'my-integration')); + }); + + it('returns normalized path with integration dir prefix', () => { + assert.equal( + normalizeInjectedTypeFilename('types.d.ts', 'my-integration'), + './integrations/my-integration/types.d.ts', + ); + }); + + it('sanitizes special characters in filename', () => { + assert.equal( + normalizeInjectedTypeFilename('my types!.d.ts', 'my-integration'), + './integrations/my-integration/my_types_.d.ts', + ); + }); + + it('sanitizes special characters in integration name', () => { + assert.equal( + normalizeInjectedTypeFilename('types.d.ts', '@scope/pkg'), + './integrations/_scope_pkg/types.d.ts', + ); + }); + + it('handles both filename and integration name with special chars', () => { + assert.equal( + normalizeInjectedTypeFilename('aA1-*/_"~.d.ts', 'aA1-*/_"~.'), + './integrations/aA1-_____./aA1-_____.d.ts', + ); + }); +}); +// #endregion + +// #region toIntegrationResolvedRoute +describe('toIntegrationResolvedRoute', () => { + it('maps RouteData fields to IntegrationResolvedRoute fields', () => { + const route = makeRoute({ + route: '/blog/[slug]', + segments: [[staticPart('blog')], [dynamicPart('slug')]], + trailingSlash: 'ignore', + pathname: undefined, + }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + + assert.equal(result.isPrerendered, false); + assert.equal(result.entrypoint, route.component); + assert.equal(result.pattern, '/blog/[slug]'); + assert.deepEqual(result.params, ['slug']); + assert.equal(result.origin, 'project'); + assert.equal(result.patternRegex, route.pattern); + assert.deepEqual(result.segments, route.segments); + assert.equal(result.type, 'page'); + assert.equal(result.pathname, undefined); + assert.equal(result.redirect, undefined); + assert.equal(result.redirectRoute, undefined); + assert.deepEqual(result.fallbackRoutes, []); + }); + + it('generate function produces correct path from params', () => { + const route = makeRoute({ + route: '/blog/[slug]', + segments: [[staticPart('blog')], [dynamicPart('slug')]], + trailingSlash: 'ignore', + pathname: undefined, + }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + + assert.equal(result.generate({ slug: 'hello-world' }), '/blog/hello-world'); + }); + + it('handles static routes with pathname', () => { + const route = createRouteData({ route: '/about' }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + + assert.equal(result.pathname, '/about'); + assert.equal(result.pattern, '/about'); + assert.deepEqual(result.params, []); + }); + + it('maps prerendered routes correctly', () => { + const route = createRouteData({ route: '/page', prerender: true }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + assert.equal(result.isPrerendered, true); + }); + + it('recursively maps redirectRoute', () => { + const targetRoute = createRouteData({ route: '/new-blog' }); + const route = createRouteData({ route: '/old-blog', type: 'redirect' }); + route.redirect = '/new-blog'; + route.redirectRoute = targetRoute; + + const result = toIntegrationResolvedRoute(route, 'ignore'); + assert.equal(result.type, 'redirect'); + assert.ok(result.redirectRoute); + assert.equal(result.redirectRoute.pattern, '/new-blog'); + }); + + it('recursively maps fallbackRoutes', () => { + const fallback = createRouteData({ route: '/en/blog' }); + fallback.origin = 'internal'; + const route = createRouteData({ route: '/blog' }); + route.fallbackRoutes = [fallback]; + + const result = toIntegrationResolvedRoute(route, 'ignore'); + assert.equal(result.fallbackRoutes.length, 1); + assert.equal(result.fallbackRoutes[0].pattern, '/en/blog'); + assert.equal(result.fallbackRoutes[0].origin, 'internal'); + }); + + it('applies trailingSlash "always" to generate function', () => { + const route = createRouteData({ route: '/about' }); + const result = toIntegrationResolvedRoute(route, 'always'); + assert.equal(result.generate({}), '/about/'); + }); + + it('applies trailingSlash "never" to generate function', () => { + const route = createRouteData({ route: '/about' }); + const result = toIntegrationResolvedRoute(route, 'never'); + const generated = result.generate({}); + assert.ok(!generated.endsWith('/') || generated === '/'); + }); + + it('handles endpoint route type', () => { + const route = createRouteData({ route: '/api/data', type: 'endpoint' }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + assert.equal(result.type, 'endpoint'); + }); + + it('handles spread params in generate', () => { + const route = makeRoute({ + route: '/blog/[...slug]', + segments: [[staticPart('blog')], [spreadPart('...slug')]], + trailingSlash: 'ignore', + pathname: undefined, + }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + assert.equal(result.generate({ slug: 'a/b/c' }), '/blog/a/b/c'); + }); +}); +// #endregion + +// #region resolveMiddlewareMode +describe('resolveMiddlewareMode', () => { + it('returns "classic" when features is undefined', () => { + assert.equal(resolveMiddlewareMode(undefined), 'classic'); + }); + + it('returns "classic" when features is empty object', () => { + assert.equal(resolveMiddlewareMode({}), 'classic'); + }); + + it('returns the middlewareMode value when explicitly set', () => { + assert.equal(resolveMiddlewareMode({ middlewareMode: 'edge' }), 'edge'); + }); + + it('returns "classic" when middlewareMode is "classic"', () => { + assert.equal(resolveMiddlewareMode({ middlewareMode: 'classic' }), 'classic'); + }); + + it('returns "edge" for deprecated edgeMiddleware: true', () => { + assert.equal(resolveMiddlewareMode({ edgeMiddleware: true }), 'edge'); + }); + + it('returns "classic" for deprecated edgeMiddleware: false', () => { + assert.equal(resolveMiddlewareMode({ edgeMiddleware: false }), 'classic'); + }); + + it('middlewareMode takes precedence over edgeMiddleware', () => { + assert.equal( + resolveMiddlewareMode({ middlewareMode: 'classic', edgeMiddleware: true }), + 'classic', + ); + }); +}); +// #endregion + +// #region getAdapterStaticRecommendation +describe('getAdapterStaticRecommendation', () => { + it('returns recommendation for @astrojs/vercel/static', () => { + const result = getAdapterStaticRecommendation('@astrojs/vercel/static'); + assert.ok(result); + assert.ok(result.includes('@astrojs/vercel/serverless')); + }); + + it('returns undefined for unknown adapter', () => { + assert.equal(getAdapterStaticRecommendation('unknown-adapter'), undefined); + }); + + it('returns undefined for empty string', () => { + assert.equal(getAdapterStaticRecommendation(''), undefined); + }); + + it('returns undefined for similar but non-matching adapter name', () => { + assert.equal(getAdapterStaticRecommendation('@astrojs/vercel'), undefined); + }); +}); +// #endregion + +// #region unwrapSupportKind +describe('unwrapSupportKind', () => { + it('returns undefined when supportKind is undefined', () => { + assert.equal(unwrapSupportKind(undefined), undefined); + }); + + it('returns the string directly when supportKind is a string', () => { + assert.equal(unwrapSupportKind('stable'), 'stable'); + }); + + it('returns support from object when supportKind is an object', () => { + assert.equal( + unwrapSupportKind({ support: 'experimental', message: 'Beta feature' }), + 'experimental', + ); + }); + + it('handles all stability levels as strings', () => { + assert.equal(unwrapSupportKind('stable'), 'stable'); + assert.equal(unwrapSupportKind('deprecated'), 'deprecated'); + assert.equal(unwrapSupportKind('unsupported'), 'unsupported'); + assert.equal(unwrapSupportKind('experimental'), 'experimental'); + assert.equal(unwrapSupportKind('limited'), 'limited'); + }); + + it('returns undefined for falsy values', () => { + assert.equal(unwrapSupportKind(undefined), undefined); + }); +}); +// #endregion + +// #region getSupportMessage +describe('getSupportMessage', () => { + it('returns undefined when supportKind is a string', () => { + assert.equal(getSupportMessage('stable'), undefined); + }); + + it('returns the message when supportKind is an object with message', () => { + assert.equal( + getSupportMessage({ support: 'experimental', message: 'Beta feature' }), + 'Beta feature', + ); + }); + + it('returns undefined when supportKind is an object without message', () => { + assert.equal(getSupportMessage({ support: 'stable' }), undefined); + }); +}); +// #endregion