Merge branch 'main' into next
This commit is contained in:
commit
cbb77af978
136 changed files with 1764 additions and 808 deletions
5
.changeset/big-elephants-drive.md
Normal file
5
.changeset/big-elephants-drive.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@astrojs/svelte': patch
|
||||
---
|
||||
|
||||
Filter unknown `class` prop warnings
|
5
.changeset/brown-wolves-tan.md
Normal file
5
.changeset/brown-wolves-tan.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'create-astro': patch
|
||||
---
|
||||
|
||||
Update 'dev' command for Bun users
|
5
.changeset/eleven-wasps-teach.md
Normal file
5
.changeset/eleven-wasps-teach.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Ensure dotfiles are cleaned during static builds
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Fix AstroConfigSchema type export
|
5
.changeset/itchy-pants-grin.md
Normal file
5
.changeset/itchy-pants-grin.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@astrojs/vercel': patch
|
||||
---
|
||||
|
||||
Update image support to work with latest version of Astro
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
'@astrojs/sitemap': patch
|
||||
---
|
||||
|
||||
docs: fix github search link in README.md
|
5
.changeset/many-actors-flash.md
Normal file
5
.changeset/many-actors-flash.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"astro": patch
|
||||
---
|
||||
|
||||
Fix a handful of edge cases with prerendered 404/500 pages
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
'create-astro': minor
|
||||
---
|
||||
|
||||
Reduce dependency installation size, swap `execa` for light `node:child_process` wrapper
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Add support for non-awaited imports to the Image component and `getImage`
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
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 />`
|
5
.changeset/orange-foxes-care.md
Normal file
5
.changeset/orange-foxes-care.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@astrojs/solid-js': patch
|
||||
---
|
||||
|
||||
Update `babel-preset-solid` dependency to `^1.7.7`
|
5
.changeset/popular-planes-cover.md
Normal file
5
.changeset/popular-planes-cover.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
ViewTransition: bug fix for lost scroll position in browser history
|
5
.changeset/silent-baboons-juggle.md
Normal file
5
.changeset/silent-baboons-juggle.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'create-astro': patch
|
||||
---
|
||||
|
||||
Verify internet connection and that `--template` exists before continuing
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
`astro add` now passes down `--save-prod`, `--save-dev`, `--save-exact`, and `--no-save` flags for installation
|
27
.changeset/sour-frogs-shout.md
Normal file
27
.changeset/sour-frogs-shout.md
Normal file
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Added support for optimizing remote images from authorized sources when using `astro:assets`. This comes with two new parameters to specify which domains (`image.domains`) and host patterns (`image.remotePatterns`) are authorized for remote images.
|
||||
|
||||
For example, the following configuration will only allow remote images from `astro.build` to be optimized:
|
||||
|
||||
```ts
|
||||
// astro.config.mjs
|
||||
export default defineConfig({
|
||||
image: {
|
||||
domains: ["astro.build"],
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The following configuration will only allow remote images from HTTPS hosts:
|
||||
|
||||
```ts
|
||||
// astro.config.mjs
|
||||
export default defineConfig({
|
||||
image: {
|
||||
remotePatterns: [{ protocol: "https" }],
|
||||
}
|
||||
});
|
||||
```
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
'@astrojs/react': patch
|
||||
---
|
||||
|
||||
fix a bug where react identifierPrefix was set to null for client:only components causing React.useId to generate ids prefixed with null
|
21
.changeset/yellow-snakes-jam.md
Normal file
21
.changeset/yellow-snakes-jam.md
Normal file
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
'@astrojs/react': minor
|
||||
---
|
||||
|
||||
Optionally parse React slots as React children.
|
||||
|
||||
This adds a new configuration option for the React integration `experimentalReactChildren`:
|
||||
|
||||
```js
|
||||
export default {
|
||||
integrations: [
|
||||
react({
|
||||
experimentalReactChildren: true,
|
||||
})
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
With this enabled, children passed to React from Astro components via the default slot are parsed as React components.
|
||||
|
||||
This enables better compatibility with certain React components which manipulate their children.
|
|
@ -93,7 +93,7 @@ Astro is generously supported by Netlify, Storyblok, and several other amazing o
|
|||
|
||||
[![Astro's sponsors.](https://astro.build/sponsors.png "Astro's sponsors.
|
||||
Platinum sponsors: Netlify, storyblok, Vercel, Ship Shape, Google Chrome
|
||||
Gold sponsors: ‹div›RIOTS, DEEPGRAM, CloudCannon
|
||||
Gold sponsors: ‹div›RIOTS, DEEPGRAM, Transloadit, CloudCannon
|
||||
Sponsors: Monogram, Qoddi, Dimension")](https://github.com/sponsors/withastro)
|
||||
|
||||
</a>
|
||||
|
|
|
@ -36,7 +36,45 @@ const iconLinks: { label: string; href: string; icon: keyof typeof iconPaths }[]
|
|||
</template>
|
||||
</menu-button>
|
||||
</div>
|
||||
<div id="menu-content">
|
||||
<noscript class="menu-noscript">
|
||||
<ul class="nav-items">
|
||||
{
|
||||
textLinks.map(({ label, href }) => (
|
||||
<li>
|
||||
<a
|
||||
aria-current={Astro.url.pathname === href}
|
||||
class:list={[
|
||||
'link',
|
||||
{
|
||||
active:
|
||||
Astro.url.pathname === href ||
|
||||
(href !== '/' && Astro.url.pathname.startsWith(href)),
|
||||
},
|
||||
]}
|
||||
href={href}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</noscript>
|
||||
<noscript style="display: contents;">
|
||||
<div class="menu-footer">
|
||||
<div class="socials">
|
||||
{
|
||||
iconLinks.map(({ href, icon, label }) => (
|
||||
<a href={href} class="social">
|
||||
<span class="sr-only">{label}</span>
|
||||
<Icon icon={icon} />
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="menu-content" hidden>
|
||||
<ul class="nav-items">
|
||||
{
|
||||
textLinks.map(({ label, href }) => (
|
||||
|
@ -90,6 +128,8 @@ const iconLinks: { label: string; href: string; icon: keyof typeof iconPaths }[]
|
|||
// Hide menu (shown by default to support no-JS browsers).
|
||||
const menu = document.getElementById('menu-content')!;
|
||||
menu.hidden = true;
|
||||
// Add "menu-content" class in JS to avoid covering content in non-JS browsers.
|
||||
menu.classList.add('menu-content');
|
||||
|
||||
/** Set whether the menu is currently expanded or collapsed. */
|
||||
const setExpanded = (expand: boolean) => {
|
||||
|
@ -169,7 +209,7 @@ const iconLinks: { label: string; href: string; icon: keyof typeof iconPaths }[]
|
|||
z-index: -1;
|
||||
}
|
||||
|
||||
#menu-content {
|
||||
.menu-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
@ -254,7 +294,7 @@ const iconLinks: { label: string; href: string; icon: keyof typeof iconPaths }[]
|
|||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
#menu-content {
|
||||
.menu-content {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
import { getEntryBySlug } from 'astro:content';
|
||||
import { getEntry } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
|
||||
const intro = await getEntryBySlug('docs', 'intro');
|
||||
const intro = await getEntry('docs', 'intro');
|
||||
const { Content } = await intro.render();
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}>`;
|
||||
},
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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>[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
174
packages/astro/src/assets/build/generate.ts
Normal file
174
packages/astro/src/assets/build/generate.ts
Normal file
|
@ -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();
|
||||
}
|
48
packages/astro/src/assets/build/remote.ts
Normal file
48
packages/astro/src/assets/build/remote.ts
Normal file
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
63
packages/astro/src/assets/utils/remotePattern.ts
Normal file
63
packages/astro/src/assets/utils/remotePattern.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
@ -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)),
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function makeRequestBody(req: NodeIncomingMessage): BodyProps {
|
||||
if (req.body !== undefined) {
|
||||
if (typeof req.body === 'string' && req.body.length > 0) {
|
||||
return { body: Buffer.from(req.body) };
|
||||
}
|
||||
|
||||
if (typeof req.body === 'object' && req.body !== null && Object.keys(req.body).length > 0) {
|
||||
return { body: Buffer.from(JSON.stringify(req.body)) };
|
||||
}
|
||||
|
||||
// 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 {
|
||||
/**
|
||||
* The read-only body property of the Request interface contains a ReadableStream with the body contents that have been added to the request.
|
||||
* Allow the request body to be explicitly overridden. For example, this
|
||||
* is used by the Express JSON middleware.
|
||||
*/
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export class NodeApp extends App {
|
||||
match(req: NodeIncomingMessage | Request, opts: MatchOptions = {}) {
|
||||
return super.match(req instanceof Request ? req : createRequestFromNodeRequest(req), opts);
|
||||
if (!(req instanceof Request)) {
|
||||
req = createRequestFromNodeRequest(req, {
|
||||
emptyBody: true,
|
||||
});
|
||||
}
|
||||
return super.match(req, opts);
|
||||
}
|
||||
render(req: NodeIncomingMessage | Request, routeData?: RouteData, locals?: object) {
|
||||
if (typeof req.body === 'string' && req.body.length > 0) {
|
||||
return super.render(
|
||||
req instanceof Request ? req : createRequestFromNodeRequest(req, Buffer.from(req.body)),
|
||||
routeData,
|
||||
locals
|
||||
);
|
||||
if (!(req instanceof Request)) {
|
||||
req = createRequestFromNodeRequest(req);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
return reqBodyComplete.then(() => {
|
||||
return super.render(
|
||||
req instanceof Request ? req : createRequestFromNodeRequest(req, body),
|
||||
routeData,
|
||||
locals
|
||||
);
|
||||
});
|
||||
}
|
||||
return super.render(
|
||||
req instanceof Request ? req : createRequestFromNodeRequest(req),
|
||||
routeData,
|
||||
locals
|
||||
);
|
||||
return super.render(req, routeData, locals);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {} },
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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: {
|
||||
|
|
7
packages/astro/test/fixtures/core-image-ssg/src/pages/remote.astro
vendored
Normal file
7
packages/astro/test/fixtures/core-image-ssg/src/pages/remote.astro
vendored
Normal file
|
@ -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>
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"name": "@test/css-inline-stylesheets-auto",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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.
|
|
@ -1,15 +0,0 @@
|
|||
.bg-skyblue {
|
||||
background: skyblue;
|
||||
}
|
||||
|
||||
.bg-lightcoral {
|
||||
background: lightcoral;
|
||||
}
|
||||
|
||||
.red {
|
||||
color: darkred;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: royalblue;
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"name": "@test/css-inline-stylesheets-never",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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.
|
|
@ -1,15 +0,0 @@
|
|||
.bg-skyblue {
|
||||
background: skyblue;
|
||||
}
|
||||
|
||||
.bg-lightcoral {
|
||||
background: lightcoral;
|
||||
}
|
||||
|
||||
.red {
|
||||
color: darkred;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: royalblue;
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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');
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function ({ children }) {
|
||||
return <div className="with-children">{children}</div>;
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
111
packages/astro/test/units/assets/remote-pattern.test.js
Normal file
111
packages/astro/test/units/assets/remote-pattern.test.js
Normal file
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}`;
|
||||
|
|
93
packages/create-astro/src/actions/verify.ts
Normal file
93
packages/create-astro/src/actions/verify.ts
Normal file
|
@ -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',
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
41
packages/create-astro/test/verify.test.js
Normal file
41
packages/create-astro/test/verify.test.js
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
# for tests only
|
||||
|
||||
send_metrics = false
|
||||
|
||||
[vars]
|
||||
SECRET_STUFF = "secret"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
16
packages/integrations/node/test/fixtures/api-route/src/pages/hash.ts
vendored
Normal file
16
packages/integrations/node/test/fixtures/api-route/src/pages/hash.ts
vendored
Normal file
|
@ -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'
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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:*"
|
3
packages/integrations/node/test/fixtures/prerender-404-500/src/external-stylesheet.css
vendored
Normal file
3
packages/integrations/node/test/fixtures/prerender-404-500/src/external-stylesheet.css
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
body {
|
||||
background-color: ivory;
|
||||
}
|
17
packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-404.ts
vendored
Normal file
17
packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-404.ts
vendored
Normal file
|
@ -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
|
||||
}
|
17
packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-500.ts
vendored
Normal file
17
packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-500.ts
vendored
Normal file
|
@ -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
|
||||
}
|
5
packages/integrations/node/test/fixtures/prerender-404-500/src/pages/404.astro
vendored
Normal file
5
packages/integrations/node/test/fixtures/prerender-404-500/src/pages/404.astro
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import message from "../nondeterminism-404"
|
||||
export const prerender = true;
|
||||
---
|
||||
{message()}
|
6
packages/integrations/node/test/fixtures/prerender-404-500/src/pages/500.astro
vendored
Normal file
6
packages/integrations/node/test/fixtures/prerender-404-500/src/pages/500.astro
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
import "../external-stylesheet.css"
|
||||
import message from "../nondeterminism-500"
|
||||
export const prerender = true
|
||||
---
|
||||
<h1>{message()}</h1>
|
4
packages/integrations/node/test/fixtures/prerender-404-500/src/pages/fivehundred.astro
vendored
Normal file
4
packages/integrations/node/test/fixtures/prerender-404-500/src/pages/fivehundred.astro
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
return new Response(null, { status: 500 })
|
||||
---
|
||||
<p>This html will not be served</p>
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
export const prerender = true;
|
||||
---
|
||||
|
||||
Page does not exist
|
|
@ -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,12 +56,52 @@ 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 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($('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);
|
||||
|
||||
expect(res.status).to.equal(404);
|
||||
expect($('body').text()).to.equal('Page does not exist');
|
||||
// length will be 0 if the stylesheet does not get included
|
||||
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue