feat(assets): support remote images (#7778)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> Co-authored-by: Princesseuh <princssdev@gmail.com> Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com>
This commit is contained in:
parent
2145960472
commit
d6b4943764
23 changed files with 657 additions and 190 deletions
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
|
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" }],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
|
@ -147,6 +147,7 @@
|
||||||
"github-slugger": "^2.0.0",
|
"github-slugger": "^2.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"html-escaper": "^3.0.3",
|
"html-escaper": "^3.0.3",
|
||||||
|
"http-cache-semantics": "^4.1.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"kleur": "^4.1.4",
|
"kleur": "^4.1.4",
|
||||||
"magic-string": "^0.30.2",
|
"magic-string": "^0.30.2",
|
||||||
|
@ -186,6 +187,7 @@
|
||||||
"@types/estree": "^0.0.51",
|
"@types/estree": "^0.0.51",
|
||||||
"@types/hast": "^2.3.4",
|
"@types/hast": "^2.3.4",
|
||||||
"@types/html-escaper": "^3.0.0",
|
"@types/html-escaper": "^3.0.0",
|
||||||
|
"@types/http-cache-semantics": "^4.0.1",
|
||||||
"@types/js-yaml": "^4.0.5",
|
"@types/js-yaml": "^4.0.5",
|
||||||
"@types/mime": "^2.0.3",
|
"@types/mime": "^2.0.3",
|
||||||
"@types/mocha": "^9.1.1",
|
"@types/mocha": "^9.1.1",
|
||||||
|
|
|
@ -13,6 +13,7 @@ import type { AddressInfo } from 'node:net';
|
||||||
import type * as rollup from 'rollup';
|
import type * as rollup from 'rollup';
|
||||||
import type { TsConfigJson } from 'tsconfig-resolver';
|
import type { TsConfigJson } from 'tsconfig-resolver';
|
||||||
import type * as vite from 'vite';
|
import type * as vite from 'vite';
|
||||||
|
import type { RemotePattern } from '../assets/utils/remotePattern';
|
||||||
import type { SerializedSSRManifest } from '../core/app/types';
|
import type { SerializedSSRManifest } from '../core/app/types';
|
||||||
import type { PageBuildData } from '../core/build/types';
|
import type { PageBuildData } from '../core/build/types';
|
||||||
import type { AstroConfigType } from '../core/config';
|
import type { AstroConfigType } from '../core/config';
|
||||||
|
@ -43,6 +44,7 @@ export type {
|
||||||
ImageQualityPreset,
|
ImageQualityPreset,
|
||||||
ImageTransform,
|
ImageTransform,
|
||||||
} from '../assets/types';
|
} from '../assets/types';
|
||||||
|
export type { RemotePattern } from '../assets/utils/remotePattern';
|
||||||
export type { SSRManifest } from '../core/app/types';
|
export type { SSRManifest } from '../core/app/types';
|
||||||
export type { AstroCookies } from '../core/cookies';
|
export type { AstroCookies } from '../core/cookies';
|
||||||
|
|
||||||
|
@ -366,10 +368,10 @@ export interface ViteUserConfig extends vite.UserConfig {
|
||||||
ssr?: vite.SSROptions;
|
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
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
entrypoint: 'astro/assets/services/sharp' | 'astro/assets/services/squoosh' | (string & {});
|
entrypoint: 'astro/assets/services/sharp' | 'astro/assets/services/squoosh' | (string & {});
|
||||||
config?: Record<string, any>;
|
config?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1010,6 +1012,68 @@ export interface AstroUserConfig {
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
service: ImageServiceConfig;
|
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,127 +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 (!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,11 +1,11 @@
|
||||||
import mime from 'mime/lite.js';
|
import mime from 'mime/lite.js';
|
||||||
import type { APIRoute } from '../@types/astro.js';
|
import type { APIRoute } from '../@types/astro.js';
|
||||||
import { isRemotePath } from '../core/path.js';
|
import { isRemotePath } from '../core/path.js';
|
||||||
import { getConfiguredImageService } from './internal.js';
|
import { getConfiguredImageService, isRemoteAllowed } from './internal.js';
|
||||||
import { isLocalService } from './services/service.js';
|
import { isLocalService } from './services/service.js';
|
||||||
import { etag } from './utils/etag.js';
|
import { etag } from './utils/etag.js';
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
import { imageServiceConfig } from 'astro:assets';
|
import { imageConfig } from 'astro:assets';
|
||||||
|
|
||||||
async function loadRemoteImage(src: URL) {
|
async function loadRemoteImage(src: URL) {
|
||||||
try {
|
try {
|
||||||
|
@ -33,7 +33,7 @@ export const get: APIRoute = async ({ request }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const transform = await imageService.parseURL(url, imageServiceConfig);
|
const transform = await imageService.parseURL(url, imageConfig);
|
||||||
|
|
||||||
if (!transform?.src) {
|
if (!transform?.src) {
|
||||||
throw new Error('Incorrect transform returned by `parseURL`');
|
throw new Error('Incorrect transform returned by `parseURL`');
|
||||||
|
@ -45,17 +45,18 @@ export const get: APIRoute = async ({ request }) => {
|
||||||
const sourceUrl = isRemotePath(transform.src)
|
const sourceUrl = isRemotePath(transform.src)
|
||||||
? new URL(transform.src)
|
? new URL(transform.src)
|
||||||
: new URL(transform.src, url.origin);
|
: 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);
|
inputBuffer = await loadRemoteImage(sourceUrl);
|
||||||
|
|
||||||
if (!inputBuffer) {
|
if (!inputBuffer) {
|
||||||
return new Response('Not Found', { status: 404 });
|
return new Response('Not Found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, format } = await imageService.transform(
|
const { data, format } = await imageService.transform(inputBuffer, transform, imageConfig);
|
||||||
inputBuffer,
|
|
||||||
transform,
|
|
||||||
imageServiceConfig
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Response(data, {
|
return new Response(data, {
|
||||||
status: 200,
|
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 { AstroError, AstroErrorData } from '../core/errors/index.js';
|
||||||
import { isLocalService, type ImageService } from './services/service.js';
|
import { isLocalService, type ImageService } from './services/service.js';
|
||||||
import type {
|
import type {
|
||||||
|
@ -7,6 +8,7 @@ import type {
|
||||||
ImageTransform,
|
ImageTransform,
|
||||||
UnresolvedImageTransform,
|
UnresolvedImageTransform,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { matchHostname, matchPattern } from './utils/remotePattern.js';
|
||||||
|
|
||||||
export function injectImageEndpoint(settings: AstroSettings) {
|
export function injectImageEndpoint(settings: AstroSettings) {
|
||||||
settings.injectedRoutes.push({
|
settings.injectedRoutes.push({
|
||||||
|
@ -22,6 +24,26 @@ export function isESMImportedImage(src: ImageMetadata | string): src is ImageMet
|
||||||
return typeof src === 'object';
|
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> {
|
export async function getConfiguredImageService(): Promise<ImageService> {
|
||||||
if (!globalThis?.astroAsset?.imageService) {
|
if (!globalThis?.astroAsset?.imageService) {
|
||||||
const { default: service }: { default: ImageService } = await import(
|
const { default: service }: { default: ImageService } = await import(
|
||||||
|
@ -43,7 +65,7 @@ export async function getConfiguredImageService(): Promise<ImageService> {
|
||||||
|
|
||||||
export async function getImage(
|
export async function getImage(
|
||||||
options: ImageTransform | UnresolvedImageTransform,
|
options: ImageTransform | UnresolvedImageTransform,
|
||||||
serviceConfig: Record<string, any>
|
imageConfig: AstroConfig['image']
|
||||||
): Promise<GetImageResult> {
|
): Promise<GetImageResult> {
|
||||||
if (!options || typeof options !== 'object') {
|
if (!options || typeof options !== 'object') {
|
||||||
throw new AstroError({
|
throw new AstroError({
|
||||||
|
@ -64,13 +86,18 @@ export async function getImage(
|
||||||
};
|
};
|
||||||
|
|
||||||
const validatedOptions = service.validateOptions
|
const validatedOptions = service.validateOptions
|
||||||
? await service.validateOptions(resolvedOptions, serviceConfig)
|
? await service.validateOptions(resolvedOptions, imageConfig)
|
||||||
: resolvedOptions;
|
: 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
|
// 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);
|
imageURL = globalThis.astroAsset.addStaticImage(validatedOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,7 +107,7 @@ export async function getImage(
|
||||||
src: imageURL,
|
src: imageURL,
|
||||||
attributes:
|
attributes:
|
||||||
service.getHTMLAttributes !== undefined
|
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 { AstroError, AstroErrorData } from '../../core/errors/index.js';
|
||||||
import { joinPaths } from '../../core/path.js';
|
import { joinPaths } from '../../core/path.js';
|
||||||
import { VALID_SUPPORTED_FORMATS } from '../consts.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';
|
import type { ImageOutputFormat, ImageTransform } from '../types.js';
|
||||||
|
|
||||||
export type ImageService = LocalImageService | ExternalImageService;
|
export type ImageService = LocalImageService | ExternalImageService;
|
||||||
|
@ -23,7 +24,11 @@ export function parseQuality(quality: string): string | number {
|
||||||
return result;
|
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.
|
* 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`
|
* 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.
|
* Return any additional HTML attributes separate from `src` that your service requires to show the image properly.
|
||||||
*
|
*
|
||||||
|
@ -41,7 +46,7 @@ interface SharedServiceProps {
|
||||||
*/
|
*/
|
||||||
getHTMLAttributes?: (
|
getHTMLAttributes?: (
|
||||||
options: ImageTransform,
|
options: ImageTransform,
|
||||||
serviceConfig: Record<string, any>
|
imageConfig: ImageConfig<T>
|
||||||
) => Record<string, any> | Promise<Record<string, any>>;
|
) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
/**
|
/**
|
||||||
* Validate and return the options passed by the user.
|
* Validate and return the options passed by the user.
|
||||||
|
@ -53,18 +58,20 @@ interface SharedServiceProps {
|
||||||
*/
|
*/
|
||||||
validateOptions?: (
|
validateOptions?: (
|
||||||
options: ImageTransform,
|
options: ImageTransform,
|
||||||
serviceConfig: Record<string, any>
|
imageConfig: ImageConfig<T>
|
||||||
) => ImageTransform | Promise<ImageTransform>;
|
) => ImageTransform | Promise<ImageTransform>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExternalImageService = SharedServiceProps;
|
export type ExternalImageService<T extends Record<string, any> = Record<string, any>> =
|
||||||
|
SharedServiceProps<T>;
|
||||||
|
|
||||||
export type LocalImageTransform = {
|
export type LocalImageTransform = {
|
||||||
src: string;
|
src: string;
|
||||||
[key: string]: any;
|
[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`.
|
* 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: (
|
parseURL: (
|
||||||
url: URL,
|
url: URL,
|
||||||
serviceConfig: Record<string, any>
|
imageConfig: ImageConfig<T>
|
||||||
) => LocalImageTransform | undefined | Promise<LocalImageTransform> | Promise<undefined>;
|
) => LocalImageTransform | undefined | Promise<LocalImageTransform> | Promise<undefined>;
|
||||||
/**
|
/**
|
||||||
* Performs the image transformations on the input image and returns both the binary data and
|
* 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: (
|
transform: (
|
||||||
inputBuffer: Buffer,
|
inputBuffer: Buffer,
|
||||||
transform: LocalImageTransform,
|
transform: LocalImageTransform,
|
||||||
serviceConfig: Record<string, any>
|
imageConfig: ImageConfig<T>
|
||||||
) => Promise<{ data: Buffer; format: ImageOutputFormat }>;
|
) => Promise<{ data: Buffer; format: ImageOutputFormat }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -202,21 +209,31 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
|
||||||
decoding: attributes.decoding ?? 'async',
|
decoding: attributes.decoding ?? 'async',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getURL(options: ImageTransform) {
|
getURL(options, imageConfig) {
|
||||||
// Both our currently available local services don't handle remote images, so we return the path as is.
|
const searchParams = new URLSearchParams();
|
||||||
if (!isESMImportedImage(options.src)) {
|
|
||||||
|
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;
|
return options.src;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
const params: Record<string, keyof typeof options> = {
|
||||||
searchParams.append('href', options.src.src);
|
w: 'width',
|
||||||
|
h: 'height',
|
||||||
|
q: 'quality',
|
||||||
|
f: 'format',
|
||||||
|
};
|
||||||
|
|
||||||
options.width && searchParams.append('w', options.width.toString());
|
Object.entries(params).forEach(([param, key]) => {
|
||||||
options.height && searchParams.append('h', options.height.toString());
|
options[key] && searchParams.append(param, options[key].toString());
|
||||||
options.quality && searchParams.append('q', options.quality.toString());
|
});
|
||||||
options.format && searchParams.append('f', options.format);
|
|
||||||
|
|
||||||
return joinPaths(import.meta.env.BASE_URL, '/_image?') + searchParams;
|
const imageEndpoint = joinPaths(import.meta.env.BASE_URL, '/_image');
|
||||||
|
return `${imageEndpoint}?${searchParams}`;
|
||||||
},
|
},
|
||||||
parseURL(url) {
|
parseURL(url) {
|
||||||
const params = url.searchParams;
|
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';
|
import type { ImageTransform } from '../types.js';
|
||||||
|
|
||||||
export function propsToFilename(transform: ImageTransform, hash: string) {
|
export function propsToFilename(transform: ImageTransform, hash: string) {
|
||||||
if (!isESMImportedImage(transform.src)) {
|
let filename = removeQueryString(
|
||||||
return transform.src;
|
isESMImportedImage(transform.src) ? transform.src.src : transform.src
|
||||||
}
|
);
|
||||||
|
|
||||||
let filename = removeQueryString(transform.src.src);
|
|
||||||
const ext = extname(filename);
|
const ext = extname(filename);
|
||||||
filename = basename(filename, ext);
|
filename = basename(filename, ext);
|
||||||
const outputExt = transform.format ? `.${transform.format}` : ext;
|
|
||||||
|
let outputExt = transform.format ? `.${transform.format}` : ext;
|
||||||
return `/${filename}_${hash}${outputExt}`;
|
return `/${filename}_${hash}${outputExt}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,6 @@ import {
|
||||||
removeQueryString,
|
removeQueryString,
|
||||||
} from '../core/path.js';
|
} from '../core/path.js';
|
||||||
import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
|
import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
|
||||||
import { isESMImportedImage } from './internal.js';
|
|
||||||
import { emitESMImage } from './utils/emitAsset.js';
|
import { emitESMImage } from './utils/emitAsset.js';
|
||||||
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
|
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
|
||||||
|
|
||||||
|
@ -85,8 +84,8 @@ export default function assets({
|
||||||
import { getImage as getImageInternal } from "astro/assets";
|
import { getImage as getImageInternal } from "astro/assets";
|
||||||
export { default as Image } from "astro/components/Image.astro";
|
export { default as Image } from "astro/components/Image.astro";
|
||||||
|
|
||||||
export const imageServiceConfig = ${JSON.stringify(settings.config.image.service.config)};
|
export const imageConfig = ${JSON.stringify(settings.config.image)};
|
||||||
export const getImage = async (options) => await getImageInternal(options, imageServiceConfig);
|
export const getImage = async (options) => await getImageInternal(options, imageConfig);
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -109,15 +108,10 @@ export default function assets({
|
||||||
if (globalThis.astroAsset.staticImages.has(hash)) {
|
if (globalThis.astroAsset.staticImages.has(hash)) {
|
||||||
filePath = globalThis.astroAsset.staticImages.get(hash)!.path;
|
filePath = globalThis.astroAsset.staticImages.get(hash)!.path;
|
||||||
} else {
|
} 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(
|
filePath = prependForwardSlash(
|
||||||
joinPaths(settings.config.build.assets, propsToFilename(options, hash))
|
joinPaths(settings.config.build.assets, propsToFilename(options, hash))
|
||||||
);
|
);
|
||||||
|
|
||||||
globalThis.astroAsset.staticImages.set(hash, { path: filePath, options: options });
|
globalThis.astroAsset.staticImages.set(hash, { path: filePath, options: options });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ import type {
|
||||||
import {
|
import {
|
||||||
generateImage as generateImageInternal,
|
generateImage as generateImageInternal,
|
||||||
getStaticImageList,
|
getStaticImageList,
|
||||||
} from '../../assets/generate.js';
|
} from '../../assets/build/generate.js';
|
||||||
import {
|
import {
|
||||||
eachPageDataFromEntryPoint,
|
eachPageDataFromEntryPoint,
|
||||||
eachRedirectPageData,
|
eachRedirectPageData,
|
||||||
|
|
|
@ -182,9 +182,35 @@ export const AstroConfigSchema = z.object({
|
||||||
]),
|
]),
|
||||||
config: z.record(z.any()).default({}),
|
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({
|
.default({
|
||||||
service: { entrypoint: 'astro/assets/services/squoosh', config: {} },
|
service: { entrypoint: 'astro/assets/services/squoosh', config: {} },
|
||||||
|
domains: [],
|
||||||
|
remotePatterns: [],
|
||||||
}),
|
}),
|
||||||
markdown: z
|
markdown: z
|
||||||
.object({
|
.object({
|
||||||
|
|
|
@ -25,6 +25,7 @@ describe('astro:image', () => {
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
service: testImageService({ foo: 'bar' }),
|
service: testImageService({ foo: 'bar' }),
|
||||||
|
domains: ['avatars.githubusercontent.com'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -198,6 +199,15 @@ describe('astro:image', () => {
|
||||||
$ = cheerio.load(html);
|
$ = 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 () => {
|
it('includes the provided alt', async () => {
|
||||||
let $img = $('#remote img');
|
let $img = $('#remote img');
|
||||||
expect($img.attr('alt')).to.equal('fred');
|
expect($img.attr('alt')).to.equal('fred');
|
||||||
|
@ -587,6 +597,7 @@ describe('astro:image', () => {
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
service: testImageService(),
|
service: testImageService(),
|
||||||
|
domains: ['astro.build'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Remove cache directory
|
// Remove cache directory
|
||||||
|
@ -604,6 +615,15 @@ describe('astro:image', () => {
|
||||||
expect(data).to.be.an.instanceOf(Buffer);
|
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 () => {
|
it('writes out images to dist folder with proper extension if no format was passed', async () => {
|
||||||
const html = await fixture.readFile('/index.html');
|
const html = await fixture.readFile('/index.html');
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
@ -708,12 +728,15 @@ describe('astro:image', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has cache entries', async () => {
|
it('has cache entries', async () => {
|
||||||
const generatedImages = (await fixture.glob('_astro/**/*.webp')).map((path) =>
|
const generatedImages = (await fixture.glob('_astro/**/*.webp'))
|
||||||
basename(path)
|
.map((path) => basename(path))
|
||||||
);
|
.sort();
|
||||||
const cachedImages = (await fixture.glob('../node_modules/.astro/assets/**/*.webp')).map(
|
const cachedImages = [
|
||||||
(path) => basename(path)
|
...(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);
|
expect(generatedImages).to.deep.equal(cachedImages);
|
||||||
});
|
});
|
||||||
|
|
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>
|
|
@ -17,8 +17,8 @@ export default {
|
||||||
...baseService,
|
...baseService,
|
||||||
getHTMLAttributes(options, serviceConfig) {
|
getHTMLAttributes(options, serviceConfig) {
|
||||||
options['data-service'] = 'my-custom-service';
|
options['data-service'] = 'my-custom-service';
|
||||||
if (serviceConfig.foo) {
|
if (serviceConfig.service.config.foo) {
|
||||||
options['data-service-config'] = serviceConfig.foo;
|
options['data-service-config'] = serviceConfig.service.config.foo;
|
||||||
}
|
}
|
||||||
return baseService.getHTMLAttributes(options);
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,7 +3,7 @@ import { isESMImportedImage, sharedValidateOptions } from './shared';
|
||||||
|
|
||||||
const service: ExternalImageService = {
|
const service: ExternalImageService = {
|
||||||
validateOptions: (options, serviceOptions) =>
|
validateOptions: (options, serviceOptions) =>
|
||||||
sharedValidateOptions(options, serviceOptions, 'production'),
|
sharedValidateOptions(options, serviceOptions.service.config, 'production'),
|
||||||
getHTMLAttributes(options) {
|
getHTMLAttributes(options) {
|
||||||
const { inputtedWidth, ...props } = options;
|
const { inputtedWidth, ...props } = options;
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { sharedValidateOptions } from './shared';
|
||||||
|
|
||||||
const service: LocalImageService = {
|
const service: LocalImageService = {
|
||||||
validateOptions: (options, serviceOptions) =>
|
validateOptions: (options, serviceOptions) =>
|
||||||
sharedValidateOptions(options, serviceOptions, 'development'),
|
sharedValidateOptions(options, serviceOptions.service.config, 'development'),
|
||||||
getHTMLAttributes(options, serviceOptions) {
|
getHTMLAttributes(options, serviceOptions) {
|
||||||
const { inputtedWidth, ...props } = options;
|
const { inputtedWidth, ...props } = options;
|
||||||
|
|
||||||
|
|
|
@ -89,10 +89,10 @@ export function getImageConfig(
|
||||||
|
|
||||||
export function sharedValidateOptions(
|
export function sharedValidateOptions(
|
||||||
options: ImageTransform,
|
options: ImageTransform,
|
||||||
serviceOptions: Record<string, any>,
|
serviceConfig: Record<string, any>,
|
||||||
mode: 'development' | 'production'
|
mode: 'development' | 'production'
|
||||||
) {
|
) {
|
||||||
const vercelImageOptions = serviceOptions as VercelImageConfig;
|
const vercelImageOptions = serviceConfig as VercelImageConfig;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
mode === 'development' &&
|
mode === 'development' &&
|
||||||
|
|
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
@ -578,6 +578,9 @@ importers:
|
||||||
html-escaper:
|
html-escaper:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
|
http-cache-semantics:
|
||||||
|
specifier: ^4.1.1
|
||||||
|
version: 4.1.1
|
||||||
js-yaml:
|
js-yaml:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
@ -690,6 +693,9 @@ importers:
|
||||||
'@types/html-escaper':
|
'@types/html-escaper':
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
'@types/http-cache-semantics':
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.1
|
||||||
'@types/js-yaml':
|
'@types/js-yaml':
|
||||||
specifier: ^4.0.5
|
specifier: ^4.0.5
|
||||||
version: 4.0.5
|
version: 4.0.5
|
||||||
|
|
Loading…
Add table
Reference in a new issue