diff options
Diffstat (limited to 'packages')
115 files changed, 1559 insertions, 727 deletions
diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index a551fa323..e026a0eed 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -327,6 +327,28 @@ - @astrojs/internal-helpers@0.2.0-beta.0 - @astrojs/markdown-remark@3.0.0-beta.0 +## 2.10.9 + +### Patch Changes + +- [#8091](https://github.com/withastro/astro/pull/8091) [`56e7c5177`](https://github.com/withastro/astro/commit/56e7c5177bd61b404978dc9b82e2d34d76a4b2f9) Thanks [@martrapp](https://github.com/martrapp)! - Handle `<noscript>` tags in `<head>` during ViewTransitions + +## 2.10.8 + +### Patch Changes + +- [#7702](https://github.com/withastro/astro/pull/7702) [`c19987df0`](https://github.com/withastro/astro/commit/c19987df0be3520cf774476cea270c03edd08354) Thanks [@shishkin](https://github.com/shishkin)! - Fix AstroConfigSchema type export + +- [#8084](https://github.com/withastro/astro/pull/8084) [`560e45924`](https://github.com/withastro/astro/commit/560e45924622141206ff5b47d134cb343d6d2a71) Thanks [@hbgl](https://github.com/hbgl)! - Stream request body instead of buffering it in memory. + +- [#8066](https://github.com/withastro/astro/pull/8066) [`afc45af20`](https://github.com/withastro/astro/commit/afc45af2022f7c43fbb6c5c04983695f3819e47e) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Add support for non-awaited imports to the Image component and `getImage` + +- [#7866](https://github.com/withastro/astro/pull/7866) [`d1f7143f9`](https://github.com/withastro/astro/commit/d1f7143f9caf2ffa0e87cc55c0e05339d3501db3) Thanks [@43081j](https://github.com/43081j)! - Add second type argument to the AstroGlobal type to type Astro.self. This change will ultimately allow our editor tooling to provide props completions and intellisense for `<Astro.self />` + +- [#8032](https://github.com/withastro/astro/pull/8032) [`3e46634fd`](https://github.com/withastro/astro/commit/3e46634fd540e5b967d2e5c9abd6235452cee2f2) Thanks [@natemoo-re](https://github.com/natemoo-re)! - `astro add` now passes down `--save-prod`, `--save-dev`, `--save-exact`, and `--no-save` flags for installation + +- [#8035](https://github.com/withastro/astro/pull/8035) [`a12027b6a`](https://github.com/withastro/astro/commit/a12027b6af411be39700919ca47e240a335e9887) Thanks [@fyndor](https://github.com/fyndor)! - Removed extra double quotes from computed style in shiki code component + ## 2.10.7 ### Patch Changes diff --git a/packages/astro/components/Code.astro b/packages/astro/components/Code.astro index a990e877a..ee7a84a09 100644 --- a/packages/astro/components/Code.astro +++ b/packages/astro/components/Code.astro @@ -74,9 +74,9 @@ const html = renderToHtml(tokens, { // Handle code wrapping // if wrap=null, do nothing. if (wrap === false) { - style += '; overflow-x: auto;"'; + style += '; overflow-x: auto;'; } else if (wrap === true) { - style += '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;"'; + style += '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'; } return `<${tag} class="${className}" style="${style}" tabindex="0">${children}</${tag}>`; }, diff --git a/packages/astro/components/ViewTransitions.astro b/packages/astro/components/ViewTransitions.astro index 05fc57aa6..47eb3c112 100644 --- a/packages/astro/components/ViewTransitions.astro +++ b/packages/astro/components/ViewTransitions.astro @@ -38,12 +38,21 @@ const { fallback = 'animate' } = Astro.props as Props; const throttle = (cb: (...args: any[]) => any, delay: number) => { let wait = false; + // During the waiting time additional events are lost. + // So repeat the callback at the end if we have swallowed events. + let onceMore = false; return (...args: any[]) => { - if (wait) return; - + if (wait) { + onceMore = true; + return; + } cb(...args); wait = true; setTimeout(() => { + if (onceMore) { + onceMore = false; + cb(...args); + } wait = false; }, delay); }; @@ -125,6 +134,10 @@ const { fallback = 'animate' } = Astro.props as Props; }; const swap = () => { + // noscript tags inside head element are not honored on swap (#7969). + // Remove them before swapping. + doc.querySelectorAll('head noscript').forEach((el) => el.remove()); + // Swap head for (const el of Array.from(document.head.children)) { const newEl = persistedHeadElement(el); @@ -159,6 +172,8 @@ const { fallback = 'animate' } = Astro.props as Props; } if (state?.scrollY != null) { scrollTo(0, state.scrollY); + // Overwrite erroneous updates by the scroll handler during transition + persistState(state); } triggerEvent('astro:beforeload'); diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index e2b3e6d63..f9568d417 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -13,6 +13,7 @@ import type { AddressInfo } from 'node:net'; import type * as rollup from 'rollup'; import type { TsConfigJson } from 'tsconfig-resolver'; import type * as vite from 'vite'; +import type { RemotePattern } from '../assets/utils/remotePattern'; import type { SerializedSSRManifest } from '../core/app/types'; import type { PageBuildData } from '../core/build/types'; import type { AstroConfigType } from '../core/config'; @@ -45,6 +46,7 @@ export type { ImageQualityPreset, ImageTransform, } from '../assets/types'; +export type { RemotePattern } from '../assets/utils/remotePattern'; export type { SSRManifest } from '../core/app/types'; export type { AstroCookies } from '../core/cookies'; @@ -367,10 +369,10 @@ export interface ViteUserConfig extends vite.UserConfig { ssr?: vite.SSROptions; } -export interface ImageServiceConfig { +export interface ImageServiceConfig<T extends Record<string, any> = Record<string, any>> { // eslint-disable-next-line @typescript-eslint/ban-types entrypoint: 'astro/assets/services/sharp' | 'astro/assets/services/squoosh' | (string & {}); - config?: Record<string, any>; + config?: T; } /** @@ -697,6 +699,10 @@ export interface AstroUserConfig { * - `file` - The `Astro.url.pathname` will include `.html`; ie `/foo.html`. * * This means that when you create relative URLs using `new URL('./relative', Astro.url)`, you will get consistent behavior between dev and build. + * + * To prevent inconsistencies with trailing slash behaviour in dev, you can restrict the [`trailingSlash` option](#trailingslash) to `'always'` or `'never'` depending on your build format: + * - `directory` - Set `trailingSlash: 'always'` + * - `file` - Set `trailingSlash: 'never'` */ format?: 'file' | 'directory'; /** @@ -833,10 +839,10 @@ export interface AstroUserConfig { * @default `never` * @version 2.6.0 * @description - * Control whether styles are sent to the browser in a separate css file or inlined into `<style>` tags. Choose from the following options: - * - `'always'` - all styles are inlined into `<style>` tags - * - `'auto'` - only stylesheets smaller than `ViteConfig.build.assetsInlineLimit` (default: 4kb) are inlined. Otherwise, styles are sent in external stylesheets. - * - `'never'` - all styles are sent in external stylesheets + * Control whether project styles are sent to the browser in a separate css file or inlined into `<style>` tags. Choose from the following options: + * - `'always'` - project styles are inlined into `<style>` tags + * - `'auto'` - only stylesheets smaller than `ViteConfig.build.assetsInlineLimit` (default: 4kb) are inlined. Otherwise, project styles are sent in external stylesheets. + * - `'never'` - project styles are sent in external stylesheets * * ```js * { @@ -1004,6 +1010,68 @@ export interface AstroUserConfig { * ``` */ service: ImageServiceConfig; + + /** + * @docs + * @name image.domains (Experimental) + * @type {string[]} + * @default `{domains: []}` + * @version 2.10.10 + * @description + * Defines a list of permitted image source domains for local image optimization. No other remote images will be optimized by Astro. + * + * This option requires an array of individual domain names as strings. Wildcards are not permitted. Instead, use [`image.remotePatterns`](#imageremotepatterns-experimental) to define a list of allowed source URL patterns. + * + * ```js + * // astro.config.mjs + * { + * image: { + * // Example: Allow remote image optimization from a single domain + * domains: ['astro.build'], + * }, + * } + * ``` + */ + domains?: string[]; + + /** + * @docs + * @name image.remotePatterns (Experimental) + * @type {RemotePattern[]} + * @default `{remotePatterns: []}` + * @version 2.10.10 + * @description + * Defines a list of permitted image source URL patterns for local image optimization. + * + * `remotePatterns` can be configured with four properties: + * 1. protocol + * 2. hostname + * 3. port + * 4. pathname + * + * ```js + * { + * image: { + * // Example: allow processing all images from your aws s3 bucket + * remotePatterns: [{ + * protocol: 'https', + * hostname: '**.amazonaws.com', + * }], + * }, + * } + * ``` + * + * You can use wildcards to define the permitted `hostname` and `pathname` values as described below. Otherwise, only the exact values provided will be configured: + * `hostname`: + * - Start with '**.' to allow all subdomains ('endsWith'). + * - Start with '*.' to allow only one level of subdomain. + * + * `pathname`: + * - End with '/**' to allow all sub-routes ('startsWith'). + * - End with '/*' to allow only one level of sub-route. + + */ + remotePatterns?: Partial<RemotePattern>[]; }; /** diff --git a/packages/astro/src/assets/build/generate.ts b/packages/astro/src/assets/build/generate.ts new file mode 100644 index 000000000..b78800a43 --- /dev/null +++ b/packages/astro/src/assets/build/generate.ts @@ -0,0 +1,174 @@ +import fs, { readFileSync } from 'node:fs'; +import { basename, join } from 'node:path/posix'; +import type { StaticBuildOptions } from '../../core/build/types.js'; +import { warn } from '../../core/logger/core.js'; +import { prependForwardSlash } from '../../core/path.js'; +import { isServerLikeOutput } from '../../prerender/utils.js'; +import { getConfiguredImageService, isESMImportedImage } from '../internal.js'; +import type { LocalImageService } from '../services/service.js'; +import type { ImageMetadata, ImageTransform } from '../types.js'; +import { loadRemoteImage, type RemoteCacheEntry } from './remote.js'; + +interface GenerationDataUncached { + cached: false; + weight: { + before: number; + after: number; + }; +} + +interface GenerationDataCached { + cached: true; +} + +type GenerationData = GenerationDataUncached | GenerationDataCached; + +export async function generateImage( + buildOpts: StaticBuildOptions, + options: ImageTransform, + filepath: string +): Promise<GenerationData | undefined> { + let useCache = true; + const assetsCacheDir = new URL('assets/', buildOpts.settings.config.cacheDir); + + // Ensure that the cache directory exists + try { + await fs.promises.mkdir(assetsCacheDir, { recursive: true }); + } catch (err) { + warn( + buildOpts.logging, + 'astro:assets', + `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${err}` + ); + useCache = false; + } + + let serverRoot: URL, clientRoot: URL; + if (isServerLikeOutput(buildOpts.settings.config)) { + serverRoot = buildOpts.settings.config.build.server; + clientRoot = buildOpts.settings.config.build.client; + } else { + serverRoot = buildOpts.settings.config.outDir; + clientRoot = buildOpts.settings.config.outDir; + } + + const isLocalImage = isESMImportedImage(options.src); + + const finalFileURL = new URL('.' + filepath, clientRoot); + const finalFolderURL = new URL('./', finalFileURL); + + // For remote images, instead of saving the image directly, we save a JSON file with the image data and expiration date from the server + const cacheFile = basename(filepath) + (isLocalImage ? '' : '.json'); + const cachedFileURL = new URL(cacheFile, assetsCacheDir); + + await fs.promises.mkdir(finalFolderURL, { recursive: true }); + + // Check if we have a cached entry first + try { + if (isLocalImage) { + await fs.promises.copyFile(cachedFileURL, finalFileURL); + + return { + cached: true, + }; + } else { + const JSONData = JSON.parse(readFileSync(cachedFileURL, 'utf-8')) as RemoteCacheEntry; + + // If the cache entry is not expired, use it + if (JSONData.expires < Date.now()) { + await fs.promises.writeFile(finalFileURL, Buffer.from(JSONData.data, 'base64')); + + return { + cached: true, + }; + } + } + } catch (e: any) { + if (e.code !== 'ENOENT') { + throw new Error(`An error was encountered while reading the cache file. Error: ${e}`); + } + // If the cache file doesn't exist, just move on, and we'll generate it + } + + // The original filepath or URL from the image transform + const originalImagePath = isLocalImage + ? (options.src as ImageMetadata).src + : (options.src as string); + + let imageData; + let resultData: { data: Buffer | undefined; expires: number | undefined } = { + data: undefined, + expires: undefined, + }; + + // If the image is local, we can just read it directly, otherwise we need to download it + if (isLocalImage) { + imageData = await fs.promises.readFile( + new URL( + '.' + + prependForwardSlash( + join(buildOpts.settings.config.build.assets, basename(originalImagePath)) + ), + serverRoot + ) + ); + } else { + const remoteImage = await loadRemoteImage(originalImagePath); + resultData.expires = remoteImage.expires; + imageData = remoteImage.data; + } + + const imageService = (await getConfiguredImageService()) as LocalImageService; + resultData.data = ( + await imageService.transform( + imageData, + { ...options, src: originalImagePath }, + buildOpts.settings.config.image + ) + ).data; + + try { + // Write the cache entry + if (useCache) { + if (isLocalImage) { + await fs.promises.writeFile(cachedFileURL, resultData.data); + } else { + await fs.promises.writeFile( + cachedFileURL, + JSON.stringify({ + data: Buffer.from(resultData.data).toString('base64'), + expires: resultData.expires, + }) + ); + } + } + } catch (e) { + warn( + buildOpts.logging, + 'astro:assets', + `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${e}` + ); + } finally { + // Write the final file + await fs.promises.writeFile(finalFileURL, resultData.data); + } + + return { + cached: false, + weight: { + // Divide by 1024 to get size in kilobytes + before: Math.trunc(imageData.byteLength / 1024), + after: Math.trunc(Buffer.from(resultData.data).byteLength / 1024), + }, + }; +} + +export function getStaticImageList(): Iterable< + [string, { path: string; options: ImageTransform }] +> { + if (!globalThis?.astroAsset?.staticImages) { + return []; + } + + return globalThis.astroAsset.staticImages?.entries(); +} diff --git a/packages/astro/src/assets/build/remote.ts b/packages/astro/src/assets/build/remote.ts new file mode 100644 index 000000000..c3d4bb9ba --- /dev/null +++ b/packages/astro/src/assets/build/remote.ts @@ -0,0 +1,48 @@ +import CachePolicy from 'http-cache-semantics'; + +export type RemoteCacheEntry = { data: string; expires: number }; + +export async function loadRemoteImage(src: string) { + const req = new Request(src); + const res = await fetch(req); + + if (!res.ok) { + throw new Error( + `Failed to load remote image ${src}. The request did not return a 200 OK response. (received ${res.status}))` + ); + } + + // calculate an expiration date based on the response's TTL + const policy = new CachePolicy(webToCachePolicyRequest(req), webToCachePolicyResponse(res)); + const expires = policy.storable() ? policy.timeToLive() : 0; + + return { + data: Buffer.from(await res.arrayBuffer()), + expires: Date.now() + expires, + }; +} + +function webToCachePolicyRequest({ url, method, headers: _headers }: Request): CachePolicy.Request { + let headers: CachePolicy.Headers = {}; + // Be defensive here due to a cookie header bug in node@18.14.1 + undici + try { + headers = Object.fromEntries(_headers.entries()); + } catch {} + return { + method, + url, + headers, + }; +} + +function webToCachePolicyResponse({ status, headers: _headers }: Response): CachePolicy.Response { + let headers: CachePolicy.Headers = {}; + // Be defensive here due to a cookie header bug in node@18.14.1 + undici + try { + headers = Object.fromEntries(_headers.entries()); + } catch {} + return { + status, + headers, + }; +} diff --git a/packages/astro/src/assets/generate.ts b/packages/astro/src/assets/generate.ts deleted file mode 100644 index 04488ed8f..000000000 --- a/packages/astro/src/assets/generate.ts +++ /dev/null @@ -1,132 +0,0 @@ -import fs from 'node:fs'; -import { basename, join } from 'node:path/posix'; -import type { StaticBuildOptions } from '../core/build/types.js'; -import { warn } from '../core/logger/core.js'; -import { prependForwardSlash } from '../core/path.js'; -import { isServerLikeOutput } from '../prerender/utils.js'; -import { getConfiguredImageService, isESMImportedImage } from './internal.js'; -import type { LocalImageService } from './services/service.js'; -import type { ImageTransform } from './types.js'; - -interface GenerationDataUncached { - cached: false; - weight: { - before: number; - after: number; - }; -} - -interface GenerationDataCached { - cached: true; -} - -type GenerationData = GenerationDataUncached | GenerationDataCached; - -export async function generateImage( - buildOpts: StaticBuildOptions, - options: ImageTransform, - filepath: string -): Promise<GenerationData | undefined> { - if (typeof buildOpts.settings.config.image === 'undefined') { - throw new Error( - "Astro hasn't set a default service for `astro:assets`. This is an internal error and you should report it." - ); - } - if (!isESMImportedImage(options.src)) { - return undefined; - } - - let useCache = true; - const assetsCacheDir = new URL('assets/', buildOpts.settings.config.cacheDir); - - // Ensure that the cache directory exists - try { - await fs.promises.mkdir(assetsCacheDir, { recursive: true }); - } catch (err) { - warn( - buildOpts.logging, - 'astro:assets', - `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${err}` - ); - useCache = false; - } - - let serverRoot: URL, clientRoot: URL; - if (isServerLikeOutput(buildOpts.settings.config)) { - serverRoot = buildOpts.settings.config.build.server; - clientRoot = buildOpts.settings.config.build.client; - } else { - serverRoot = buildOpts.settings.config.outDir; - clientRoot = buildOpts.settings.config.outDir; - } - - const finalFileURL = new URL('.' + filepath, clientRoot); - const finalFolderURL = new URL('./', finalFileURL); - const cachedFileURL = new URL(basename(filepath), assetsCacheDir); - - try { - await fs.promises.copyFile(cachedFileURL, finalFileURL); - - return { - cached: true, - }; - } catch (e) { - // no-op - } - - // The original file's path (the `src` attribute of the ESM imported image passed by the user) - const originalImagePath = options.src.src; - - const fileData = await fs.promises.readFile( - new URL( - '.' + - prependForwardSlash( - join(buildOpts.settings.config.build.assets, basename(originalImagePath)) - ), - serverRoot - ) - ); - - const imageService = (await getConfiguredImageService()) as LocalImageService; - const resultData = await imageService.transform( - fileData, - { ...options, src: originalImagePath }, - buildOpts.settings.config.image.service.config - ); - - await fs.promises.mkdir(finalFolderURL, { recursive: true }); - - if (useCache) { - try { - await fs.promises.writeFile(cachedFileURL, resultData.data); - await fs.promises.copyFile(cachedFileURL, finalFileURL); - } catch (e) { - warn( - buildOpts.logging, - 'astro:assets', - `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${e}` - ); - await fs.promises.writeFile(finalFileURL, resultData.data); - } - } else { - await fs.promises.writeFile(finalFileURL, resultData.data); - } - - return { - cached: false, - weight: { - before: Math.trunc(fileData.byteLength / 1024), - after: Math.trunc(resultData.data.byteLength / 1024), - }, - }; -} - -export function getStaticImageList(): Iterable< - [string, { path: string; options: ImageTransform }] -> { - if (!globalThis?.astroAsset?.staticImages) { - return []; - } - - return globalThis.astroAsset.staticImages?.entries(); -} diff --git a/packages/astro/src/assets/image-endpoint.ts b/packages/astro/src/assets/image-endpoint.ts index d9a101679..d83517379 100644 --- a/packages/astro/src/assets/image-endpoint.ts +++ b/packages/astro/src/assets/image-endpoint.ts @@ -1,8 +1,10 @@ import mime from 'mime/lite.js'; import type { APIRoute } from '../@types/astro.js'; import { etag } from './utils/etag.js'; +import { isRemotePath } from '../core/path.js'; +import { getConfiguredImageService, isRemoteAllowed } from './internal.js'; // @ts-expect-error -import { getConfiguredImageService, imageServiceConfig } from 'astro:assets'; +import { imageConfig } from 'astro:assets'; async function loadRemoteImage(src: URL) { try { @@ -30,7 +32,7 @@ export const GET: APIRoute = async ({ request }) => { } const url = new URL(request.url); - const transform = await imageService.parseURL(url, imageServiceConfig); + const transform = await imageService.parseURL(url, imageConfig); if (!transform?.src) { throw new Error('Incorrect transform returned by `parseURL`'); @@ -42,17 +44,18 @@ export const GET: APIRoute = async ({ request }) => { const sourceUrl = isRemotePath(transform.src) ? new URL(transform.src) : new URL(transform.src, url.origin); + + if (isRemotePath(transform.src) && isRemoteAllowed(transform.src, imageConfig) === false) { + return new Response('Forbidden', { status: 403 }); + } + inputBuffer = await loadRemoteImage(sourceUrl); if (!inputBuffer) { return new Response('Not Found', { status: 404 }); } - const { data, format } = await imageService.transform( - inputBuffer, - transform, - imageServiceConfig - ); + const { data, format } = await imageService.transform(inputBuffer, transform, imageConfig); return new Response(data, { status: 200, diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index a49828a46..dd5e427f6 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -1,4 +1,5 @@ -import type { AstroSettings } from '../@types/astro.js'; +import { isRemotePath } from '@astrojs/internal-helpers/path'; +import type { AstroConfig, AstroSettings } from '../@types/astro.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { isLocalService, type ImageService } from './services/service.js'; import type { @@ -7,6 +8,7 @@ import type { ImageTransform, UnresolvedImageTransform, } from './types.js'; +import { matchHostname, matchPattern } from './utils/remotePattern.js'; export function injectImageEndpoint(settings: AstroSettings) { // TODO: Add a setting to disable the image endpoint @@ -23,6 +25,26 @@ export function isESMImportedImage(src: ImageMetadata | string): src is ImageMet return typeof src === 'object'; } +export function isRemoteImage(src: ImageMetadata | string): src is string { + return typeof src === 'string'; +} + +export function isRemoteAllowed( + src: string, + { + domains = [], + remotePatterns = [], + }: Partial<Pick<AstroConfig['image'], 'domains' | 'remotePatterns'>> +): boolean { + if (!isRemotePath(src)) return false; + + const url = new URL(src); + return ( + domains.some((domain) => matchHostname(url, domain)) || + remotePatterns.some((remotePattern) => matchPattern(url, remotePattern)) + ); +} + export async function getConfiguredImageService(): Promise<ImageService> { if (!globalThis?.astroAsset?.imageService) { const { default: service }: { default: ImageService } = await import( @@ -44,7 +66,7 @@ export async function getConfiguredImageService(): Promise<ImageService> { export async function getImage( options: ImageTransform | UnresolvedImageTransform, - serviceConfig: Record<string, any> + imageConfig: AstroConfig['image'] ): Promise<GetImageResult> { if (!options || typeof options !== 'object') { throw new AstroError({ @@ -65,13 +87,18 @@ export async function getImage( }; const validatedOptions = service.validateOptions - ? await service.validateOptions(resolvedOptions, serviceConfig) + ? await service.validateOptions(resolvedOptions, imageConfig) : resolvedOptions; - let imageURL = await service.getURL(validatedOptions, serviceConfig); + let imageURL = await service.getURL(validatedOptions, imageConfig); // In build and for local services, we need to collect the requested parameters so we can generate the final images - if (isLocalService(service) && globalThis.astroAsset.addStaticImage) { + if ( + isLocalService(service) && + globalThis.astroAsset.addStaticImage && + // If `getURL` returned the same URL as the user provided, it means the service doesn't need to do anything + !(isRemoteImage(validatedOptions.src) && imageURL === validatedOptions.src) + ) { imageURL = globalThis.astroAsset.addStaticImage(validatedOptions); } @@ -81,7 +108,7 @@ export async function getImage( src: imageURL, attributes: service.getHTMLAttributes !== undefined - ? service.getHTMLAttributes(validatedOptions, serviceConfig) + ? service.getHTMLAttributes(validatedOptions, imageConfig) : {}, }; } diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts index d3479c880..5af4a898b 100644 --- a/packages/astro/src/assets/services/service.ts +++ b/packages/astro/src/assets/services/service.ts @@ -1,7 +1,8 @@ +import type { AstroConfig } from '../../@types/astro.js'; import { AstroError, AstroErrorData } from '../../core/errors/index.js'; import { joinPaths } from '../../core/path.js'; import { VALID_SUPPORTED_FORMATS } from '../consts.js'; -import { isESMImportedImage } from '../internal.js'; +import { isESMImportedImage, isRemoteAllowed } from '../internal.js'; import type { ImageOutputFormat, ImageTransform } from '../types.js'; export type ImageService = LocalImageService | ExternalImageService; @@ -23,7 +24,11 @@ export function parseQuality(quality: string): string | number { return result; } -interface SharedServiceProps { +type ImageConfig<T> = Omit<AstroConfig['image'], 'service'> & { + service: { entrypoint: string; config: T }; +}; + +interface SharedServiceProps<T extends Record<string, any> = Record<string, any>> { /** * Return the URL to the endpoint or URL your images are generated from. * @@ -32,7 +37,7 @@ interface SharedServiceProps { * For external services, this should point to the URL your images are coming from, for instance, `/_vercel/image` * */ - getURL: (options: ImageTransform, serviceConfig: Record<string, any>) => string | Promise<string>; + getURL: (options: ImageTransform, imageConfig: ImageConfig<T>) => string | Promise<string>; /** * Return any additional HTML attributes separate from `src` that your service requires to show the image properly. * @@ -41,7 +46,7 @@ interface SharedServiceProps { */ getHTMLAttributes?: ( options: ImageTransform, - serviceConfig: Record<string, any> + imageConfig: ImageConfig<T> ) => Record<string, any> | Promise<Record<string, any>>; /** * Validate and return the options passed by the user. @@ -53,18 +58,20 @@ interface SharedServiceProps { */ validateOptions?: ( options: ImageTransform, - serviceConfig: Record<string, any> + imageConfig: ImageConfig<T> ) => ImageTransform | Promise<ImageTransform>; } -export type ExternalImageService = SharedServiceProps; +export type ExternalImageService<T extends Record<string, any> = Record<string, any>> = + SharedServiceProps<T>; export type LocalImageTransform = { src: string; [key: string]: any; }; -export interface LocalImageService extends SharedServiceProps { +export interface LocalImageService<T extends Record<string, any> = Record<string, any>> + extends SharedServiceProps<T> { /** * Parse the requested parameters passed in the URL from `getURL` back into an object to be used later by `transform`. * @@ -72,7 +79,7 @@ export interface LocalImageService extends SharedServiceProps { */ parseURL: ( url: URL, - serviceConfig: Record<string, any> + imageConfig: ImageConfig<T> ) => LocalImageTransform | undefined | Promise<LocalImageTransform> | Promise<undefined>; /** * Performs the image transformations on the input image and returns both the binary data and @@ -81,7 +88,7 @@ export interface LocalImageService extends SharedServiceProps { transform: ( inputBuffer: Buffer, transform: LocalImageTransform, - serviceConfig: Record<string, any> + imageConfig: ImageConfig<T> ) => Promise<{ data: Buffer; format: ImageOutputFormat }>; } @@ -202,21 +209,31 @@ export const baseService: Omit<LocalImageService, 'transform'> = { decoding: attributes.decoding ?? 'async', }; }, - getURL(options: ImageTransform) { - // Both our currently available local services don't handle remote images, so we return the path as is. - if (!isESMImportedImage(options.src)) { + getURL(options, imageConfig) { + const searchParams = new URLSearchParams(); + + if (isESMImportedImage(options.src)) { + searchParams.append('href', options.src.src); + } else if (isRemoteAllowed(options.src, imageConfig)) { + searchParams.append('href', options.src); + } else { + // If it's not an imported image, nor is it allowed using the current domains or remote patterns, we'll just return the original URL return options.src; } - const searchParams = new URLSearchParams(); - searchParams.append('href', options.src.src); + const params: Record<string, keyof typeof options> = { + w: 'width', + h: 'height', + q: 'quality', + f: 'format', + }; - options.width && searchParams.append('w', options.width.toString()); - options.height && searchParams.append('h', options.height.toString()); - options.quality && searchParams.append('q', options.quality.toString()); - options.format && searchParams.append('f', options.format); + Object.entries(params).forEach(([param, key]) => { + options[key] && searchParams.append(param, options[key].toString()); + }); - return joinPaths(import.meta.env.BASE_URL, '/_image?') + searchParams; + const imageEndpoint = joinPaths(import.meta.env.BASE_URL, '/_image'); + return `${imageEndpoint}?${searchParams}`; }, parseURL(url) { const params = url.searchParams; diff --git a/packages/astro/src/assets/utils/remotePattern.ts b/packages/astro/src/assets/utils/remotePattern.ts new file mode 100644 index 000000000..7708b42e7 --- /dev/null +++ b/packages/astro/src/assets/utils/remotePattern.ts @@ -0,0 +1,63 @@ +export type RemotePattern = { + hostname?: string; + pathname?: string; + protocol?: string; + port?: string; +}; + +export function matchPattern(url: URL, remotePattern: RemotePattern) { + return ( + matchProtocol(url, remotePattern.protocol) && + matchHostname(url, remotePattern.hostname, true) && + matchPort(url, remotePattern.port) && + matchPathname(url, remotePattern.pathname, true) + ); +} + +export function matchPort(url: URL, port?: string) { + return !port || port === url.port; +} + +export function matchProtocol(url: URL, protocol?: string) { + return !protocol || protocol === url.protocol.slice(0, -1); +} + +export function matchHostname(url: URL, hostname?: string, allowWildcard?: boolean) { + if (!hostname) { + return true; + } else if (!allowWildcard || !hostname.startsWith('*')) { + return hostname === url.hostname; + } else if (hostname.startsWith('**.')) { + const slicedHostname = hostname.slice(2); // ** length + return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname); + } else if (hostname.startsWith('*.')) { + const slicedHostname = hostname.slice(1); // * length + const additionalSubdomains = url.hostname + .replace(slicedHostname, '') + .split('.') + .filter(Boolean); + return additionalSubdomains.length === 1; + } + + return false; +} + +export function matchPathname(url: URL, pathname?: string, allowWildcard?: boolean) { + if (!pathname) { + return true; + } else if (!allowWildcard || !pathname.endsWith('*')) { + return pathname === url.pathname; + } else if (pathname.endsWith('/**')) { + const slicedPathname = pathname.slice(0, -2); // ** length + return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname); + } else if (pathname.endsWith('/*')) { + const slicedPathname = pathname.slice(0, -1); // * length + const additionalPathChunks = url.pathname + .replace(slicedPathname, '') + .split('/') + .filter(Boolean); + return additionalPathChunks.length === 1; + } + + return false; +} diff --git a/packages/astro/src/assets/utils/transformToPath.ts b/packages/astro/src/assets/utils/transformToPath.ts index 04ddee0a1..d5535137b 100644 --- a/packages/astro/src/assets/utils/transformToPath.ts +++ b/packages/astro/src/assets/utils/transformToPath.ts @@ -5,14 +5,13 @@ import { isESMImportedImage } from '../internal.js'; import type { ImageTransform } from '../types.js'; export function propsToFilename(transform: ImageTransform, hash: string) { - if (!isESMImportedImage(transform.src)) { - return transform.src; - } - - let filename = removeQueryString(transform.src.src); + let filename = removeQueryString( + isESMImportedImage(transform.src) ? transform.src.src : transform.src + ); const ext = extname(filename); filename = basename(filename, ext); - const outputExt = transform.format ? `.${transform.format}` : ext; + + let outputExt = transform.format ? `.${transform.format}` : ext; return `/${filename}_${hash}${outputExt}`; } diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 24de955ba..f194e5288 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -9,7 +9,6 @@ import { removeQueryString, } from '../core/path.js'; import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js'; -import { isESMImportedImage } from './internal.js'; import { emitESMImage } from './utils/emitAsset.js'; import { hashTransform, propsToFilename } from './utils/transformToPath.js'; @@ -45,8 +44,8 @@ export default function assets({ import { getImage as getImageInternal } from "astro/assets"; export { default as Image } from "astro/components/Image.astro"; - export const imageServiceConfig = ${JSON.stringify(settings.config.image.service.config)}; - export const getImage = async (options) => await getImageInternal(options, imageServiceConfig); + export const imageConfig = ${JSON.stringify(settings.config.image)}; + export const getImage = async (options) => await getImageInternal(options, imageConfig); `; } }, @@ -69,15 +68,10 @@ export default function assets({ if (globalThis.astroAsset.staticImages.has(hash)) { filePath = globalThis.astroAsset.staticImages.get(hash)!.path; } else { - // If the image is not imported, we can return the path as-is, since static references - // should only point ot valid paths for builds or remote images - if (!isESMImportedImage(options.src)) { - return options.src; - } - filePath = prependForwardSlash( joinPaths(settings.config.build.assets, propsToFilename(options, hash)) ); + globalThis.astroAsset.staticImages.set(hash, { path: filePath, options: options }); } diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index c3b687d01..92f671b85 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -258,10 +258,19 @@ export class App { const errorRouteData = matchRoute('/' + status, this.#manifestData); const url = new URL(request.url); if (errorRouteData) { - if (errorRouteData.prerender && !errorRouteData.route.endsWith(`/${status}`)) { - const statusURL = new URL(`${this.#baseWithoutTrailingSlash}/${status}`, url); + if (errorRouteData.prerender) { + const maybeDotHtml = errorRouteData.route.endsWith(`/${status}`) ? '.html' : ''; + const statusURL = new URL( + `${this.#baseWithoutTrailingSlash}/${status}${maybeDotHtml}`, + url + ); const response = await fetch(statusURL.toString()); - return this.#mergeResponses(response, originalResponse); + + // response for /404.html and 500.html is 200, which is not meaningful + // so we create an override + const override = { status }; + + return this.#mergeResponses(response, originalResponse, override); } const mod = await this.#getModuleForRoute(errorRouteData); try { @@ -287,14 +296,31 @@ export class App { return response; } - #mergeResponses(newResponse: Response, oldResponse?: Response) { - if (!oldResponse) return newResponse; - const { status, statusText, headers } = oldResponse; + #mergeResponses(newResponse: Response, oldResponse?: Response, override?: { status: 404 | 500 }) { + if (!oldResponse) { + if (override !== undefined) { + return new Response(newResponse.body, { + status: override.status, + statusText: newResponse.statusText, + headers: newResponse.headers, + }); + } + return newResponse; + } + + const { statusText, headers } = oldResponse; + + // If the the new response did not have a meaningful status, an override may have been provided + // If the original status was 200 (default), override it with the new status (probably 404 or 500) + // Otherwise, the user set a specific status while rendering and we should respect that one + const status = override?.status + ? override.status + : oldResponse.status === 200 + ? newResponse.status + : oldResponse.status; return new Response(newResponse.body, { - // If the original status was 200 (default), override it with the new status (probably 404 or 500) - // Otherwise, the user set a specific status while rendering and we should respect that one - status: status === 200 ? newResponse.status : status, + status, statusText: status === 200 ? newResponse.statusText : statusText, headers: new Headers(Array.from(headers)), }); diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index f31af36db..054064a08 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -10,20 +10,33 @@ export { apply as applyPolyfills } from '../polyfill.js'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); -function createRequestFromNodeRequest(req: NodeIncomingMessage, body?: Uint8Array): Request { +type CreateNodeRequestOptions = { + emptyBody?: boolean; +}; + +type BodyProps = Partial<RequestInit>; + +function createRequestFromNodeRequest( + req: NodeIncomingMessage, + options?: CreateNodeRequestOptions +): Request { const protocol = req.socket instanceof TLSSocket || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; const hostname = req.headers.host || req.headers[':authority']; const url = `${protocol}://${hostname}${req.url}`; - const rawHeaders = req.headers as Record<string, any>; - const entries = Object.entries(rawHeaders); + const headers = makeRequestHeaders(req); const method = req.method || 'GET'; + let bodyProps: BodyProps = {}; + const bodyAllowed = method !== 'HEAD' && method !== 'GET' && !options?.emptyBody; + if (bodyAllowed) { + bodyProps = makeRequestBody(req); + } const request = new Request(url, { method, - headers: new Headers(entries), - body: ['HEAD', 'GET'].includes(method) ? null : body, + headers, + ...bodyProps, }); if (req.socket?.remoteAddress) { Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress); @@ -31,63 +44,83 @@ function createRequestFromNodeRequest(req: NodeIncomingMessage, body?: Uint8Arra return request; } -class NodeIncomingMessage extends IncomingMessage { - /** - * The read-only body property of the Request interface contains a ReadableStream with the body contents that have been added to the request. - */ - body?: unknown; +function makeRequestHeaders(req: NodeIncomingMessage): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (value === undefined) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + headers.append(name, item); + } + } else { + headers.append(name, value); + } + } + return headers; } -export class NodeApp extends App { - match(req: NodeIncomingMessage | Request, opts: MatchOptions = {}) { - return super.match(req instanceof Request ? req : createRequestFromNodeRequest(req), opts); - } - render(req: NodeIncomingMessage | Request, routeData?: RouteData, locals?: object) { +function makeRequestBody(req: NodeIncomingMessage): BodyProps { + if (req.body !== undefined) { if (typeof req.body === 'string' && req.body.length > 0) { - return super.render( - req instanceof Request ? req : createRequestFromNodeRequest(req, Buffer.from(req.body)), - routeData, - locals - ); + return { body: Buffer.from(req.body) }; } if (typeof req.body === 'object' && req.body !== null && Object.keys(req.body).length > 0) { - return super.render( - req instanceof Request - ? req - : createRequestFromNodeRequest(req, Buffer.from(JSON.stringify(req.body))), - routeData, - locals - ); + return { body: Buffer.from(JSON.stringify(req.body)) }; } - if ('on' in req) { - let body = Buffer.from([]); - let reqBodyComplete = new Promise((resolve, reject) => { - req.on('data', (d) => { - body = Buffer.concat([body, d]); - }); - req.on('end', () => { - resolve(body); - }); - req.on('error', (err) => { - reject(err); - }); - }); + // This covers all async iterables including Readable and ReadableStream. + if ( + typeof req.body === 'object' && + req.body !== null && + typeof (req.body as any)[Symbol.asyncIterator] !== 'undefined' + ) { + return asyncIterableToBodyProps(req.body as AsyncIterable<any>); + } + } + + // Return default body. + return asyncIterableToBodyProps(req); +} + +function asyncIterableToBodyProps(iterable: AsyncIterable<any>): BodyProps { + return { + // Node uses undici for the Request implementation. Undici accepts + // a non-standard async iterable for the body. + // @ts-expect-error + body: iterable, + // The duplex property is required when using a ReadableStream or async + // iterable for the body. The type definitions do not include the duplex + // property because they are not up-to-date. + // @ts-expect-error + duplex: 'half', + } satisfies BodyProps; +} + +class NodeIncomingMessage extends IncomingMessage { + /** + * Allow the request body to be explicitly overridden. For example, this + * is used by the Express JSON middleware. + */ + body?: unknown; +} - return reqBodyComplete.then(() => { - return super.render( - req instanceof Request ? req : createRequestFromNodeRequest(req, body), - routeData, - locals - ); +export class NodeApp extends App { + match(req: NodeIncomingMessage | Request, opts: MatchOptions = {}) { + if (!(req instanceof Request)) { + req = createRequestFromNodeRequest(req, { + emptyBody: true, }); } - return super.render( - req instanceof Request ? req : createRequestFromNodeRequest(req), - routeData, - locals - ); + return super.match(req, opts); + } + render(req: NodeIncomingMessage | Request, routeData?: RouteData, locals?: object) { + if (!(req instanceof Request)) { + req = createRequestFromNodeRequest(req); + } + return super.render(req, routeData, locals); } } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 00be46ea9..4e89dfb61 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -19,7 +19,7 @@ import type { import { generateImage as generateImageInternal, getStaticImageList, -} from '../../assets/generate.js'; +} from '../../assets/build/generate.js'; import { hasPrerenderedPages, type BuildInternals } from '../../core/build/internal.js'; import { isRelativePath, diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index cbb259e03..a1c7c3e56 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -311,8 +311,12 @@ async function runPostBuildHooks( async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInternals) { const allStaticFiles = new Set(); for (const pageData of eachPageData(internals)) { - if (pageData.route.prerender) - allStaticFiles.add(internals.pageToBundleMap.get(pageData.moduleSpecifier)); + if (pageData.route.prerender) { + const { moduleSpecifier } = pageData; + const pageBundleId = internals.pageToBundleMap.get(moduleSpecifier); + const entryBundleId = internals.entrySpecifierToBundleMap.get(moduleSpecifier); + allStaticFiles.add(pageBundleId ?? entryBundleId); + } } const ssr = isServerLikeOutput(opts.settings.config); const out = ssr @@ -340,7 +344,8 @@ async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInter // Replace exports (only prerendered pages) with a noop let value = 'const noop = () => {};'; for (const e of exports) { - value += `\nexport const ${e.n} = noop;`; + if (e.n === 'default') value += `\n export default noop;`; + else value += `\nexport const ${e.n} = noop;`; } await fs.promises.writeFile(url, value, { encoding: 'utf8' }); }) @@ -355,6 +360,8 @@ async function cleanServerOutput(opts: StaticBuildOptions) { // The SSR output is all .mjs files, the client output is not. const files = await glob('**/*.mjs', { cwd: fileURLToPath(out), + // Important! Also cleanup dotfiles like `node_modules/.pnpm/**` + dot: true, }); if (files.length) { // Remove all the SSR generated .mjs files diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 48b0f3a59..bff55b392 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -189,6 +189,30 @@ export const AstroConfigSchema = z.object({ ]), config: z.record(z.any()).default({}), }), + domains: z.array(z.string()).default([]), + remotePatterns: z + .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(), + 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(), + }) + ) + .default([]), }) .default({ service: { entrypoint: 'astro/assets/services/sharp', config: {} }, diff --git a/packages/astro/src/core/config/tsconfig.ts b/packages/astro/src/core/config/tsconfig.ts index 5a5d3fc64..a0c78f08c 100644 --- a/packages/astro/src/core/config/tsconfig.ts +++ b/packages/astro/src/core/config/tsconfig.ts @@ -1,4 +1,3 @@ -import { deepmerge } from 'deepmerge-ts'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import * as tsr from 'tsconfig-resolver'; @@ -96,5 +95,28 @@ export function updateTSConfigForFramework( return target; } - return deepmerge(target, presets.get(framework)!); + return deepMergeObjects(target, presets.get(framework)!); +} + +// Simple deep merge implementation that merges objects and strings +function deepMergeObjects<T extends Record<string, any>>(a: T, b: T): T { + const merged: T = { ...a }; + + for (const key in b) { + const value = b[key]; + + if (a[key] == null) { + merged[key] = value; + continue; + } + + if (typeof a[key] === 'object' && typeof value === 'object') { + merged[key] = deepMergeObjects(a[key], value); + continue; + } + + merged[key] = value; + } + + return merged; } diff --git a/packages/astro/src/vite-plugin-integrations-container/index.ts b/packages/astro/src/vite-plugin-integrations-container/index.ts index d6bfd76d7..6cc2da152 100644 --- a/packages/astro/src/vite-plugin-integrations-container/index.ts +++ b/packages/astro/src/vite-plugin-integrations-container/index.ts @@ -16,9 +16,9 @@ export default function astroIntegrationsContainerPlugin({ }): VitePlugin { return { name: 'astro:integration-container', - configureServer(server) { + async configureServer(server) { if (server.config.isProduction) return; - runHookServerSetup({ config: settings.config, server, logging }); + await runHookServerSetup({ config: settings.config, server, logging }); }, async buildStart() { if (settings.injectedRoutes.length === settings.resolvedInjectedRoutes.length) return; diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js index ff0adde52..26fafe41a 100644 --- a/packages/astro/test/core-image.test.js +++ b/packages/astro/test/core-image.test.js @@ -22,6 +22,7 @@ describe('astro:image', () => { root: './fixtures/core-image/', image: { service: testImageService({ foo: 'bar' }), + domains: ['avatars.githubusercontent.com'], }, }); @@ -195,6 +196,15 @@ describe('astro:image', () => { $ = cheerio.load(html); }); + it('has proper link and works', async () => { + let $img = $('#remote img'); + + let src = $img.attr('src'); + expect(src.startsWith('/_image?')).to.be.true; + const imageRequest = await fixture.fetch(src); + expect(imageRequest.status).to.equal(200); + }); + it('includes the provided alt', async () => { let $img = $('#remote img'); expect($img.attr('alt')).to.equal('fred'); @@ -572,6 +582,7 @@ describe('astro:image', () => { root: './fixtures/core-image-ssg/', image: { service: testImageService(), + domains: ['astro.build'], }, }); // Remove cache directory @@ -589,6 +600,15 @@ describe('astro:image', () => { expect(data).to.be.an.instanceOf(Buffer); }); + it('writes out allowed remote images', async () => { + const html = await fixture.readFile('/remote/index.html'); + const $ = cheerio.load(html); + const src = $('#remote img').attr('src'); + expect(src.length).to.be.greaterThan(0); + const data = await fixture.readFile(src, null); + expect(data).to.be.an.instanceOf(Buffer); + }); + it('writes out images to dist folder with proper extension if no format was passed', async () => { const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); @@ -693,12 +713,15 @@ describe('astro:image', () => { }); it('has cache entries', async () => { - const generatedImages = (await fixture.glob('_astro/**/*.webp')).map((path) => - basename(path) - ); - const cachedImages = (await fixture.glob('../node_modules/.astro/assets/**/*.webp')).map( - (path) => basename(path) - ); + const generatedImages = (await fixture.glob('_astro/**/*.webp')) + .map((path) => basename(path)) + .sort(); + const cachedImages = [ + ...(await fixture.glob('../node_modules/.astro/assets/**/*.webp')), + ...(await fixture.glob('../node_modules/.astro/assets/**/*.json')), + ] + .map((path) => basename(path).replace('.webp.json', '.webp')) + .sort(); expect(generatedImages).to.deep.equal(cachedImages); }); diff --git a/packages/astro/test/css-inline-stylesheets.test.js b/packages/astro/test/css-inline-stylesheets.test.js index 63148bbfd..bcd895a47 100644 --- a/packages/astro/test/css-inline-stylesheets.test.js +++ b/packages/astro/test/css-inline-stylesheets.test.js @@ -8,7 +8,11 @@ describe('Setting inlineStylesheets to never in static output', () => { before(async () => { fixture = await loadFixture({ - root: './fixtures/css-inline-stylesheets/never/', + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.dev/', + root: './fixtures/css-inline-stylesheets/', output: 'static', build: { inlineStylesheets: 'never', @@ -41,7 +45,11 @@ describe('Setting inlineStylesheets to never in server output', () => { before(async () => { const fixture = await loadFixture({ - root: './fixtures/css-inline-stylesheets/never/', + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.dev/', + root: './fixtures/css-inline-stylesheets/', output: 'server', adapter: testAdapter(), build: { @@ -77,7 +85,11 @@ describe('Setting inlineStylesheets to auto in static output', () => { before(async () => { fixture = await loadFixture({ - root: './fixtures/css-inline-stylesheets/auto/', + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.info/', + root: './fixtures/css-inline-stylesheets/', output: 'static', build: { inlineStylesheets: 'auto', @@ -117,7 +129,11 @@ describe('Setting inlineStylesheets to auto in server output', () => { before(async () => { const fixture = await loadFixture({ - root: './fixtures/css-inline-stylesheets/auto/', + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.info/', + root: './fixtures/css-inline-stylesheets/', output: 'server', adapter: testAdapter(), build: { @@ -161,7 +177,11 @@ describe('Setting inlineStylesheets to always in static output', () => { before(async () => { fixture = await loadFixture({ - root: './fixtures/css-inline-stylesheets/always/', + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.net/', + root: './fixtures/css-inline-stylesheets/', output: 'static', build: { inlineStylesheets: 'always', @@ -193,7 +213,11 @@ describe('Setting inlineStylesheets to always in server output', () => { before(async () => { const fixture = await loadFixture({ - root: './fixtures/css-inline-stylesheets/always/', + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.net/', + root: './fixtures/css-inline-stylesheets/', output: 'server', adapter: testAdapter(), build: { diff --git a/packages/astro/test/fixtures/core-image-ssg/src/pages/remote.astro b/packages/astro/test/fixtures/core-image-ssg/src/pages/remote.astro new file mode 100644 index 000000000..727a15ff0 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-ssg/src/pages/remote.astro @@ -0,0 +1,7 @@ +--- +import { Image } from "astro:assets"; +--- + +<div id="remote"> +<Image src="https://astro.build/sponsors.png" alt="fred" width="48" height="48" /> +</div> diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/auto/package.json b/packages/astro/test/fixtures/css-inline-stylesheets/auto/package.json deleted file mode 100644 index 3eb8e9d51..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/auto/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/css-inline-stylesheets-auto", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/components/Button.astro b/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/components/Button.astro deleted file mode 100644 index 3f25cbd3e..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/components/Button.astro +++ /dev/null @@ -1,86 +0,0 @@ ---- -const { class: className = '', style, href } = Astro.props; -const { variant = 'primary' } = Astro.props; ---- - -<span class:list={[`link pixel variant-${variant}`, className]} > - <a {href}> - <span><slot /></span> - </a> -</span> - -<style> - .link { - --border-radius: 8; - --duration: 200ms; - --delay: 30ms; - --background: linear-gradient(180deg, var(--link-color-stop-a), var(--link-color-stop-b)); - display: flex; - color: white; - font-size: 1.25rem; - width: max-content; - } - a { - display: flex; - align-items: center; - justify-content: center; - padding: 0.67rem 1.25rem; - width: 100%; - height: 100%; - text-decoration: none; - color: inherit !important; - /* Indicates the button boundaries for forced colors users in older browsers */ - outline: 1px solid transparent; - } - - @media (forced-colors: active) { - a { - border: 1px solid LinkText; - } - } - - a > :global(* + *) { - margin-inline-start: 0.25rem; - } - - .variant-primary { - --variant: primary; - --background: linear-gradient(180deg, var(--link-color-stop-a), var(--link-color-stop-b)); - } - .variant-primary:hover, - .variant-primary:focus-within { - --link-color-stop-a: #6d39ff; - --link-color-stop-b: #af43ff; - } - .variant-primary:active { - --link-color-stop-a: #5f31e1; - --link-color-stop-b: #a740f3; - } - - .variant-outline { - --variant: outline; - --background: none; - color: var(--background); - } - .variant-outline > a::before { - position: absolute; - top: 0; - right: calc(var(--pixel-size) * 1px); - bottom: calc(var(--pixel-size) * 1px); - left: calc(var(--pixel-size) * 1px); - content: ''; - display: block; - transform-origin: bottom center; - background: linear-gradient(to top, var(--background), rgba(255, 255, 255, 0)); - opacity: 0.3; - transform: scaleY(0); - transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1); - } - .variant-outline:hover > a::before, - .variant-outline:focus-within > a::before { - transform: scaleY(1); - } - .variant-outline:active > a::before { - transform: scaleY(1); - } -</style>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/content/en/endeavour.md b/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/content/en/endeavour.md deleted file mode 100644 index 240eeeae3..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/content/en/endeavour.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Endeavour -description: 'Learn about the Endeavour NASA space shuttle.' -publishedDate: 'Sun Jul 11 2021 00:00:00 GMT-0400 (Eastern Daylight Time)' -layout: '../../layouts/Layout.astro' -tags: [space, 90s] ---- - -**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour) - -Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly. - -The United States Congress approved the construction of Endeavour in 1987 to replace the Space Shuttle Challenger, which was destroyed in 1986. - -NASA chose, on cost grounds, to build much of Endeavour from spare parts rather than refitting the Space Shuttle Enterprise, and used structural spares built during the construction of Discovery and Atlantis in its assembly.
\ No newline at end of file diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/imported.css b/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/imported.css deleted file mode 100644 index 3959523ff..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/imported.css +++ /dev/null @@ -1,15 +0,0 @@ -.bg-skyblue { - background: skyblue; -} - -.bg-lightcoral { - background: lightcoral; -} - -.red { - color: darkred; -} - -.blue { - color: royalblue; -} diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/layouts/Layout.astro b/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/layouts/Layout.astro deleted file mode 100644 index 0a2665518..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/layouts/Layout.astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -import Button from '../components/Button.astro'; -import '../imported.css'; - -interface Props { - title: string; -} - -const { title } = Astro.props; ---- - -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width" /> - <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> - <meta name="generator" content={Astro.generator} /> - <title>{title}</title> - </head> - <body> - <Button>Button used in layout</Button> - <slot /> - </body> -</html> -<style is:global> - html { - font-family: system-ui, sans-serif; - background-color: #F6F6F6; - } - code { - font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, - Bitstream Vera Sans Mono, Courier New, monospace; - } -</style> diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/pages/index.astro b/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/pages/index.astro deleted file mode 100644 index bfdbeb5f8..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/auto/src/pages/index.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -import Button from '../components/Button.astro'; -import { getEntryBySlug } from 'astro:content'; - -const entry = await getEntryBySlug('en', 'endeavour'); -const { Content } = await entry.render(); ---- -<style> - #welcome::after { - content: '🚀' - } -</style> -<main> - <h1 id="welcome">Welcome to Astro</h1> - <Content/> - <Button>Button used directly in page</Button> -</main> diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/never/package.json b/packages/astro/test/fixtures/css-inline-stylesheets/never/package.json deleted file mode 100644 index 382288fbc..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/never/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/css-inline-stylesheets-never", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/never/src/components/Button.astro b/packages/astro/test/fixtures/css-inline-stylesheets/never/src/components/Button.astro deleted file mode 100644 index 3f25cbd3e..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/never/src/components/Button.astro +++ /dev/null @@ -1,86 +0,0 @@ ---- -const { class: className = '', style, href } = Astro.props; -const { variant = 'primary' } = Astro.props; ---- - -<span class:list={[`link pixel variant-${variant}`, className]} > - <a {href}> - <span><slot /></span> - </a> -</span> - -<style> - .link { - --border-radius: 8; - --duration: 200ms; - --delay: 30ms; - --background: linear-gradient(180deg, var(--link-color-stop-a), var(--link-color-stop-b)); - display: flex; - color: white; - font-size: 1.25rem; - width: max-content; - } - a { - display: flex; - align-items: center; - justify-content: center; - padding: 0.67rem 1.25rem; - width: 100%; - height: 100%; - text-decoration: none; - color: inherit !important; - /* Indicates the button boundaries for forced colors users in older browsers */ - outline: 1px solid transparent; - } - - @media (forced-colors: active) { - a { - border: 1px solid LinkText; - } - } - - a > :global(* + *) { - margin-inline-start: 0.25rem; - } - - .variant-primary { - --variant: primary; - --background: linear-gradient(180deg, var(--link-color-stop-a), var(--link-color-stop-b)); - } - .variant-primary:hover, - .variant-primary:focus-within { - --link-color-stop-a: #6d39ff; - --link-color-stop-b: #af43ff; - } - .variant-primary:active { - --link-color-stop-a: #5f31e1; - --link-color-stop-b: #a740f3; - } - - .variant-outline { - --variant: outline; - --background: none; - color: var(--background); - } - .variant-outline > a::before { - position: absolute; - top: 0; - right: calc(var(--pixel-size) * 1px); - bottom: calc(var(--pixel-size) * 1px); - left: calc(var(--pixel-size) * 1px); - content: ''; - display: block; - transform-origin: bottom center; - background: linear-gradient(to top, var(--background), rgba(255, 255, 255, 0)); - opacity: 0.3; - transform: scaleY(0); - transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1); - } - .variant-outline:hover > a::before, - .variant-outline:focus-within > a::before { - transform: scaleY(1); - } - .variant-outline:active > a::before { - transform: scaleY(1); - } -</style>
\ No newline at end of file diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/never/src/content/en/endeavour.md b/packages/astro/test/fixtures/css-inline-stylesheets/never/src/content/en/endeavour.md deleted file mode 100644 index 240eeeae3..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/never/src/content/en/endeavour.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Endeavour -description: 'Learn about the Endeavour NASA space shuttle.' -publishedDate: 'Sun Jul 11 2021 00:00:00 GMT-0400 (Eastern Daylight Time)' -layout: '../../layouts/Layout.astro' -tags: [space, 90s] ---- - -**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour) - -Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly. - -The United States Congress approved the construction of Endeavour in 1987 to replace the Space Shuttle Challenger, which was destroyed in 1986. - -NASA chose, on cost grounds, to build much of Endeavour from spare parts rather than refitting the Space Shuttle Enterprise, and used structural spares built during the construction of Discovery and Atlantis in its assembly.
\ No newline at end of file diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/never/src/imported.css b/packages/astro/test/fixtures/css-inline-stylesheets/never/src/imported.css deleted file mode 100644 index 3959523ff..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/never/src/imported.css +++ /dev/null @@ -1,15 +0,0 @@ -.bg-skyblue { - background: skyblue; -} - -.bg-lightcoral { - background: lightcoral; -} - -.red { - color: darkred; -} - -.blue { - color: royalblue; -} diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/never/src/layouts/Layout.astro b/packages/astro/test/fixtures/css-inline-stylesheets/never/src/layouts/Layout.astro deleted file mode 100644 index 0a2665518..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/never/src/layouts/Layout.astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -import Button from '../components/Button.astro'; -import '../imported.css'; - -interface Props { - title: string; -} - -const { title } = Astro.props; ---- - -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width" /> - <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> - <meta name="generator" content={Astro.generator} /> - <title>{title}</title> - </head> - <body> - <Button>Button used in layout</Button> - <slot /> - </body> -</html> -<style is:global> - html { - font-family: system-ui, sans-serif; - background-color: #F6F6F6; - } - code { - font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, - Bitstream Vera Sans Mono, Courier New, monospace; - } -</style> diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/never/src/pages/index.astro b/packages/astro/test/fixtures/css-inline-stylesheets/never/src/pages/index.astro deleted file mode 100644 index bfdbeb5f8..000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets/never/src/pages/index.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -import Button from '../components/Button.astro'; -import { getEntryBySlug } from 'astro:content'; - -const entry = await getEntryBySlug('en', 'endeavour'); -const { Content } = await entry.render(); ---- -<style> - #welcome::after { - content: '🚀' - } -</style> -<main> - <h1 id="welcome">Welcome to Astro</h1> - <Content/> - <Button>Button used directly in page</Button> -</main> diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/always/package.json b/packages/astro/test/fixtures/css-inline-stylesheets/package.json index 0d4a8617d..0d4a8617d 100644 --- a/packages/astro/test/fixtures/css-inline-stylesheets/always/package.json +++ b/packages/astro/test/fixtures/css-inline-stylesheets/package.json diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/always/src/components/Button.astro b/packages/astro/test/fixtures/css-inline-stylesheets/src/components/Button.astro index 3f25cbd3e..3f25cbd3e 100644 --- a/packages/astro/test/fixtures/css-inline-stylesheets/always/src/components/Button.astro +++ b/packages/astro/test/fixtures/css-inline-stylesheets/src/components/Button.astro diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/always/src/content/en/endeavour.md b/packages/astro/test/fixtures/css-inline-stylesheets/src/content/en/endeavour.md index 240eeeae3..240eeeae3 100644 --- a/packages/astro/test/fixtures/css-inline-stylesheets/always/src/content/en/endeavour.md +++ b/packages/astro/test/fixtures/css-inline-stylesheets/src/content/en/endeavour.md diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/always/src/imported.css b/packages/astro/test/fixtures/css-inline-stylesheets/src/imported.css index 3959523ff..3959523ff 100644 --- a/packages/astro/test/fixtures/css-inline-stylesheets/always/src/imported.css +++ b/packages/astro/test/fixtures/css-inline-stylesheets/src/imported.css diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/always/src/layouts/Layout.astro b/packages/astro/test/fixtures/css-inline-stylesheets/src/layouts/Layout.astro index 0a2665518..0a2665518 100644 --- a/packages/astro/test/fixtures/css-inline-stylesheets/always/src/layouts/Layout.astro +++ b/packages/astro/test/fixtures/css-inline-stylesheets/src/layouts/Layout.astro diff --git a/packages/astro/test/fixtures/css-inline-stylesheets/always/src/pages/index.astro b/packages/astro/test/fixtures/css-inline-stylesheets/src/pages/index.astro index bfdbeb5f8..bfdbeb5f8 100644 --- a/packages/astro/test/fixtures/css-inline-stylesheets/always/src/pages/index.astro +++ b/packages/astro/test/fixtures/css-inline-stylesheets/src/pages/index.astro diff --git a/packages/astro/test/fixtures/integration-server-setup/integration.js b/packages/astro/test/fixtures/integration-server-setup/integration.js index bcbd86228..f5000fa6b 100644 --- a/packages/astro/test/fixtures/integration-server-setup/integration.js +++ b/packages/astro/test/fixtures/integration-server-setup/integration.js @@ -1,8 +1,12 @@ +import { setTimeout } from "node:timers/promises"; + export default function() { return { name: '@astrojs/test-integration', hooks: { - 'astro:server:setup': ({ server }) => { + 'astro:server:setup': async ({ server }) => { + // Ensure that `async` is respected + await setTimeout(100); server.middlewares.use( function middleware(req, res, next) { res.setHeader('x-middleware', 'true'); diff --git a/packages/astro/test/fixtures/react-component/src/components/WithChildren.jsx b/packages/astro/test/fixtures/react-component/src/components/WithChildren.jsx deleted file mode 100644 index cdcb0e0a6..000000000 --- a/packages/astro/test/fixtures/react-component/src/components/WithChildren.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -export default function ({ children }) { - return <div className="with-children">{children}</div>; -} diff --git a/packages/astro/test/ssr-prerender.test.js b/packages/astro/test/ssr-prerender.test.js index 90ec1b6fa..567371f0b 100644 --- a/packages/astro/test/ssr-prerender.test.js +++ b/packages/astro/test/ssr-prerender.test.js @@ -30,8 +30,7 @@ describe('SSR: prerender', () => { const app = await fixture.loadTestAdapterApp(); /** @type {Set<string>} */ const assets = app.manifest.assets; - expect(assets.size).to.equal(1); - expect(Array.from(assets)[0].endsWith('static/index.html')).to.be.true; + expect(assets).to.contain('/static/index.html'); }); }); diff --git a/packages/astro/test/test-image-service.js b/packages/astro/test/test-image-service.js index ebdbb0765..bcf623caa 100644 --- a/packages/astro/test/test-image-service.js +++ b/packages/astro/test/test-image-service.js @@ -17,8 +17,8 @@ export default { ...baseService, getHTMLAttributes(options, serviceConfig) { options['data-service'] = 'my-custom-service'; - if (serviceConfig.foo) { - options['data-service-config'] = serviceConfig.foo; + if (serviceConfig.service.config.foo) { + options['data-service-config'] = serviceConfig.service.config.foo; } return baseService.getHTMLAttributes(options); }, diff --git a/packages/astro/test/units/assets/remote-pattern.test.js b/packages/astro/test/units/assets/remote-pattern.test.js new file mode 100644 index 000000000..62a411e3a --- /dev/null +++ b/packages/astro/test/units/assets/remote-pattern.test.js @@ -0,0 +1,111 @@ +import { expect } from 'chai'; +import { + matchProtocol, + matchPort, + matchHostname, + matchPathname, + matchPattern, +} from '../../../dist/assets/utils/remotePattern.js'; + +describe('astro/src/assets/utils/remotePattern', () => { + const url1 = new URL('https://docs.astro.build/en/getting-started'); + const url2 = new URL('http://preview.docs.astro.build:8080/'); + const url3 = new URL('https://astro.build/'); + const url4 = new URL('https://example.co/'); + + describe('remote pattern matchers', () => { + it('matches protocol', async () => { + // undefined + expect(matchProtocol(url1)).to.be.true; + + // defined, true/false + expect(matchProtocol(url1, 'http')).to.be.false; + expect(matchProtocol(url1, 'https')).to.be.true; + }); + + it('matches port', async () => { + // undefined + expect(matchPort(url1)).to.be.true; + + // defined, but port is empty (default port used in URL) + expect(matchPort(url1, '')).to.be.true; + + // defined and port is custom + expect(matchPort(url2, '8080')).to.be.true; + }); + + it('matches hostname (no wildcards)', async () => { + // undefined + expect(matchHostname(url1)).to.be.true; + + // defined, true/false + expect(matchHostname(url1, 'astro.build')).to.be.false; + expect(matchHostname(url1, 'docs.astro.build')).to.be.true; + }); + + it('matches hostname (with wildcards)', async () => { + // defined, true/false + expect(matchHostname(url1, 'docs.astro.build', true)).to.be.true; + expect(matchHostname(url1, '**.astro.build', true)).to.be.true; + expect(matchHostname(url1, '*.astro.build', true)).to.be.true; + + expect(matchHostname(url2, '*.astro.build', true)).to.be.false; + expect(matchHostname(url2, '**.astro.build', true)).to.be.true; + + expect(matchHostname(url3, 'astro.build', true)).to.be.true; + expect(matchHostname(url3, '*.astro.build', true)).to.be.false; + expect(matchHostname(url3, '**.astro.build', true)).to.be.false; + }); + + it('matches pathname (no wildcards)', async () => { + // undefined + expect(matchPathname(url1)).to.be.true; + + // defined, true/false + expect(matchPathname(url1, '/')).to.be.false; + expect(matchPathname(url1, '/en/getting-started')).to.be.true; + }); + + it('matches pathname (with wildcards)', async () => { + // defined, true/false + expect(matchPathname(url1, '/en/**', true)).to.be.true; + expect(matchPathname(url1, '/en/*', true)).to.be.true; + expect(matchPathname(url1, '/**', true)).to.be.true; + + expect(matchPathname(url2, '/**', true)).to.be.false; + expect(matchPathname(url2, '/*', true)).to.be.false; + }); + + it('matches patterns', async () => { + expect(matchPattern(url1, {})).to.be.true; + + expect( + matchPattern(url1, { + protocol: 'https', + }) + ).to.be.true; + + expect( + matchPattern(url1, { + protocol: 'https', + hostname: '**.astro.build', + }) + ).to.be.true; + + expect( + matchPattern(url1, { + protocol: 'https', + hostname: '**.astro.build', + pathname: '/en/**', + }) + ).to.be.true; + + expect( + matchPattern(url4, { + protocol: 'https', + hostname: 'example.com', + }) + ).to.be.false; + }); + }); +}); diff --git a/packages/create-astro/CHANGELOG.md b/packages/create-astro/CHANGELOG.md index 519133c71..be4257b58 100644 --- a/packages/create-astro/CHANGELOG.md +++ b/packages/create-astro/CHANGELOG.md @@ -6,6 +6,18 @@ - [`1eae2e3f7`](https://github.com/withastro/astro/commit/1eae2e3f7d693c9dfe91c8ccfbe606d32bf2fb81) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Remove support for Node 16. The lowest supported version by Astro and all integrations is now v18.14.1. As a reminder, Node 16 will be deprecated on the 11th September 2023. +## 3.2.1 + +### Patch Changes + +- [#8089](https://github.com/withastro/astro/pull/8089) [`04755e846`](https://github.com/withastro/astro/commit/04755e84658ea10914a09f3d07f302267326d610) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Fix install step to avoid uncaught errors + +## 3.2.0 + +### Minor Changes + +- [#8077](https://github.com/withastro/astro/pull/8077) [`44cf30a25`](https://github.com/withastro/astro/commit/44cf30a25209b331e6e8a95a4b40a768ede3604a) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Reduce dependency installation size, swap `execa` for light `node:child_process` wrapper + ## 3.1.13 ### Patch Changes diff --git a/packages/create-astro/src/actions/next-steps.ts b/packages/create-astro/src/actions/next-steps.ts index 1b3a0db28..01c1963d9 100644 --- a/packages/create-astro/src/actions/next-steps.ts +++ b/packages/create-astro/src/actions/next-steps.ts @@ -5,7 +5,12 @@ import { nextSteps, say } from '../messages.js'; export async function next(ctx: Pick<Context, 'cwd' | 'pkgManager' | 'skipHouston'>) { let projectDir = path.relative(process.cwd(), ctx.cwd); - const devCmd = ctx.pkgManager === 'npm' ? 'npm run dev' : `${ctx.pkgManager} dev`; + const devCmd = + ctx.pkgManager === 'npm' + ? 'npm run dev' + : ctx.pkgManager === 'bun' + ? 'bun run dev' + : `${ctx.pkgManager} dev`; await nextSteps({ projectDir, devCmd }); if (!ctx.skipHouston) { diff --git a/packages/create-astro/src/actions/template.ts b/packages/create-astro/src/actions/template.ts index ca041642b..887ba69f5 100644 --- a/packages/create-astro/src/actions/template.ts +++ b/packages/create-astro/src/actions/template.ts @@ -66,7 +66,7 @@ const FILES_TO_UPDATE = { }), }; -function getTemplateTarget(tmpl: string, ref = 'latest') { +export function getTemplateTarget(tmpl: string, ref = 'latest') { if (tmpl.startsWith('starlight')) { const [, starter = 'basics'] = tmpl.split('/'); return `withastro/starlight/examples/${starter}`; diff --git a/packages/create-astro/src/actions/verify.ts b/packages/create-astro/src/actions/verify.ts new file mode 100644 index 000000000..73359142b --- /dev/null +++ b/packages/create-astro/src/actions/verify.ts @@ -0,0 +1,93 @@ +import type { Context } from './context'; + +import { color } from '@astrojs/cli-kit'; +import fetch from 'node-fetch-native'; +import dns from 'node:dns/promises'; +import { bannerAbort, error, info, log } from '../messages.js'; +import { getTemplateTarget } from './template.js'; + +export async function verify( + ctx: Pick<Context, 'version' | 'dryRun' | 'template' | 'ref' | 'exit'> +) { + if (!ctx.dryRun) { + const online = await isOnline(); + if (!online) { + bannerAbort(); + log(''); + error('error', `Unable to connect to the internet.`); + ctx.exit(1); + } + } + + if (ctx.template) { + const ok = await verifyTemplate(ctx.template, ctx.ref); + if (!ok) { + bannerAbort(); + log(''); + error('error', `Template ${color.reset(ctx.template)} ${color.dim('could not be found!')}`); + await info('check', 'https://astro.build/examples'); + ctx.exit(1); + } + } +} + +function isOnline(): Promise<boolean> { + return dns.lookup('github.com').then( + () => true, + () => false + ); +} + +async function verifyTemplate(tmpl: string, ref?: string) { + const target = getTemplateTarget(tmpl, ref); + const { repo, subdir, ref: branch } = parseGitURI(target.replace('github:', '')); + const url = new URL(`/repos/${repo}/contents${subdir}?ref=${branch}`, 'https://api.github.com/'); + + let res = await fetch(url.toString(), { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + + // If users hit a ratelimit, fallback to the GitHub website + if (res.status === 403) { + res = await fetch(`https://github.com/${repo}/tree/${branch}${subdir}`); + } + + return res.status === 200; +} + +// Adapted from https://github.com/unjs/giget/blob/main/src/_utils.ts +// MIT License + +// Copyright (c) Pooya Parsa <pooya@pi0.io> + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +const GIT_RE = /^(?<repo>[\w.-]+\/[\w.-]+)(?<subdir>[^#]+)?(?<ref>#[\w.-]+)?/; + +function parseGitURI(input: string) { + const m = input.match(GIT_RE)?.groups; + if (!m) throw new Error(`Unable to parse "${input}"`); + return { + repo: m.repo, + subdir: m.subdir || '/', + ref: m.ref ? m.ref.slice(1) : 'main', + }; +} diff --git a/packages/create-astro/src/index.ts b/packages/create-astro/src/index.ts index b99aa1cc3..a33e11bff 100644 --- a/packages/create-astro/src/index.ts +++ b/packages/create-astro/src/index.ts @@ -8,6 +8,7 @@ import { next } from './actions/next-steps.js'; import { projectName } from './actions/project-name.js'; import { template } from './actions/template.js'; import { setupTypeScript, typescript } from './actions/typescript.js'; +import { verify } from './actions/verify.js'; import { setStdout } from './messages.js'; const exit = () => process.exit(0); @@ -30,6 +31,7 @@ export async function main() { } const steps = [ + verify, intro, projectName, template, @@ -58,4 +60,5 @@ export { setupTypeScript, template, typescript, + verify, }; diff --git a/packages/create-astro/src/messages.ts b/packages/create-astro/src/messages.ts index 89ccddcdb..fbf276794 100644 --- a/packages/create-astro/src/messages.ts +++ b/packages/create-astro/src/messages.ts @@ -93,11 +93,14 @@ export const getVersion = () => export const log = (message: string) => stdout.write(message + '\n'); export const banner = async (version: string) => log( - `\n${label('astro', color.bgGreen, color.black)} ${ - version ? color.green(color.bold(`v${version}`)) : '' + `\n${label('astro', color.bgGreen, color.black)}${ + version ? ' ' + color.green(color.bold(`v${version}`)) : '' } ${color.bold('Launch sequence initiated.')}` ); +export const bannerAbort = () => + log(`\n${label('astro', color.bgRed)} ${color.bold('Launch sequence aborted.')}`); + export const info = async (prefix: string, text: string) => { await sleep(100); if (stdout.columns < 80) { diff --git a/packages/create-astro/src/shell.ts b/packages/create-astro/src/shell.ts index d2d7ef033..e6f1295ea 100644 --- a/packages/create-astro/src/shell.ts +++ b/packages/create-astro/src/shell.ts @@ -1,11 +1,10 @@ // This is an extremely simplified version of [`execa`](https://github.com/sindresorhus/execa) // intended to keep our dependency size down -import type { StdioOptions } from 'node:child_process'; +import type { ChildProcess, StdioOptions } from 'node:child_process'; import type { Readable } from 'node:stream'; import { spawn } from 'node:child_process'; import { text as textFromStream } from 'node:stream/consumers'; -import { setTimeout as sleep } from 'node:timers/promises'; export interface ExecaOptions { cwd?: string | URL; @@ -25,25 +24,28 @@ export async function shell( flags: string[], opts: ExecaOptions = {} ): Promise<Output> { - const controller = opts.timeout ? new AbortController() : undefined; - const child = spawn(command, flags, { - cwd: opts.cwd, - shell: true, - stdio: opts.stdio, - signal: controller?.signal, - }); - const stdout = await text(child.stdout); - const stderr = await text(child.stderr); - if (opts.timeout) { - sleep(opts.timeout).then(() => { - controller!.abort(); - throw { stdout, stderr, exitCode: 1 }; + let child: ChildProcess; + let stdout = ''; + let stderr = ''; + try { + child = spawn(command, flags, { + cwd: opts.cwd, + shell: true, + stdio: opts.stdio, + timeout: opts.timeout, }); + const done = new Promise((resolve) => child.on('close', resolve)); + [stdout, stderr] = await Promise.all([text(child.stdout), text(child.stderr)]); + await done; + } catch (e) { + throw { stdout, stderr, exitCode: 1 }; } - await new Promise((resolve) => child.on('exit', resolve)); const { exitCode } = child; + if (exitCode === null) { + throw new Error('Timeout'); + } if (exitCode !== 0) { - throw { stdout, stderr, exitCode }; + throw new Error(stderr); } return { stdout, stderr, exitCode }; } diff --git a/packages/create-astro/test/verify.test.js b/packages/create-astro/test/verify.test.js new file mode 100644 index 000000000..ecfaba727 --- /dev/null +++ b/packages/create-astro/test/verify.test.js @@ -0,0 +1,41 @@ +import { expect } from 'chai'; + +import { verify } from '../dist/index.js'; +import { setup } from './utils.js'; + +describe('verify', () => { + const fixture = setup(); + const exit = (code) => { + throw code; + }; + + it('basics', async () => { + const context = { template: 'basics', exit }; + await verify(context); + expect(fixture.messages().length).to.equal(0, 'Did not expect `verify` to log any messages'); + }); + + it('missing', async () => { + const context = { template: 'missing', exit }; + let err = null; + try { + await verify(context); + } catch (e) { + err = e; + } + expect(err).to.eq(1); + expect(fixture.hasMessage('Template missing does not exist!')); + }); + + it('starlight', async () => { + const context = { template: 'starlight', exit }; + await verify(context); + expect(fixture.messages().length).to.equal(0, 'Did not expect `verify` to log any messages'); + }); + + it('starlight/tailwind', async () => { + const context = { template: 'starlight/tailwind', exit }; + await verify(context); + expect(fixture.messages().length).to.equal(0, 'Did not expect `verify` to log any messages'); + }); +}); diff --git a/packages/integrations/cloudflare/test/basics.test.js b/packages/integrations/cloudflare/test/basics.test.js index 9aa78f98e..c27b6be6c 100644 --- a/packages/integrations/cloudflare/test/basics.test.js +++ b/packages/integrations/cloudflare/test/basics.test.js @@ -14,7 +14,7 @@ describe('Basic app', () => { }); await fixture.build(); - cli = runCLI('./fixtures/basics/', { silent: true, port: 8789 }); + cli = await runCLI('./fixtures/basics/', { silent: true, port: 8789 }); await cli.ready; }); @@ -23,7 +23,7 @@ describe('Basic app', () => { }); it('can render', async () => { - let res = await fetch(`http://localhost:8789/`); + let res = await fetch(`http://127.0.0.1:8789/`); expect(res.status).to.equal(200); let html = await res.text(); let $ = cheerio.load(html); diff --git a/packages/integrations/cloudflare/test/cf.test.js b/packages/integrations/cloudflare/test/cf.test.js index f8ab9c02f..ec0e52c97 100644 --- a/packages/integrations/cloudflare/test/cf.test.js +++ b/packages/integrations/cloudflare/test/cf.test.js @@ -17,7 +17,7 @@ describe('Cf metadata and caches', () => { }); await fixture.build(); - cli = runCLI('./fixtures/cf/', { silent: false, port: 8788 }); + cli = await runCLI('./fixtures/cf/', { silent: false, port: 8788 }); await cli.ready; }); @@ -26,12 +26,12 @@ describe('Cf metadata and caches', () => { }); it('Load cf and caches API', async () => { - let res = await fetch(`http://localhost:8788/`); + let res = await fetch(`http://127.0.0.1:8788/`); expect(res.status).to.equal(200); let html = await res.text(); let $ = cheerio.load(html); - // console.log($('#cf').text(), html); - expect($('#cf').text()).to.contain('city'); + + expect($('#hasRuntime').text()).to.equal('true'); expect($('#hasCache').text()).to.equal('true'); }); }); diff --git a/packages/integrations/cloudflare/test/fixtures/cf/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/cf/src/pages/index.astro index c9e67bb05..6ba48e803 100644 --- a/packages/integrations/cloudflare/test/fixtures/cf/src/pages/index.astro +++ b/packages/integrations/cloudflare/test/fixtures/cf/src/pages/index.astro @@ -8,7 +8,7 @@ const runtime = getRuntime(Astro.request); </head> <body> <h1>Testing</h1> - <div id="cf">{JSON.stringify(runtime.cf)}</div> + <div id="hasRuntime">{!!runtime.cf?.colo}</div> <div id="hasCache">{!!runtime.caches}</div> </body> </html> diff --git a/packages/integrations/cloudflare/test/fixtures/runtime/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/runtime/src/pages/index.astro index 320e8e162..aa73ab8ea 100644 --- a/packages/integrations/cloudflare/test/fixtures/runtime/src/pages/index.astro +++ b/packages/integrations/cloudflare/test/fixtures/runtime/src/pages/index.astro @@ -8,8 +8,8 @@ const env = runtime.env; </head> <body> <h1>Testing</h1> - <div id="cf">{JSON.stringify(runtime.cf)}</div> <div id="env">{JSON.stringify(env)}</div> + <div id="hasRuntime">{!!runtime.cf?.colo}</div> <div id="hasCache">{!!runtime.caches}</div> </body> </html> diff --git a/packages/integrations/cloudflare/test/runtime.test.js b/packages/integrations/cloudflare/test/runtime.test.js index 243c1dd67..be14718e8 100644 --- a/packages/integrations/cloudflare/test/runtime.test.js +++ b/packages/integrations/cloudflare/test/runtime.test.js @@ -17,7 +17,7 @@ describe('Runtime Locals', () => { }); await fixture.build(); - cli = runCLI('./fixtures/runtime/', { silent: true, port: 8793 }); + cli = await runCLI('./fixtures/runtime/', { silent: true, port: 8793 }); await cli.ready; }); @@ -26,13 +26,13 @@ describe('Runtime Locals', () => { }); it('has CF and Caches', async () => { - let res = await fetch(`http://localhost:8793/`); + let res = await fetch(`http://127.0.0.1:8793/`); expect(res.status).to.equal(200); let html = await res.text(); let $ = cheerio.load(html); - expect($('#cf').text()).to.contain('city'); expect($('#env').text()).to.contain('SECRET_STUFF'); expect($('#env').text()).to.contain('secret'); + expect($('#hasRuntime').text()).to.contain('true'); expect($('#hasCache').text()).to.equal('true'); }); }); diff --git a/packages/integrations/cloudflare/test/test-utils.js b/packages/integrations/cloudflare/test/test-utils.js index 90147a7f6..36515f831 100644 --- a/packages/integrations/cloudflare/test/test-utils.js +++ b/packages/integrations/cloudflare/test/test-utils.js @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import kill from 'kill-port'; import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; export { fixLineEndings } from '../../../astro/test/test-utils.js'; @@ -21,22 +22,39 @@ const wranglerPath = fileURLToPath( ); /** - * @returns {WranglerCLI} + * @returns {Promise<WranglerCLI>} */ -export function runCLI(basePath, { silent, port = 8787 }) { +export async function runCLI(basePath, { silent, port }) { + // Hack: force existing process on port to be killed + try { + await kill(port, 'tcp'); + } catch { + // Will throw if port is not in use, but that's fine + } + const script = fileURLToPath(new URL(`${basePath}/dist/_worker.js`, import.meta.url)); - const p = spawn('node', [wranglerPath, 'dev', '-l', script, '--port', port]); + const p = spawn('node', [ + wranglerPath, + 'dev', + script, + '--port', + port, + '--log-level', + 'info', + '--persist-to', + `${basePath}/.wrangler/state`, + ]); p.stderr.setEncoding('utf-8'); p.stdout.setEncoding('utf-8'); - const timeout = 10000; + const timeout = 20_000; const ready = new Promise(async (resolve, reject) => { - const failed = setTimeout( - () => reject(new Error(`Timed out starting the wrangler CLI`)), - timeout - ); + const failed = setTimeout(() => { + p.kill(); + reject(new Error(`Timed out starting the wrangler CLI`)); + }, timeout); (async function () { for (const msg of p.stderr) { @@ -50,7 +68,7 @@ export function runCLI(basePath, { silent, port = 8787 }) { if (!silent) { console.log(msg); } - if (msg.includes(`Listening on`)) { + if (msg.includes(`[mf:inf] Ready on`)) { break; } } diff --git a/packages/integrations/cloudflare/test/with-solid-js.test.js b/packages/integrations/cloudflare/test/with-solid-js.test.js index 90c1c0722..c091d04b3 100644 --- a/packages/integrations/cloudflare/test/with-solid-js.test.js +++ b/packages/integrations/cloudflare/test/with-solid-js.test.js @@ -14,7 +14,7 @@ describe('With SolidJS', () => { }); await fixture.build(); - cli = runCLI('./fixtures/with-solid-js/', { silent: true, port: 8790 }); + cli = await runCLI('./fixtures/with-solid-js/', { silent: true, port: 8790 }); await cli.ready; }); @@ -23,7 +23,7 @@ describe('With SolidJS', () => { }); it('renders the solid component', async () => { - let res = await fetch(`http://localhost:8790/`); + let res = await fetch(`http://127.0.0.1:8790/`); expect(res.status).to.equal(200); let html = await res.text(); let $ = cheerio.load(html); diff --git a/packages/integrations/cloudflare/test/wrangler.toml b/packages/integrations/cloudflare/test/wrangler.toml index 6e2d864b0..2c1acb55a 100644 --- a/packages/integrations/cloudflare/test/wrangler.toml +++ b/packages/integrations/cloudflare/test/wrangler.toml @@ -1,4 +1,6 @@ # for tests only +send_metrics = false + [vars] SECRET_STUFF = "secret" diff --git a/packages/integrations/node/CHANGELOG.md b/packages/integrations/node/CHANGELOG.md index 01e61307f..95b8e8ad1 100644 --- a/packages/integrations/node/CHANGELOG.md +++ b/packages/integrations/node/CHANGELOG.md @@ -39,6 +39,15 @@ - Updated dependencies [[`1eae2e3f7`](https://github.com/withastro/astro/commit/1eae2e3f7d693c9dfe91c8ccfbe606d32bf2fb81), [`76ddef19c`](https://github.com/withastro/astro/commit/76ddef19ccab6e5f7d3a5740cd41acf10e334b38), [`9b4f70a62`](https://github.com/withastro/astro/commit/9b4f70a629f55e461759ba46f68af7097a2e9215), [`3fdf509b2`](https://github.com/withastro/astro/commit/3fdf509b2731a9b2f972d89291e57cf78d62c769), [`2f951cd40`](https://github.com/withastro/astro/commit/2f951cd403dfcc2c3ca6aae618ae3e1409516e32), [`c022a4217`](https://github.com/withastro/astro/commit/c022a4217a805d223c1494e9eda4e48bbf810388), [`67becaa58`](https://github.com/withastro/astro/commit/67becaa580b8f787df58de66b7008b7098f1209c), [`bc37331d8`](https://github.com/withastro/astro/commit/bc37331d8154e3e95a8df9131e4e014e78a7a9e7), [`dfc2d93e3`](https://github.com/withastro/astro/commit/dfc2d93e3c645995379358fabbdfa9aab99f43d8), [`3dc1ca2fa`](https://github.com/withastro/astro/commit/3dc1ca2fac8d9965cc5085a5d09e72ed87b4281a), [`1be84dfee`](https://github.com/withastro/astro/commit/1be84dfee3ce8e6f5cc624f99aec4e980f6fde37), [`35f01df79`](https://github.com/withastro/astro/commit/35f01df797d23315f2bee2fc3fd795adb0559c58), [`3fdf509b2`](https://github.com/withastro/astro/commit/3fdf509b2731a9b2f972d89291e57cf78d62c769), [`78de801f2`](https://github.com/withastro/astro/commit/78de801f21fd4ca1653950027d953bf08614566b), [`59d6e569f`](https://github.com/withastro/astro/commit/59d6e569f63e175c97e82e94aa7974febfb76f7c), [`7723c4cc9`](https://github.com/withastro/astro/commit/7723c4cc93298c2e6530e55da7afda048f22cf81), [`fb5cd6b56`](https://github.com/withastro/astro/commit/fb5cd6b56dc27a71366ed5e1ab8bfe9b8f96bac5), [`631b9c410`](https://github.com/withastro/astro/commit/631b9c410d5d66fa384674027ba95d69ebb5063f)]: - astro@3.0.0-beta.0 +## 5.3.4 + +### Patch Changes + +- [#8084](https://github.com/withastro/astro/pull/8084) [`560e45924`](https://github.com/withastro/astro/commit/560e45924622141206ff5b47d134cb343d6d2a71) Thanks [@hbgl](https://github.com/hbgl)! - Stream request body instead of buffering it in memory. + +- Updated dependencies [[`c19987df0`](https://github.com/withastro/astro/commit/c19987df0be3520cf774476cea270c03edd08354), [`560e45924`](https://github.com/withastro/astro/commit/560e45924622141206ff5b47d134cb343d6d2a71), [`afc45af20`](https://github.com/withastro/astro/commit/afc45af2022f7c43fbb6c5c04983695f3819e47e), [`d1f7143f9`](https://github.com/withastro/astro/commit/d1f7143f9caf2ffa0e87cc55c0e05339d3501db3), [`3e46634fd`](https://github.com/withastro/astro/commit/3e46634fd540e5b967d2e5c9abd6235452cee2f2), [`a12027b6a`](https://github.com/withastro/astro/commit/a12027b6af411be39700919ca47e240a335e9887)]: + - astro@2.10.8 + ## 5.3.3 ### Patch Changes diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index 999ce294a..c3ef49637 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -48,6 +48,7 @@ "chai": "^4.3.7", "cheerio": "1.0.0-rc.12", "mocha": "^9.2.2", - "node-mocks-http": "^1.12.2" + "node-mocks-http": "^1.13.0", + "undici": "^5.22.1" } } diff --git a/packages/integrations/node/test/api-route.test.js b/packages/integrations/node/test/api-route.test.js index 7fbd95776..c830eee2d 100644 --- a/packages/integrations/node/test/api-route.test.js +++ b/packages/integrations/node/test/api-route.test.js @@ -1,6 +1,7 @@ import nodejs from '../dist/index.js'; import { loadFixture, createRequestAndResponse } from './test-utils.js'; import { expect } from 'chai'; +import crypto from 'node:crypto'; describe('API routes', () => { /** @type {import('./test-utils').Fixture} */ @@ -22,9 +23,11 @@ describe('API routes', () => { url: '/recipes', }); - handler(req, res); + req.once('async_iterator', () => { + req.send(JSON.stringify({ id: 2 })); + }); - req.send(JSON.stringify({ id: 2 })); + handler(req, res); let [buffer] = await done; @@ -43,11 +46,47 @@ describe('API routes', () => { url: '/binary', }); + req.once('async_iterator', () => { + req.send(Buffer.from(new Uint8Array([1, 2, 3, 4, 5]))); + }); + handler(req, res); - req.send(Buffer.from(new Uint8Array([1, 2, 3, 4, 5]))); let [out] = await done; let arr = Array.from(new Uint8Array(out.buffer)); expect(arr).to.deep.equal([5, 4, 3, 2, 1]); }); + + it('Can post large binary data', async () => { + const { handler } = await import('./fixtures/api-route/dist/server/entry.mjs'); + + let { req, res, done } = createRequestAndResponse({ + method: 'POST', + url: '/hash', + }); + + handler(req, res); + + let expectedDigest = null; + req.once('async_iterator', () => { + // Send 256MB of garbage data in 256KB chunks. This should be fast (< 1sec). + let remainingBytes = 256 * 1024 * 1024; + const chunkSize = 256 * 1024; + + const hash = crypto.createHash('sha256'); + while (remainingBytes > 0) { + const size = Math.min(remainingBytes, chunkSize); + const chunk = Buffer.alloc(size, Math.floor(Math.random() * 256)); + hash.update(chunk); + req.emit('data', chunk); + remainingBytes -= size; + } + + req.emit('end'); + expectedDigest = hash.digest(); + }); + + let [out] = await done; + expect(new Uint8Array(out.buffer)).to.deep.equal(expectedDigest); + }); }); diff --git a/packages/integrations/node/test/fixtures/api-route/src/pages/hash.ts b/packages/integrations/node/test/fixtures/api-route/src/pages/hash.ts new file mode 100644 index 000000000..fbf44c547 --- /dev/null +++ b/packages/integrations/node/test/fixtures/api-route/src/pages/hash.ts @@ -0,0 +1,16 @@ +import crypto from 'node:crypto'; + +export async function post({ request }: { request: Request }) { + const hash = crypto.createHash('sha256'); + + const iterable = request.body as unknown as AsyncIterable<Uint8Array>; + for await (const chunk of iterable) { + hash.update(chunk); + } + + return new Response(hash.digest(), { + headers: { + 'Content-Type': 'application/octet-stream' + } + }); +} diff --git a/packages/integrations/node/test/fixtures/prerender-404/package.json b/packages/integrations/node/test/fixtures/prerender-404-500/package.json index dfd109c91..f962fe991 100644 --- a/packages/integrations/node/test/fixtures/prerender-404/package.json +++ b/packages/integrations/node/test/fixtures/prerender-404-500/package.json @@ -1,7 +1,8 @@ { - "name": "@test/nodejs-prerender-404", + "name": "@test/nodejs-prerender-404-500", "version": "0.0.0", "private": true, + "type": "module", "dependencies": { "astro": "workspace:*", "@astrojs/node": "workspace:*" diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/external-stylesheet.css b/packages/integrations/node/test/fixtures/prerender-404-500/src/external-stylesheet.css new file mode 100644 index 000000000..5f331948a --- /dev/null +++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/external-stylesheet.css @@ -0,0 +1,3 @@ +body { + background-color: ivory; +} diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-404.ts b/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-404.ts new file mode 100644 index 000000000..1795c26b0 --- /dev/null +++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-404.ts @@ -0,0 +1,17 @@ +// This module is only used by the prerendered 404.astro. +// It exhibits different behavior if it's called more than once, +// which is detected by a test and interpreted as a failure. + +let usedOnce = false +let dynamicMessage = "Page was not prerendered" + +export default function () { + if (usedOnce === false) { + usedOnce = true + return "Page does not exist" + } + + dynamicMessage += "+" + + return dynamicMessage +} diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-500.ts b/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-500.ts new file mode 100644 index 000000000..8f8024a60 --- /dev/null +++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-500.ts @@ -0,0 +1,17 @@ +// This module is only used by the prerendered 500.astro. +// It exhibits different behavior if it's called more than once, +// which is detected by a test and interpreted as a failure. + +let usedOnce = false +let dynamicMessage = "Page was not prerendered" + +export default function () { + if (usedOnce === false) { + usedOnce = true + return "Something went wrong" + } + + dynamicMessage += "+" + + return dynamicMessage +} diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/404.astro b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/404.astro new file mode 100644 index 000000000..37fd1c1d3 --- /dev/null +++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/404.astro @@ -0,0 +1,5 @@ +--- +import message from "../nondeterminism-404" +export const prerender = true; +--- +{message()} diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/500.astro b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/500.astro new file mode 100644 index 000000000..ef91ad0ff --- /dev/null +++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/500.astro @@ -0,0 +1,6 @@ +--- +import "../external-stylesheet.css" +import message from "../nondeterminism-500" +export const prerender = true +--- +<h1>{message()}</h1> diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/fivehundred.astro b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/fivehundred.astro new file mode 100644 index 000000000..99d103567 --- /dev/null +++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/fivehundred.astro @@ -0,0 +1,4 @@ +--- +return new Response(null, { status: 500 }) +--- +<p>This html will not be served</p> diff --git a/packages/integrations/node/test/fixtures/prerender-404/src/pages/static.astro b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/static.astro index af6bad2fb..af6bad2fb 100644 --- a/packages/integrations/node/test/fixtures/prerender-404/src/pages/static.astro +++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/static.astro diff --git a/packages/integrations/node/test/fixtures/prerender-404/src/pages/404.astro b/packages/integrations/node/test/fixtures/prerender-404/src/pages/404.astro deleted file mode 100644 index 230402bbc..000000000 --- a/packages/integrations/node/test/fixtures/prerender-404/src/pages/404.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -export const prerender = true; ---- - -Page does not exist diff --git a/packages/integrations/node/test/prerender-404.test.js b/packages/integrations/node/test/prerender-404-500.test.js index 3a39a9470..8816ebe4c 100644 --- a/packages/integrations/node/test/prerender-404.test.js +++ b/packages/integrations/node/test/prerender-404-500.test.js @@ -9,10 +9,11 @@ import * as cheerio from 'cheerio'; async function load() { const mod = await import( - `./fixtures/prerender-404/dist/server/entry.mjs?dropcache=${Date.now()}` + `./fixtures/prerender-404-500/dist/server/entry.mjs?dropcache=${Date.now()}` ); return mod; } + describe('Prerender 404', () => { /** @type {import('./test-utils').Fixture} */ let fixture; @@ -24,8 +25,12 @@ describe('Prerender 404', () => { process.env.PRERENDER = true; fixture = await loadFixture({ + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.dev/', base: '/some-base', - root: './fixtures/prerender-404/', + root: './fixtures/prerender-404-500/', output: 'server', adapter: nodejs({ mode: 'standalone' }), }); @@ -51,13 +56,53 @@ describe('Prerender 404', () => { }); it('Can handle prerendered 404', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/missing`); - const html = await res.text(); - const $ = cheerio.load(html); + const url = `http://${server.host}:${server.port}/some-base/missing`; + const res1 = await fetch(url); + const res2 = await fetch(url); + const res3 = await fetch(url); + + expect(res1.status).to.equal(404); + expect(res2.status).to.equal(404); + expect(res3.status).to.equal(404); + + const html1 = await res1.text(); + const html2 = await res2.text(); + const html3 = await res3.text(); + + expect(html1).to.equal(html2); + expect(html2).to.equal(html3); + + const $ = cheerio.load(html1); - expect(res.status).to.equal(404); expect($('body').text()).to.equal('Page does not exist'); }); + + it(' Can handle prerendered 500 called indirectly', async () => { + const url = `http://${server.host}:${server.port}/some-base/fivehundred`; + const response1 = await fetch(url); + const response2 = await fetch(url); + const response3 = await fetch(url); + + expect(response1.status).to.equal(500); + + const html1 = await response1.text(); + const html2 = await response2.text(); + const html3 = await response3.text(); + + expect(html1).to.contain('Something went wrong'); + + expect(html1).to.equal(html2); + expect(html2).to.equal(html3); + }); + + it('prerendered 500 page includes expected styles', async () => { + const response = await fetch(`http://${server.host}:${server.port}/some-base/fivehundred`); + const html = await response.text(); + const $ = cheerio.load(html); + + // length will be 0 if the stylesheet does not get included + expect($('link[rel=stylesheet]')).to.have.a.lengthOf(1); + }); }); describe('Without base', async () => { @@ -66,12 +111,16 @@ describe('Prerender 404', () => { process.env.PRERENDER = true; fixture = await loadFixture({ - root: './fixtures/prerender-404/', + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.info/', + root: './fixtures/prerender-404-500/', output: 'server', adapter: nodejs({ mode: 'standalone' }), }); await fixture.build(); - const { startServer } = await await load(); + const { startServer } = await load(); let res = startServer(); server = res.server; }); @@ -92,11 +141,24 @@ describe('Prerender 404', () => { }); it('Can handle prerendered 404', async () => { - const res = await fetch(`http://${server.host}:${server.port}/missing`); - const html = await res.text(); - const $ = cheerio.load(html); + const url = `http://${server.host}:${server.port}/some-base/missing`; + const res1 = await fetch(url); + const res2 = await fetch(url); + const res3 = await fetch(url); + + expect(res1.status).to.equal(404); + expect(res2.status).to.equal(404); + expect(res3.status).to.equal(404); + + const html1 = await res1.text(); + const html2 = await res2.text(); + const html3 = await res3.text(); + + expect(html1).to.equal(html2); + expect(html2).to.equal(html3); + + const $ = cheerio.load(html1); - expect(res.status).to.equal(404); expect($('body').text()).to.equal('Page does not exist'); }); }); @@ -112,13 +174,17 @@ describe('Hybrid 404', () => { process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.com/', base: '/some-base', - root: './fixtures/prerender-404/', + root: './fixtures/prerender-404-500/', output: 'hybrid', adapter: nodejs({ mode: 'standalone' }), }); await fixture.build(); - const { startServer } = await await load(); + const { startServer } = await load(); let res = startServer(); server = res.server; }); @@ -139,11 +205,24 @@ describe('Hybrid 404', () => { }); it('Can handle prerendered 404', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/missing`); - const html = await res.text(); - const $ = cheerio.load(html); + const url = `http://${server.host}:${server.port}/some-base/missing`; + const res1 = await fetch(url); + const res2 = await fetch(url); + const res3 = await fetch(url); + + expect(res1.status).to.equal(404); + expect(res2.status).to.equal(404); + expect(res3.status).to.equal(404); + + const html1 = await res1.text(); + const html2 = await res2.text(); + const html3 = await res3.text(); + + expect(html1).to.equal(html2); + expect(html2).to.equal(html3); + + const $ = cheerio.load(html1); - expect(res.status).to.equal(404); expect($('body').text()).to.equal('Page does not exist'); }); }); @@ -153,12 +232,16 @@ describe('Hybrid 404', () => { process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ - root: './fixtures/prerender-404/', + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.net/', + root: './fixtures/prerender-404-500/', output: 'hybrid', adapter: nodejs({ mode: 'standalone' }), }); await fixture.build(); - const { startServer } = await await load(); + const { startServer } = await load(); let res = startServer(); server = res.server; }); @@ -179,11 +262,24 @@ describe('Hybrid 404', () => { }); it('Can handle prerendered 404', async () => { - const res = await fetch(`http://${server.host}:${server.port}/missing`); - const html = await res.text(); - const $ = cheerio.load(html); + const url = `http://${server.host}:${server.port}/missing`; + const res1 = await fetch(url); + const res2 = await fetch(url); + const res3 = await fetch(url); + + expect(res1.status).to.equal(404); + expect(res2.status).to.equal(404); + expect(res3.status).to.equal(404); + + const html1 = await res1.text(); + const html2 = await res2.text(); + const html3 = await res3.text(); + + expect(html1).to.equal(html2); + expect(html2).to.equal(html3); + + const $ = cheerio.load(html1); - expect(res.status).to.equal(404); expect($('body').text()).to.equal('Page does not exist'); }); }); diff --git a/packages/integrations/react/CHANGELOG.md b/packages/integrations/react/CHANGELOG.md index cf056a58f..146d36427 100644 --- a/packages/integrations/react/CHANGELOG.md +++ b/packages/integrations/react/CHANGELOG.md @@ -23,6 +23,12 @@ - [`1eae2e3f7`](https://github.com/withastro/astro/commit/1eae2e3f7d693c9dfe91c8ccfbe606d32bf2fb81) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Remove support for Node 16. The lowest supported version by Astro and all integrations is now v18.14.1. As a reminder, Node 16 will be deprecated on the 11th September 2023. +## 2.2.2 + +### Patch Changes + +- [#8075](https://github.com/withastro/astro/pull/8075) [`da517d405`](https://github.com/withastro/astro/commit/da517d4055825ee1b630cd4a6983818d6120a7b7) Thanks [@SudoCat](https://github.com/SudoCat)! - fix a bug where react identifierPrefix was set to null for client:only components causing React.useId to generate ids prefixed with null + ## 2.2.1 ### Patch Changes diff --git a/packages/integrations/react/README.md b/packages/integrations/react/README.md index 48c45881f..8009972b3 100644 --- a/packages/integrations/react/README.md +++ b/packages/integrations/react/README.md @@ -61,6 +61,46 @@ To use your first React component in Astro, head to our [UI framework documentat - 💧 client-side hydration options, and - 🤝 opportunities to mix and nest frameworks together +## Options + +### Children parsing + +Children passed into a React component from an Astro component are parsed as plain strings, not React nodes. + +For example, the `<ReactComponent />` below will only receive a single child element: + +```astro +--- +import ReactComponent from './ReactComponent'; +--- + +<ReactComponent> + <div>one</div> + <div>two</div> +</ReactComponent> +``` + +If you are using a library that _expects_ more than one child element element to be passed, for example so that it can slot certain elements in different places, you might find this to be a blocker. + +You can set the experimental flag `experimentalReactChildren` to tell Astro to always pass children to React as React vnodes. There is some runtime cost to this, but it can help with compatibility. + +You can enable this option in the configuration for the React integration: + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; + +export default defineConfig({ + // ... + integrations: [ + react({ + experimentalReactChildren: true, + }), + ], +}); +``` + ## Troubleshooting For help, check out the `#support` channel on [Discord](https://astro.build/chat). Our friendly Support Squad members are here to help! diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json index 87fd46f94..302ac4ae0 100644 --- a/packages/integrations/react/package.json +++ b/packages/integrations/react/package.json @@ -45,7 +45,8 @@ }, "dependencies": { "@astrojs/internal-helpers": "0.2.0-beta.1", - "@vitejs/plugin-react": "^4.0.3" + "@vitejs/plugin-react": "^4.0.3", + "ultrahtml": "^1.2.0" }, "devDependencies": { "@types/react": "^17.0.62", @@ -53,7 +54,10 @@ "astro": "workspace:*", "astro-scripts": "workspace:*", "react": "^18.1.0", - "react-dom": "^18.1.0" + "react-dom": "^18.1.0", + "chai": "^4.3.7", + "cheerio": "1.0.0-rc.12", + "vite": "^4.4.6" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21", diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js index 8c02c4b26..c2400accb 100644 --- a/packages/integrations/react/server.js +++ b/packages/integrations/react/server.js @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/server'; import StaticHtml from './static-html.js'; import { incrementId } from './context.js'; +import opts from 'astro:react:opts'; const slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase()); const reactTypeof = Symbol.for('react.element'); @@ -85,7 +86,10 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl ...slots, }; const newChildren = children ?? props.children; - if (newChildren != null) { + if (children && opts.experimentalReactChildren) { + const convert = await import('./vnode-children.js').then((mod) => mod.default); + newProps.children = convert(children); + } else if (newChildren != null) { newProps.children = React.createElement(StaticHtml, { hydrate: needsHydration(metadata), value: newChildren, diff --git a/packages/integrations/react/src/index.ts b/packages/integrations/react/src/index.ts index f5332e2ed..a318bb4c2 100644 --- a/packages/integrations/react/src/index.ts +++ b/packages/integrations/react/src/index.ts @@ -2,9 +2,13 @@ import type { AstroIntegration } from 'astro'; import { version as ReactVersion } from 'react-dom'; import react, { type Options as ViteReactPluginOptions } from '@vitejs/plugin-react'; import { appendForwardSlash } from '@astrojs/internal-helpers/path'; +import type * as vite from 'vite'; const FAST_REFRESH_PREAMBLE = react.preambleCode; + + + function getRenderer() { return { name: '@astrojs/react', @@ -17,7 +21,29 @@ function getRenderer() { }; } -function getViteConfiguration({ include, exclude }: Options = {}) { +function optionsPlugin(experimentalReactChildren: boolean): vite.Plugin { + const virtualModule = 'astro:react:opts'; + const virtualModuleId = '\0' + virtualModule; + return { + name: '@astrojs/react:opts', + resolveId(id) { + if (id === virtualModule) { + return virtualModuleId; + } + }, + load(id) { + if (id === virtualModuleId) { + return { + code: `export default { + experimentalReactChildren: ${JSON.stringify(experimentalReactChildren)} + }`, + }; + } + }, + }; +} + +function getViteConfiguration(experimentalReactChildren: boolean, { include, exclude }: Options = {}) { return { optimizeDeps: { include: [ @@ -35,7 +61,10 @@ function getViteConfiguration({ include, exclude }: Options = {}) { : '@astrojs/react/server-v17.js', ], }, - plugins: [react({ include, exclude })], + plugins: [ + react({ include, exclude }), + optionsPlugin(experimentalReactChildren) + ], resolve: { dedupe: ['react', 'react-dom', 'react-dom/server'], }, @@ -55,17 +84,22 @@ function getViteConfiguration({ include, exclude }: Options = {}) { }; } -export type Options = Pick<ViteReactPluginOptions, 'include' | 'exclude'>; +export type ReactIntegrationOptions = Pick<ViteReactPluginOptions, 'include' | 'exclude'> & { + experimentalReactChildren: boolean; +}; export default function ({ include, exclude, -}: Pick<ViteReactPluginOptions, 'include' | 'exclude'> = {}): AstroIntegration { + experimentalReactChildren +}: ReactIntegrationOptions = { + experimentalReactChildren: false +}): AstroIntegration { return { name: '@astrojs/react', hooks: { 'astro:config:setup': ({ config, command, addRenderer, updateConfig, injectScript }) => { addRenderer(getRenderer()); - updateConfig({ vite: getViteConfiguration({ include, exclude }) }); + updateConfig({ vite: getViteConfiguration(experimentalReactChildren, { include, exclude }) }); if (command === 'dev') { const preamble = FAST_REFRESH_PREAMBLE.replace( `__BASE__`, diff --git a/packages/astro/test/fixtures/react-component/astro.config.mjs b/packages/integrations/react/test/fixtures/react-component/astro.config.mjs index 53d0bd03b..cd54d60f8 100644 --- a/packages/astro/test/fixtures/react-component/astro.config.mjs +++ b/packages/integrations/react/test/fixtures/react-component/astro.config.mjs @@ -4,5 +4,7 @@ import vue from '@astrojs/vue'; // https://astro.build/config export default defineConfig({ - integrations: [react(), vue()], -});
\ No newline at end of file + integrations: [react({ + experimentalReactChildren: true, + }), vue()], +}); diff --git a/packages/astro/test/fixtures/react-component/package.json b/packages/integrations/react/test/fixtures/react-component/package.json index cf7b2b057..cf7b2b057 100644 --- a/packages/astro/test/fixtures/react-component/package.json +++ b/packages/integrations/react/test/fixtures/react-component/package.json diff --git a/packages/astro/test/fixtures/react-component/src/components/ArrowFunction.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/ArrowFunction.jsx index 16fac5bb6..16fac5bb6 100644 --- a/packages/astro/test/fixtures/react-component/src/components/ArrowFunction.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/ArrowFunction.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/CloneElement.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/CloneElement.jsx index 809ac4aa4..809ac4aa4 100644 --- a/packages/astro/test/fixtures/react-component/src/components/CloneElement.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/CloneElement.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/ForgotImport.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/ForgotImport.jsx index 9ee27faca..9ee27faca 100644 --- a/packages/astro/test/fixtures/react-component/src/components/ForgotImport.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/ForgotImport.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/GetSearch.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/GetSearch.jsx index d3fee2f9a..d3fee2f9a 100644 --- a/packages/astro/test/fixtures/react-component/src/components/GetSearch.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/GetSearch.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/Goodbye.vue b/packages/integrations/react/test/fixtures/react-component/src/components/Goodbye.vue index 430dfdb71..430dfdb71 100644 --- a/packages/astro/test/fixtures/react-component/src/components/Goodbye.vue +++ b/packages/integrations/react/test/fixtures/react-component/src/components/Goodbye.vue diff --git a/packages/astro/test/fixtures/react-component/src/components/Hello.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/Hello.jsx index 4c241162d..4c241162d 100644 --- a/packages/astro/test/fixtures/react-component/src/components/Hello.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/Hello.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/ImportsThrowsAnError.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/ImportsThrowsAnError.jsx index d6ff21dc3..d6ff21dc3 100644 --- a/packages/astro/test/fixtures/react-component/src/components/ImportsThrowsAnError.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/ImportsThrowsAnError.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/LazyComponent.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/LazyComponent.jsx index b43aa36be..b43aa36be 100644 --- a/packages/astro/test/fixtures/react-component/src/components/LazyComponent.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/LazyComponent.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/PragmaComment.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/PragmaComment.jsx index d8ea77810..d8ea77810 100644 --- a/packages/astro/test/fixtures/react-component/src/components/PragmaComment.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/PragmaComment.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/PragmaCommentTypeScript.tsx b/packages/integrations/react/test/fixtures/react-component/src/components/PragmaCommentTypeScript.tsx index 9f2256fbf..9f2256fbf 100644 --- a/packages/astro/test/fixtures/react-component/src/components/PragmaCommentTypeScript.tsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/PragmaCommentTypeScript.tsx diff --git a/packages/astro/test/fixtures/react-component/src/components/PropsSpread.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/PropsSpread.jsx index 044c2a019..044c2a019 100644 --- a/packages/astro/test/fixtures/react-component/src/components/PropsSpread.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/PropsSpread.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/Pure.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/Pure.jsx index 6fae8613b..6fae8613b 100644 --- a/packages/astro/test/fixtures/react-component/src/components/Pure.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/Pure.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/Research.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/Research.jsx index 9ab83e5f3..9ab83e5f3 100644 --- a/packages/astro/test/fixtures/react-component/src/components/Research.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/Research.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/Suspense.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/Suspense.jsx index 87dc82625..87dc82625 100644 --- a/packages/astro/test/fixtures/react-component/src/components/Suspense.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/Suspense.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/ThrowsAnError.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/ThrowsAnError.jsx index cf970e38c..cf970e38c 100644 --- a/packages/astro/test/fixtures/react-component/src/components/ThrowsAnError.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/ThrowsAnError.jsx diff --git a/packages/astro/test/fixtures/react-component/src/components/TypeScriptComponent.tsx b/packages/integrations/react/test/fixtures/react-component/src/components/TypeScriptComponent.tsx index bde96da84..bde96da84 100644 --- a/packages/astro/test/fixtures/react-component/src/components/TypeScriptComponent.tsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/TypeScriptComponent.tsx diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx new file mode 100644 index 000000000..500c0c694 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function ({ children }) { + return ( + <div> + <div className="with-children">{children}</div> + <div className="with-children-count">{children.length}</div> + </div> + ); +} diff --git a/packages/astro/test/fixtures/react-component/src/components/WithId.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/WithId.jsx index 0abe91c72..0abe91c72 100644 --- a/packages/astro/test/fixtures/react-component/src/components/WithId.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/WithId.jsx diff --git a/packages/integrations/react/test/fixtures/react-component/src/pages/children.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/children.astro new file mode 100644 index 000000000..59595c266 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/children.astro @@ -0,0 +1,14 @@ +--- +import WithChildren from '../components/WithChildren'; +--- + +<html> + <head> + <!-- Head Stuff --> + </head> + <body> + <WithChildren> + <div>child 1</div><div>child 2</div> + </WithChildren> + </body> +</html> diff --git a/packages/astro/test/fixtures/react-component/src/pages/error-rendering.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/error-rendering.astro index 6984a6da5..6984a6da5 100644 --- a/packages/astro/test/fixtures/react-component/src/pages/error-rendering.astro +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/error-rendering.astro diff --git a/packages/astro/test/fixtures/react-component/src/pages/index.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/index.astro index 3afd8233f..3afd8233f 100644 --- a/packages/astro/test/fixtures/react-component/src/pages/index.astro +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/index.astro diff --git a/packages/astro/test/fixtures/react-component/src/pages/pragma-comment.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/pragma-comment.astro index b3ddba639..b3ddba639 100644 --- a/packages/astro/test/fixtures/react-component/src/pages/pragma-comment.astro +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/pragma-comment.astro diff --git a/packages/astro/test/fixtures/react-component/src/pages/suspense.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/suspense.astro index 5a9d15644..5a9d15644 100644 --- a/packages/astro/test/fixtures/react-component/src/pages/suspense.astro +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/suspense.astro diff --git a/packages/astro/test/fixtures/react-component/src/skipped-pages/forgot-import.astro b/packages/integrations/react/test/fixtures/react-component/src/skipped-pages/forgot-import.astro index de5d319d9..de5d319d9 100644 --- a/packages/astro/test/fixtures/react-component/src/skipped-pages/forgot-import.astro +++ b/packages/integrations/react/test/fixtures/react-component/src/skipped-pages/forgot-import.astro diff --git a/packages/astro/test/fixtures/react-component/src/skipped-pages/window.astro b/packages/integrations/react/test/fixtures/react-component/src/skipped-pages/window.astro index e780f3c44..e780f3c44 100644 --- a/packages/astro/test/fixtures/react-component/src/skipped-pages/window.astro +++ b/packages/integrations/react/test/fixtures/react-component/src/skipped-pages/window.astro diff --git a/packages/astro/test/react-component.test.js b/packages/integrations/react/test/react-component.test.js index a6bb8cfae..43df1d9e4 100644 --- a/packages/astro/test/react-component.test.js +++ b/packages/integrations/react/test/react-component.test.js @@ -1,13 +1,13 @@ import { expect } from 'chai'; import { load as cheerioLoad } from 'cheerio'; -import { isWindows, loadFixture } from './test-utils.js'; +import { isWindows, loadFixture } from '../../../astro/test/test-utils.js'; let fixture; describe('React Components', () => { before(async () => { fixture = await loadFixture({ - root: './fixtures/react-component/', + root: new URL('./fixtures/react-component/', import.meta.url), }); }); @@ -51,7 +51,9 @@ describe('React Components', () => { // test 10: Should properly render children passed as props const islandsWithChildren = $('.with-children'); expect(islandsWithChildren).to.have.lengthOf(2); - expect($(islandsWithChildren[0]).html()).to.equal($(islandsWithChildren[1]).html()); + expect($(islandsWithChildren[0]).html()).to.equal( + $(islandsWithChildren[1]).find('astro-slot').html() + ); // test 11: Should generate unique React.useId per island const islandsWithId = $('.react-use-id'); @@ -99,12 +101,18 @@ describe('React Components', () => { const $ = cheerioLoad(html); expect($('#cloned').text()).to.equal('Cloned With Props'); }); + + it('Children are parsed as React components, can be manipulated', async () => { + const html = await fixture.readFile('/children/index.html'); + const $ = cheerioLoad(html); + expect($('.with-children-count').text()).to.equal('2'); + }); }); if (isWindows) return; describe('dev', () => { - /** @type {import('./test-utils').Fixture} */ + /** @type {import('../../../astro/test/test-utils.js').Fixture} */ let devServer; before(async () => { diff --git a/packages/integrations/react/vnode-children.js b/packages/integrations/react/vnode-children.js new file mode 100644 index 000000000..9c7abe644 --- /dev/null +++ b/packages/integrations/react/vnode-children.js @@ -0,0 +1,37 @@ +import { parse, walkSync, DOCUMENT_NODE, ELEMENT_NODE, TEXT_NODE } from 'ultrahtml'; +import { createElement, Fragment } from 'react'; + +export default function convert(children) { + const nodeMap = new WeakMap(); + let doc = parse(children.toString().trim()); + let root = createElement(Fragment, { children: [] }); + + walkSync(doc, (node, parent, index) => { + let newNode = {}; + if (node.type === DOCUMENT_NODE) { + nodeMap.set(node, root); + } else if (node.type === ELEMENT_NODE) { + const { class: className, ...props } = node.attributes; + newNode = createElement(node.name, { ...props, className, children: [] }); + nodeMap.set(node, newNode); + if (parent) { + const newParent = nodeMap.get(parent); + newParent.props.children[index] = newNode; + } + } else if (node.type === TEXT_NODE) { + newNode = node.value.trim(); + if (newNode.trim()) { + if (parent) { + const newParent = nodeMap.get(parent); + if (parent.children.length === 1) { + newParent.props.children[0] = newNode; + } else { + newParent.props.children[index] = newNode; + } + } + } + } + }); + + return root.props.children; +} diff --git a/packages/integrations/sitemap/CHANGELOG.md b/packages/integrations/sitemap/CHANGELOG.md index c6c64db1e..32c54dcc8 100644 --- a/packages/integrations/sitemap/CHANGELOG.md +++ b/packages/integrations/sitemap/CHANGELOG.md @@ -6,6 +6,12 @@ - [`1eae2e3f7`](https://github.com/withastro/astro/commit/1eae2e3f7d693c9dfe91c8ccfbe606d32bf2fb81) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Remove support for Node 16. The lowest supported version by Astro and all integrations is now v18.14.1. As a reminder, Node 16 will be deprecated on the 11th September 2023. +## 2.0.2 + +### Patch Changes + +- [#8063](https://github.com/withastro/astro/pull/8063) [`bee284cb7`](https://github.com/withastro/astro/commit/bee284cb7741ee594e8b38b1a618763e9058740b) Thanks [@martrapp](https://github.com/martrapp)! - docs: fix github search link in README.md + ## 2.0.1 ### Patch Changes diff --git a/packages/integrations/svelte/client.js b/packages/integrations/svelte/client.js index 658ddb02f..0d07ff2ba 100644 --- a/packages/integrations/svelte/client.js +++ b/packages/integrations/svelte/client.js @@ -1,5 +1,8 @@ const noop = () => {}; +let originalConsoleWarning; +let consoleFilterRefs = 0; + export default (target) => { return (Component, props, slotted, { client }) => { if (!target.hasAttribute('ssr')) return; @@ -7,7 +10,10 @@ export default (target) => { for (const [key, value] of Object.entries(slotted)) { slots[key] = createSlotDefinition(key, value); } + try { + if (import.meta.env.DEV) useConsoleFilter(); + new Component({ target, props: { @@ -18,7 +24,10 @@ export default (target) => { hydrate: client !== 'only', $$inline: true, }); - } catch (e) {} + } catch (e) { + } finally { + if (import.meta.env.DEV) finishUsingConsoleFilter(); + } }; }; @@ -51,3 +60,52 @@ function createSlotDefinition(key, children) { noop, ]; } + +/** + * Reduces console noise by filtering known non-problematic warnings. + * + * Performs reference counting to allow parallel usage from async code. + * + * To stop filtering, please ensure that there always is a matching call + * to `finishUsingConsoleFilter` afterwards. + */ +function useConsoleFilter() { + consoleFilterRefs++; + + if (!originalConsoleWarning) { + originalConsoleWarning = console.warn; + try { + console.warn = filteredConsoleWarning; + } catch (error) { + // If we're unable to hook `console.warn`, just accept it + } + } +} + +/** + * Indicates that the filter installed by `useConsoleFilter` + * is no longer needed by the calling code. + */ +function finishUsingConsoleFilter() { + consoleFilterRefs--; + + // Note: Instead of reverting `console.warning` back to the original + // when the reference counter reaches 0, we leave our hook installed + // to prevent potential race conditions once `check` is made async +} + +/** + * Hook/wrapper function for the global `console.warning` function. + * + * Ignores known non-problematic errors while any code is using the console filter. + * Otherwise, simply forwards all arguments to the original function. + */ +function filteredConsoleWarning(msg, ...rest) { + if (consoleFilterRefs > 0 && typeof msg === 'string') { + // Astro passes a `class` prop to the Svelte component, which + // outputs the following warning, which we can safely filter out. + const isKnownSvelteError = msg.endsWith("was created with unknown prop 'class'"); + if (isKnownSvelteError) return; + } + originalConsoleWarning(msg, ...rest); +} diff --git a/packages/integrations/vercel/src/image/build-service.ts b/packages/integrations/vercel/src/image/build-service.ts index 973ceb22a..63a37a5fe 100644 --- a/packages/integrations/vercel/src/image/build-service.ts +++ b/packages/integrations/vercel/src/image/build-service.ts @@ -3,7 +3,7 @@ import { isESMImportedImage, sharedValidateOptions } from './shared'; const service: ExternalImageService = { validateOptions: (options, serviceOptions) => - sharedValidateOptions(options, serviceOptions, 'production'), + sharedValidateOptions(options, serviceOptions.service.config, 'production'), getHTMLAttributes(options) { const { inputtedWidth, ...props } = options; diff --git a/packages/integrations/vercel/src/image/dev-service.ts b/packages/integrations/vercel/src/image/dev-service.ts index d812efb93..72eb7ca0b 100644 --- a/packages/integrations/vercel/src/image/dev-service.ts +++ b/packages/integrations/vercel/src/image/dev-service.ts @@ -4,7 +4,7 @@ import { sharedValidateOptions } from './shared'; const service: LocalImageService = { validateOptions: (options, serviceOptions) => - sharedValidateOptions(options, serviceOptions, 'development'), + sharedValidateOptions(options, serviceOptions.service.config, 'development'), getHTMLAttributes(options, serviceOptions) { const { inputtedWidth, ...props } = options; diff --git a/packages/integrations/vercel/src/image/shared.ts b/packages/integrations/vercel/src/image/shared.ts index 2947b92d8..ad6b45bd0 100644 --- a/packages/integrations/vercel/src/image/shared.ts +++ b/packages/integrations/vercel/src/image/shared.ts @@ -80,10 +80,10 @@ export function getImageConfig( export function sharedValidateOptions( options: ImageTransform, - serviceOptions: Record<string, any>, + serviceConfig: Record<string, any>, mode: 'development' | 'production' ) { - const vercelImageOptions = serviceOptions as VercelImageConfig; + const vercelImageOptions = serviceConfig as VercelImageConfig; if ( mode === 'development' && |