Adds an @astrojs/image
integration for optimizing images (#3694)
* initial commit * WIP: starting to define interfaces for images and transformers * WIP: basic sharp service to test out the API setup * adding a few tests for sharp.toImageSrc * Adding tests for sharp.parseImageSrc * hooking up basic SSR support * updating image services to return width/height * simplifying config setup for v1 * hooking up basic SSR + SSG support (dev & build) * refactor: a bit of code cleanup and commenting * WIP: migrating local files to ESM + vite plugin * WIP: starting to hook up user-provided loaderEntryPoints * chore: update lock file * chore: update merged lockfile * refactor: code cleanup and type docs * pulling over the README template for first-party integrations * moving metadata out to the loader * updating the test for the refactored import * revert: remove unrelated webapi formatting * revert: remove unrelated change * fixing up the existing sharp tests * fix: vite plugin wasn't dynamically loading the image service properly * refactor: minor API renaming, removing last hard-coded use of sharp loader * don't manipulate src for hosted image services * Adding support for automatically calculating dimensions by aspect ratio, if needed * a few bug fixes + renaming the aspect ratio search param to "ar" * Adding ETag support, removing need for loaders to parse file metadata * using the battle tested `etag` package * Adding support for dynamically calculating partial sizes * refactor: moving to the packages/integrations dir, Astro Labs TBD later * refactor: renaming parse/serialize functions * Adding tests for SSG image optimizations * refactor: clean up outdated names related to ImageProps * nit: reusing cached SSG filename * chore: update pnpm lock file * handling file URLs when resolving local image imports * updating image file resolution to use file URLs * increasing test timeout for image build tests * fixing eslint error in sharp test * adding slash for windows compat in src URLs * chore: update lockfile after merge * Adding README content * adding a readme call to action for configuration options * review: A few of the quick updates from the PR review * hack: adds a one-off check to allow query params for the _image route * Adds support for src={import("...")}, and named component exports * adding SSR tests * nit: adding a bit more comments * limiting the query params in SSG dev to the images integration
This commit is contained in:
parent
0f73ece26b
commit
e8593e7ead
29 changed files with 1489 additions and 26 deletions
|
@ -197,7 +197,9 @@ async function handleRequest(
|
|||
const url = new URL(origin + req.url?.replace(/(index)?\.html$/, ''));
|
||||
const pathname = decodeURI(url.pathname);
|
||||
const rootRelativeUrl = pathname.substring(devRoot.length - 1);
|
||||
if (!buildingToSSR) {
|
||||
|
||||
// HACK! @astrojs/image uses query params for the injected route in `dev`
|
||||
if (!buildingToSSR && rootRelativeUrl !== '/_image') {
|
||||
// Prevent user from depending on search params when not doing SSR.
|
||||
// NOTE: Create an array copy here because deleting-while-iterating
|
||||
// creates bugs where not all search params are removed.
|
||||
|
|
1
packages/integrations/image/.npmignore
Normal file
1
packages/integrations/image/.npmignore
Normal file
|
@ -0,0 +1 @@
|
|||
test/
|
171
packages/integrations/image/README.md
Normal file
171
packages/integrations/image/README.md
Normal file
|
@ -0,0 +1,171 @@
|
|||
# @astrojs/image 📷
|
||||
|
||||
> ⚠️ This integration is still experimental! Only node environments are supported currently, stay tuned for Deno support in the future!
|
||||
|
||||
This **[Astro integration][astro-integration]** makes it easy to optimize images in your [Astro project](https://astro.build), with full support for SSG builds and server-side rendering!
|
||||
|
||||
- <strong>[Why `@astrojs/image`?](#why-astrojs-image)</strong>
|
||||
- <strong>[Installation](#installation)</strong>
|
||||
- <strong>[Usage](#usage)</strong>
|
||||
- <strong>[Configuration](#configuration)</strong>
|
||||
- <strong>[Examples](#examples)</strong>
|
||||
- <strong>[Troubleshooting](#troubleshooting)</strong>
|
||||
- <strong>[Contributing](#contributing)</strong>
|
||||
- <strong>[Changelog](#changelog)</strong>
|
||||
|
||||
## Why `@astrojs/image`?
|
||||
|
||||
Images play a big role in overall site performance and usability. Serving properly sized images makes all the difference but is often tricky to automate.
|
||||
|
||||
This integration provides a basic `<Image />` component and image transformer powered by [sharp](https://sharp.pixelplumbing.com/), with full support for static sites and server-side rendering. The built-in `sharp` transformer is also replacable, opening the door for future integrations that work with your favorite hosted image service.
|
||||
|
||||
## Installation
|
||||
|
||||
<details>
|
||||
<summary>Quick Install</summary>
|
||||
<br/>
|
||||
|
||||
The experimental `astro add` command-line tool automates the installation for you. Run one of the following commands in a new terminal window. (If you aren't sure which package manager you're using, run the first command.) Then, follow the prompts, and type "y" in the terminal (meaning "yes") for each one.
|
||||
|
||||
```sh
|
||||
# Using NPM
|
||||
npx astro add image
|
||||
# Using Yarn
|
||||
yarn astro add image
|
||||
# Using PNPM
|
||||
pnpx astro add image
|
||||
```
|
||||
|
||||
Then, restart the dev server by typing `CTRL-C` and then `npm run astro dev` in the terminal window that was running Astro.
|
||||
|
||||
Because this command is new, it might not properly set things up. If that happens, [feel free to log an issue on our GitHub](https://github.com/withastro/astro/issues) and try the manual installation steps below.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Manual Install</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
First, install the `@astrojs/image` package using your package manager. If you're using npm or aren't sure, run this in the terminal:
|
||||
```sh
|
||||
npm install @astrojs/image
|
||||
```
|
||||
Then, apply this integration to your `astro.config.*` file using the `integrations` property:
|
||||
|
||||
__astro.config.mjs__
|
||||
|
||||
```js
|
||||
import image from '@astrojs/image';
|
||||
|
||||
export default {
|
||||
// ...
|
||||
integrations: [image()],
|
||||
}
|
||||
```
|
||||
|
||||
Then, restart the dev server.
|
||||
</details>
|
||||
|
||||
## Usage
|
||||
|
||||
The built-in `<Image />` component is used to create an optimized `<img />` for both remote images hosted on other domains as well as local images imported from your project's `src` directory.
|
||||
|
||||
The included `sharp` transformer supports resizing images and encoding them to different image formats. Third-party image services will be able to add support for custom transformations as well (ex: `blur`, `filter`, `rotate`, etc).
|
||||
|
||||
## Configuration
|
||||
|
||||
The intergration can be configured to run with a different image service, either a hosted image service or a full image transformer that runs locally in your build or SSR deployment.
|
||||
|
||||
There are currently no other configuration options for the `@astrojs/image` integration. Please [open an issue](https://github.com/withastro/astro/issues/new/choose) if you have a compelling use case to share.
|
||||
|
||||
<details>
|
||||
<summary><strong>config.serviceEntryPoint</strong></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
The `serviceEntryPoint` should resolve to the image service installed from NPM. The default entry point is `@astrojs/image/sharp`, which resolves to the entry point exported from this integration's `package.json`.
|
||||
|
||||
```js
|
||||
// astro.config.mjs
|
||||
import image from '@astrojs/image';
|
||||
|
||||
export default {
|
||||
integrations: [image({
|
||||
// Example: The entrypoint for a third-party image service installed from NPM
|
||||
serviceEntryPoint: 'my-image-service/astro.js'
|
||||
})],
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
## Examples
|
||||
|
||||
<details>
|
||||
<summary><strong>Local images</strong></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
Image files in your project's `src` directory can be imported in frontmatter and passed directly to the `<Image />` component. All other properties are optional and will default to the original image file's properties if not provided.
|
||||
|
||||
```html
|
||||
---
|
||||
import { Image } from '@astrojs/image/components';
|
||||
import heroImage from '../assets/hero.png';
|
||||
---
|
||||
|
||||
// optimized image, keeping the original width, height, and image format
|
||||
<Image src={heroImage} />
|
||||
|
||||
// height will be recalculated to match the original aspect ratio
|
||||
<Image src={heroImage} width={300} />
|
||||
|
||||
// cropping to a specific width and height
|
||||
<Image src={heroImage} width={300} height={600} />
|
||||
|
||||
// cropping to a specific aspect ratio and converting to an avif format
|
||||
<Image src={heroImage} aspectRatio="16:9" format="avif" />
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Remote images</strong></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
Remote images can be transformed with the `<Image />` component. The `<Image />` component needs to know the final dimensions for the `<img />` element to avoid content layout shifts. For remote images, this means you must either provide `width` and `height`, or one of the dimensions plus the required `aspectRatio`.
|
||||
|
||||
```html
|
||||
---
|
||||
import { Image } from '@astrojs/image/components';
|
||||
|
||||
const imageUrl = 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png';
|
||||
---
|
||||
|
||||
// cropping to a specific width and height
|
||||
<Image src={imageUrl} width={544} height={184} />
|
||||
|
||||
// height will be recalculated to match the aspect ratio
|
||||
<Image src={imageUrl} width={300} aspectRatio={16/9} />
|
||||
|
||||
// cropping to a specific height and aspect ratio and converting to an avif format
|
||||
<Image src={imageUrl} height={200} aspectRatio="16:9" format="avif" />
|
||||
```
|
||||
</details>
|
||||
|
||||
## Troubleshooting
|
||||
- If your installation doesn't seem to be working, make sure to restart the dev server.
|
||||
- If you edit and save a file and don't see your site update accordingly, try refreshing the page.
|
||||
- If you edit and save a file and don't see your site update accordingly, try refreshing the page.
|
||||
- If refreshing the page doesn't update your preview, or if a new installation doesn't seem to be working, then restart the dev server.
|
||||
|
||||
For help, check out the `#support-threads` channel on [Discord](https://astro.build/chat). Our friendly Support Squad members are here to help!
|
||||
|
||||
You can also check our [Astro Integration Documentation][astro-integration] for more on integrations.
|
||||
|
||||
[astro-integration]: https://docs.astro.build/en/guides/integrations-guide/
|
||||
|
||||
## Contributing
|
||||
|
||||
This package is maintained by Astro's Core team. You're welcome to submit an issue or PR!
|
||||
|
||||
## Changelog
|
125
packages/integrations/image/components/Image.astro
Normal file
125
packages/integrations/image/components/Image.astro
Normal file
|
@ -0,0 +1,125 @@
|
|||
---
|
||||
// @ts-ignore
|
||||
import loader from 'virtual:image-loader';
|
||||
import { getImage } from '../src';
|
||||
import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../src/types';
|
||||
|
||||
export interface LocalImageProps extends Omit<TransformOptions, 'src'>, Omit<ImageAttributes, 'src'> {
|
||||
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
|
||||
}
|
||||
|
||||
export interface RemoteImageProps extends TransformOptions, ImageAttributes {
|
||||
src: string;
|
||||
format: OutputFormat;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type Props = LocalImageProps | RemoteImageProps;
|
||||
|
||||
function isLocalImage(props: Props): props is LocalImageProps {
|
||||
// vite-plugin-astro-image resolves ESM imported images
|
||||
// to a metadata object
|
||||
return typeof props.src !== 'string';
|
||||
}
|
||||
|
||||
function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) {
|
||||
if (!aspectRatio) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// parse aspect ratio strings, if required (ex: "16:9")
|
||||
if (typeof aspectRatio === 'number') {
|
||||
aspectRatio = aspectRatio;
|
||||
} else {
|
||||
const [width, height] = aspectRatio.split(':');
|
||||
aspectRatio = parseInt(width) / parseInt(height);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveProps(props: Props): Promise<TransformOptions> {
|
||||
// For remote images, just check the width/height provided
|
||||
if (!isLocalImage(props)) {
|
||||
return calculateSize(props);
|
||||
}
|
||||
|
||||
let { width, height, aspectRatio, format, ...rest } = props;
|
||||
|
||||
// if a Promise<ImageMetadata> was provided, unwrap it first
|
||||
const { src, ...metadata } = 'then' in props.src ? (await props.src).default : props.src;
|
||||
|
||||
if (!width && !height) {
|
||||
// neither dimension was provided, use the file metadata
|
||||
width = metadata.width;
|
||||
height = metadata.height;
|
||||
} else if (width) {
|
||||
// one dimension was provided, calculate the other
|
||||
let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
|
||||
height = height || width / ratio;
|
||||
} else if (height) {
|
||||
// one dimension was provided, calculate the other
|
||||
let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height;
|
||||
width = width || height * ratio;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
width,
|
||||
height,
|
||||
aspectRatio,
|
||||
src,
|
||||
format: format || metadata.format as OutputFormat,
|
||||
}
|
||||
}
|
||||
|
||||
function calculateSize(transform: TransformOptions): TransformOptions {
|
||||
// keep width & height as provided
|
||||
if (transform.width && transform.height) {
|
||||
return transform;
|
||||
}
|
||||
|
||||
if (!transform.width && !transform.height) {
|
||||
throw new Error(`"width" and "height" cannot both be undefined`);
|
||||
}
|
||||
|
||||
if (!transform.aspectRatio) {
|
||||
throw new Error(`"aspectRatio" must be included if only "${transform.width ? "width": "height"}" is provided`)
|
||||
}
|
||||
|
||||
let aspectRatio: number;
|
||||
|
||||
// parse aspect ratio strings, if required (ex: "16:9")
|
||||
if (typeof transform.aspectRatio === 'number') {
|
||||
aspectRatio = transform.aspectRatio;
|
||||
} else {
|
||||
const [width, height] = transform.aspectRatio.split(':');
|
||||
aspectRatio = parseInt(width) / parseInt(height);
|
||||
}
|
||||
|
||||
if (transform.width) {
|
||||
// only width was provided, calculate height
|
||||
return {
|
||||
...transform,
|
||||
width: transform.width,
|
||||
height: transform.width / aspectRatio
|
||||
};
|
||||
} else if (transform.height) {
|
||||
// only height was provided, calculate width
|
||||
return {
|
||||
...transform,
|
||||
width: transform.height * aspectRatio,
|
||||
height: transform.height
|
||||
}
|
||||
}
|
||||
|
||||
return transform;
|
||||
}
|
||||
|
||||
const props = Astro.props as Props;
|
||||
|
||||
const imageProps = await resolveProps(props);
|
||||
|
||||
const attrs = await getImage(loader, imageProps);
|
||||
---
|
||||
|
||||
<img {...attrs} />
|
1
packages/integrations/image/components/index.ts
Normal file
1
packages/integrations/image/components/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as Image } from './Image.astro';
|
53
packages/integrations/image/package.json
Normal file
53
packages/integrations/image/package.json
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "@astrojs/image",
|
||||
"description": "Load and transform images in your Astro site.",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"types": "./dist/types.d.ts",
|
||||
"author": "withastro",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/withastro/astro.git",
|
||||
"directory": "packages/integrations/image"
|
||||
},
|
||||
"keywords": [
|
||||
"astro-component",
|
||||
"withastro",
|
||||
"image"
|
||||
],
|
||||
"bugs": "https://github.com/withastro/astro/issues",
|
||||
"homepage": "https://astro.build",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./sharp": "./dist/loaders/sharp.js",
|
||||
"./endpoints/dev": "./dist/endpoints/dev.js",
|
||||
"./endpoints/prod": "./dist/endpoints/prod.js",
|
||||
"./components": "./components/index.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"components",
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
|
||||
"build:ci": "astro-scripts build \"src/**/*.ts\"",
|
||||
"dev": "astro-scripts dev \"src/**/*.ts\"",
|
||||
"test": "mocha --exit --timeout 20000 test"
|
||||
},
|
||||
"dependencies": {
|
||||
"etag": "^1.8.1",
|
||||
"image-size": "^1.0.1",
|
||||
"image-type": "^4.1.0",
|
||||
"mrmime": "^1.0.0",
|
||||
"sharp": "^0.30.6",
|
||||
"slash": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/etag": "^1.8.1",
|
||||
"@types/sharp": "^0.30.4",
|
||||
"astro": "workspace:*",
|
||||
"astro-scripts": "workspace:*"
|
||||
}
|
||||
}
|
33
packages/integrations/image/src/endpoints/dev.ts
Normal file
33
packages/integrations/image/src/endpoints/dev.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
// @ts-ignore
|
||||
import loader from 'virtual:image-loader';
|
||||
import { lookup } from 'mrmime';
|
||||
import { loadImage } from '../utils.js';
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const get: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const transform = loader.parseTransform(url.searchParams);
|
||||
|
||||
if (!transform) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const inputBuffer = await loadImage(transform.src);
|
||||
|
||||
if (!inputBuffer) {
|
||||
return new Response(`"${transform.src} not found`, { status: 404 });
|
||||
}
|
||||
|
||||
const { data, format } = await loader.transform(inputBuffer, transform);
|
||||
|
||||
return new Response(data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': lookup(format) || ''
|
||||
}
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return new Response(`Server Error: ${err}`, { status: 500 });
|
||||
}
|
||||
}
|
40
packages/integrations/image/src/endpoints/prod.ts
Normal file
40
packages/integrations/image/src/endpoints/prod.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
// @ts-ignore
|
||||
import loader from 'virtual:image-loader';
|
||||
import etag from 'etag';
|
||||
import { lookup } from 'mrmime';
|
||||
import { isRemoteImage, loadRemoteImage } from '../utils.js';
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const get: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const transform = loader.parseTransform(url.searchParams);
|
||||
|
||||
if (!transform) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: Can we lean on fs to load local images in SSR prod builds?
|
||||
const href = isRemoteImage(transform.src) ? new URL(transform.src) : new URL(transform.src, url.origin);
|
||||
|
||||
const inputBuffer = await loadRemoteImage(href.toString());
|
||||
|
||||
if (!inputBuffer) {
|
||||
return new Response(`"${transform.src} not found`, { status: 404 });
|
||||
}
|
||||
|
||||
const { data, format } = await loader.transform(inputBuffer, transform);
|
||||
|
||||
return new Response(data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': lookup(format) || '',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'ETag': etag(inputBuffer),
|
||||
'Date': (new Date()).toUTCString(),
|
||||
}
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return new Response(`Server Error: ${err}`, { status: 500 });
|
||||
}
|
||||
}
|
139
packages/integrations/image/src/index.ts
Normal file
139
packages/integrations/image/src/index.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import slash from 'slash';
|
||||
import { ensureDir, isRemoteImage, loadLocalImage, loadRemoteImage, propsToFilename } from './utils.js';
|
||||
import { createPlugin } from './vite-plugin-astro-image.js';
|
||||
import type { AstroConfig, AstroIntegration } from 'astro';
|
||||
import type { ImageAttributes, IntegrationOptions, SSRImageService, TransformOptions } from './types';
|
||||
|
||||
const PKG_NAME = '@astrojs/image';
|
||||
const ROUTE_PATTERN = '/_image';
|
||||
const OUTPUT_DIR = '/_image';
|
||||
|
||||
/**
|
||||
* Gets the HTML attributes required to build an `<img />` for the transformed image.
|
||||
*
|
||||
* @param loader @type {ImageService} The image service used for transforming images.
|
||||
* @param transform @type {TransformOptions} The transformations requested for the optimized image.
|
||||
* @returns @type {ImageAttributes} The HTML attributes to be included on the built `<img />` element.
|
||||
*/
|
||||
export async function getImage(loader: SSRImageService, transform: TransformOptions): Promise<ImageAttributes> {
|
||||
(globalThis as any).loader = loader;
|
||||
|
||||
const attributes = await loader.getImageAttributes(transform);
|
||||
|
||||
// For SSR services, build URLs for the injected route
|
||||
if (typeof loader.transform === 'function') {
|
||||
const { searchParams } = loader.serializeTransform(transform);
|
||||
|
||||
// cache all images rendered to HTML
|
||||
if (globalThis && (globalThis as any).addStaticImage) {
|
||||
(globalThis as any)?.addStaticImage(transform);
|
||||
}
|
||||
|
||||
const src = globalThis && (globalThis as any).filenameFormat
|
||||
? (globalThis as any).filenameFormat(transform, searchParams)
|
||||
: `${ROUTE_PATTERN}?${searchParams.toString()}`;
|
||||
|
||||
return {
|
||||
...attributes,
|
||||
src: slash(src), // Windows compat
|
||||
}
|
||||
}
|
||||
|
||||
// For hosted services, return the <img /> attributes as-is
|
||||
return attributes;
|
||||
}
|
||||
|
||||
const createIntegration = (options: IntegrationOptions = {}): AstroIntegration => {
|
||||
const resolvedOptions = {
|
||||
serviceEntryPoint: '@astrojs/image/sharp',
|
||||
...options
|
||||
};
|
||||
|
||||
// During SSG builds, this is used to track all transformed images required.
|
||||
const staticImages = new Map<string, TransformOptions>();
|
||||
|
||||
let _config: AstroConfig;
|
||||
|
||||
function getViteConfiguration() {
|
||||
return {
|
||||
plugins: [
|
||||
createPlugin(_config, resolvedOptions)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: PKG_NAME,
|
||||
hooks: {
|
||||
'astro:config:setup': ({ command, config, injectRoute, updateConfig }) => {
|
||||
_config = config;
|
||||
|
||||
// Always treat `astro dev` as SSR mode, even without an adapter
|
||||
const mode = command === 'dev' || config.adapter ? 'ssr' : 'ssg';
|
||||
|
||||
updateConfig({ vite: getViteConfiguration() });
|
||||
|
||||
// Used to cache all images rendered to HTML
|
||||
// Added to globalThis to share the same map in Node and Vite
|
||||
(globalThis as any).addStaticImage = (transform: TransformOptions) => {
|
||||
staticImages.set(propsToFilename(transform), transform);
|
||||
}
|
||||
|
||||
// TODO: Add support for custom, user-provided filename format functions
|
||||
(globalThis as any).filenameFormat = (transform: TransformOptions, searchParams: URLSearchParams) => {
|
||||
if (mode === 'ssg') {
|
||||
return isRemoteImage(transform.src)
|
||||
? path.join(OUTPUT_DIR, path.basename(propsToFilename(transform)))
|
||||
: path.join(OUTPUT_DIR, path.dirname(transform.src), path.basename(propsToFilename(transform)));
|
||||
} else {
|
||||
return `${ROUTE_PATTERN}?${searchParams.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'ssr') {
|
||||
injectRoute({
|
||||
pattern: ROUTE_PATTERN,
|
||||
entryPoint: command === 'dev' ? '@astrojs/image/endpoints/dev' : '@astrojs/image/endpoints/prod'
|
||||
});
|
||||
}
|
||||
},
|
||||
'astro:build:done': async ({ dir }) => {
|
||||
for await (const [filename, transform] of staticImages) {
|
||||
const loader = (globalThis as any).loader;
|
||||
|
||||
let inputBuffer: Buffer | undefined = undefined;
|
||||
let outputFile: string;
|
||||
|
||||
if (isRemoteImage(transform.src)) {
|
||||
// try to load the remote image
|
||||
inputBuffer = await loadRemoteImage(transform.src);
|
||||
|
||||
const outputFileURL = new URL(path.join('./', OUTPUT_DIR, path.basename(filename)), dir);
|
||||
outputFile = fileURLToPath(outputFileURL);
|
||||
} else {
|
||||
const inputFileURL = new URL(`.${transform.src}`, _config.srcDir);
|
||||
const inputFile = fileURLToPath(inputFileURL);
|
||||
inputBuffer = await loadLocalImage(inputFile);
|
||||
|
||||
const outputFileURL = new URL(path.join('./', OUTPUT_DIR, filename), dir);
|
||||
outputFile = fileURLToPath(outputFileURL);
|
||||
}
|
||||
|
||||
if (!inputBuffer) {
|
||||
console.warn(`"${transform.src}" image could not be fetched`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data } = await loader.transform(inputBuffer, transform);
|
||||
ensureDir(path.dirname(outputFile));
|
||||
await fs.writeFile(outputFile, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default createIntegration;
|
105
packages/integrations/image/src/loaders/sharp.ts
Normal file
105
packages/integrations/image/src/loaders/sharp.ts
Normal file
|
@ -0,0 +1,105 @@
|
|||
import sharp from 'sharp';
|
||||
import { isAspectRatioString, isOutputFormat } from '../utils.js';
|
||||
import type { TransformOptions, OutputFormat, SSRImageService } from '../types';
|
||||
|
||||
class SharpService implements SSRImageService {
|
||||
async getImageAttributes(transform: TransformOptions) {
|
||||
const { width, height, src, format, quality, aspectRatio, ...rest } = transform;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
width: width,
|
||||
height: height
|
||||
}
|
||||
}
|
||||
|
||||
serializeTransform(transform: TransformOptions) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (transform.quality) {
|
||||
searchParams.append('q', transform.quality.toString());
|
||||
}
|
||||
|
||||
if (transform.format) {
|
||||
searchParams.append('f', transform.format);
|
||||
}
|
||||
|
||||
if (transform.width) {
|
||||
searchParams.append('w', transform.width.toString());
|
||||
}
|
||||
|
||||
if (transform.height) {
|
||||
searchParams.append('h', transform.height.toString());
|
||||
}
|
||||
|
||||
if (transform.aspectRatio) {
|
||||
searchParams.append('ar', transform.aspectRatio.toString());
|
||||
}
|
||||
|
||||
searchParams.append('href', transform.src);
|
||||
|
||||
return { searchParams };
|
||||
}
|
||||
|
||||
parseTransform(searchParams: URLSearchParams) {
|
||||
if (!searchParams.has('href')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let transform: TransformOptions = { src: searchParams.get('href')! };
|
||||
|
||||
if (searchParams.has('q')) {
|
||||
transform.quality = parseInt(searchParams.get('q')!);
|
||||
}
|
||||
|
||||
if (searchParams.has('f')) {
|
||||
const format = searchParams.get('f')!;
|
||||
if (isOutputFormat(format)) {
|
||||
transform.format = format;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchParams.has('w')) {
|
||||
transform.width = parseInt(searchParams.get('w')!);
|
||||
}
|
||||
|
||||
if (searchParams.has('h')) {
|
||||
transform.height = parseInt(searchParams.get('h')!);
|
||||
}
|
||||
|
||||
if (searchParams.has('ar')) {
|
||||
const ratio = searchParams.get('ar')!;
|
||||
|
||||
if (isAspectRatioString(ratio)) {
|
||||
transform.aspectRatio = ratio;
|
||||
} else {
|
||||
transform.aspectRatio = parseFloat(ratio);
|
||||
}
|
||||
}
|
||||
|
||||
return transform;
|
||||
}
|
||||
|
||||
async transform(inputBuffer: Buffer, transform: TransformOptions) {
|
||||
const sharpImage = sharp(inputBuffer, { failOnError: false });
|
||||
|
||||
if (transform.width || transform.height) {
|
||||
sharpImage.resize(transform.width, transform.height);
|
||||
}
|
||||
|
||||
if (transform.format) {
|
||||
sharpImage.toFormat(transform.format, { quality: transform.quality });
|
||||
}
|
||||
|
||||
const { data, info } = await sharpImage.toBuffer({ resolveWithObject: true });
|
||||
|
||||
return {
|
||||
data,
|
||||
format: info.format as OutputFormat,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const service = new SharpService();
|
||||
|
||||
export default service;
|
20
packages/integrations/image/src/metadata.ts
Normal file
20
packages/integrations/image/src/metadata.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import fs from 'fs/promises';
|
||||
import sizeOf from 'image-size';
|
||||
import { ImageMetadata, InputFormat } from './types';
|
||||
|
||||
export async function metadata(src: string): Promise<ImageMetadata | undefined> {
|
||||
const file = await fs.readFile(src);
|
||||
|
||||
const { width, height, type } = await sizeOf(file);
|
||||
|
||||
if (!width || !height || !type) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
format: type as InputFormat
|
||||
}
|
||||
}
|
123
packages/integrations/image/src/types.ts
Normal file
123
packages/integrations/image/src/types.ts
Normal file
|
@ -0,0 +1,123 @@
|
|||
export * from './index';
|
||||
|
||||
export type InputFormat =
|
||||
| 'heic'
|
||||
| 'heif'
|
||||
| 'avif'
|
||||
| 'jpeg'
|
||||
| 'jpg'
|
||||
| 'png'
|
||||
| 'tiff'
|
||||
| 'webp'
|
||||
| 'gif';
|
||||
|
||||
export type OutputFormat =
|
||||
| 'avif'
|
||||
| 'jpeg'
|
||||
| 'png'
|
||||
| 'webp';
|
||||
|
||||
/**
|
||||
* Converts a set of image transforms to the filename to use when building for static.
|
||||
*
|
||||
* This is only used for static production builds and ignored when an SSR adapter is used,
|
||||
* or in `astro dev` for static builds.
|
||||
*/
|
||||
export type FilenameFormatter = (transform: TransformOptions) => string;
|
||||
|
||||
export interface IntegrationOptions {
|
||||
/**
|
||||
* Entry point for the @type {HostedImageService} or @type {LocalImageService} to be used.
|
||||
*/
|
||||
serviceEntryPoint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the original image and transforms that need to be applied to it.
|
||||
*/
|
||||
export interface TransformOptions {
|
||||
/**
|
||||
* Source for the original image file.
|
||||
*
|
||||
* For images in your project's repository, use the `src` relative to the `public` directory.
|
||||
* For remote images, provide the full URL.
|
||||
*/
|
||||
src: string;
|
||||
/**
|
||||
* The output format to be used in the optimized image.
|
||||
*
|
||||
* @default undefined The original image format will be used.
|
||||
*/
|
||||
format?: OutputFormat;
|
||||
/**
|
||||
* The compression quality used during optimization.
|
||||
*
|
||||
* @default undefined Allows the image service to determine defaults.
|
||||
*/
|
||||
quality?: number;
|
||||
/**
|
||||
* The desired width of the output image. Combine with `height` to crop the image
|
||||
* to an exact size, or `aspectRatio` to automatically calculate and crop the height.
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* The desired height of the output image. Combine with `height` to crop the image
|
||||
* to an exact size, or `aspectRatio` to automatically calculate and crop the width.
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* The desired aspect ratio of the output image. Combine with either `width` or `height`
|
||||
* to automatically calculate and crop the other dimension.
|
||||
*
|
||||
* @example 1.777 - numbers can be used for computed ratios, useful for doing `{width/height}`
|
||||
* @example "16:9" - strings can be used in the format of `{ratioWidth}:{ratioHeight}`.
|
||||
*/
|
||||
aspectRatio?: number | `${number}:${number}`;
|
||||
}
|
||||
|
||||
export type ImageAttributes = Partial<HTMLImageElement>;
|
||||
|
||||
export interface HostedImageService<T extends TransformOptions = TransformOptions> {
|
||||
/**
|
||||
* Gets the HTML attributes needed for the server rendered `<img />` element.
|
||||
*/
|
||||
getImageAttributes(transform: T): Promise<ImageAttributes>;
|
||||
}
|
||||
|
||||
export interface SSRImageService<T extends TransformOptions = TransformOptions> extends HostedImageService<T> {
|
||||
/**
|
||||
* Gets the HTML attributes needed for the server rendered `<img />` element.
|
||||
*/
|
||||
getImageAttributes(transform: T): Promise<Exclude<ImageAttributes, 'src'>>;
|
||||
/**
|
||||
* Serializes image transformation properties to URLSearchParams, used to build
|
||||
* the final `src` that points to the self-hosted SSR endpoint.
|
||||
*
|
||||
* @param transform @type {TransformOptions} defining the requested image transformation.
|
||||
*/
|
||||
serializeTransform(transform: T): { searchParams: URLSearchParams };
|
||||
/**
|
||||
* The reverse of `serializeTransform(transform)`, this parsed the @type {TransformOptions} back out of a given URL.
|
||||
*
|
||||
* @param searchParams @type {URLSearchParams}
|
||||
* @returns @type {TransformOptions} used to generate the URL, or undefined if the URL isn't valid.
|
||||
*/
|
||||
parseTransform(searchParams: URLSearchParams): T | undefined;
|
||||
/**
|
||||
* Performs the image transformations on the input image and returns both the binary data and
|
||||
* final image format of the optimized image.
|
||||
*
|
||||
* @param inputBuffer Binary buffer containing the original image.
|
||||
* @param transform @type {TransformOptions} defining the requested transformations.
|
||||
*/
|
||||
transform(inputBuffer: Buffer, transform: T): Promise<{ data: Buffer, format: OutputFormat }>;
|
||||
}
|
||||
|
||||
export type ImageService<T extends TransformOptions = TransformOptions> = HostedImageService<T> | SSRImageService<T>;
|
||||
|
||||
export interface ImageMetadata {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
format: InputFormat;
|
||||
}
|
62
packages/integrations/image/src/utils.ts
Normal file
62
packages/integrations/image/src/utils.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { OutputFormat, TransformOptions } from './types';
|
||||
|
||||
export function isOutputFormat(value: string): value is OutputFormat {
|
||||
return ['avif', 'jpeg', 'png', 'webp'].includes(value);
|
||||
}
|
||||
|
||||
export function isAspectRatioString(value: string): value is `${number}:${number}` {
|
||||
return /^\d*:\d*$/.test(value);
|
||||
}
|
||||
|
||||
export function ensureDir(dir: string) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export function isRemoteImage(src: string) {
|
||||
return /^http(s?):\/\//.test(src);
|
||||
}
|
||||
|
||||
export async function loadLocalImage(src: string) {
|
||||
try {
|
||||
return await fs.promises.readFile(src);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRemoteImage(src: string) {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
|
||||
if (!res.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadImage(src: string) {
|
||||
return isRemoteImage(src)
|
||||
? await loadRemoteImage(src)
|
||||
: await loadLocalImage(src);
|
||||
}
|
||||
|
||||
export function propsToFilename({ src, width, height, format }: TransformOptions) {
|
||||
const ext = path.extname(src);
|
||||
let filename = src.replace(ext, '');
|
||||
|
||||
if (width && height) {
|
||||
return `${filename}_${width}x${height}.${format}`;
|
||||
} else if (width) {
|
||||
return `${filename}_${width}w.${format}`;
|
||||
} else if (height) {
|
||||
return `${filename}_${height}h.${format}`;
|
||||
}
|
||||
|
||||
return format ? src.replace(ext, format) : src;
|
||||
}
|
71
packages/integrations/image/src/vite-plugin-astro-image.ts
Normal file
71
packages/integrations/image/src/vite-plugin-astro-image.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import fs from 'fs/promises';
|
||||
import { pathToFileURL } from 'url';
|
||||
import slash from 'slash';
|
||||
import { metadata } from './metadata.js';
|
||||
import type { PluginContext } from 'rollup';
|
||||
import type { Plugin, ResolvedConfig } from 'vite';
|
||||
import type { AstroConfig } from 'astro';
|
||||
import type { IntegrationOptions } from './types';
|
||||
|
||||
export function createPlugin(config: AstroConfig, options: Required<IntegrationOptions>): Plugin {
|
||||
const filter = (id: string) => /^(?!\/_image?).*.(heic|heif|avif|jpeg|jpg|png|tiff|webp|gif)$/.test(id);
|
||||
|
||||
const virtualModuleId = 'virtual:image-loader';
|
||||
|
||||
let resolvedConfig: ResolvedConfig;
|
||||
let loaderModuleId: string;
|
||||
|
||||
async function resolveLoader(context: PluginContext) {
|
||||
if (!loaderModuleId) {
|
||||
const module = await context.resolve(options.serviceEntryPoint);
|
||||
if (!module) {
|
||||
throw new Error(`"${options.serviceEntryPoint}" could not be found`);
|
||||
}
|
||||
loaderModuleId = module.id;
|
||||
}
|
||||
|
||||
return loaderModuleId;
|
||||
}
|
||||
|
||||
return {
|
||||
name: '@astrojs/image',
|
||||
enforce: 'pre',
|
||||
configResolved(config) {
|
||||
resolvedConfig = config;
|
||||
},
|
||||
async resolveId(id) {
|
||||
// The virtual model redirects imports to the ImageService being used
|
||||
// This ensures the module is available in `astro dev` and is included
|
||||
// in the SSR server bundle.
|
||||
if (id === virtualModuleId) {
|
||||
return await resolveLoader(this);
|
||||
}
|
||||
},
|
||||
async load(id) {
|
||||
// only claim image ESM imports
|
||||
if (!filter(id)) { return null; }
|
||||
|
||||
const meta = await metadata(id);
|
||||
|
||||
const fileUrl = pathToFileURL(id);
|
||||
const src = resolvedConfig.isProduction
|
||||
? fileUrl.pathname.replace(config.srcDir.pathname, '/')
|
||||
: id;
|
||||
|
||||
const output = {
|
||||
...meta,
|
||||
src: slash(src), // Windows compat
|
||||
};
|
||||
|
||||
if (resolvedConfig.isProduction) {
|
||||
this.emitFile({
|
||||
fileName: output.src.replace(/^\//, ''),
|
||||
source: await fs.readFile(id),
|
||||
type: 'asset',
|
||||
});
|
||||
}
|
||||
|
||||
return `export default ${JSON.stringify(output)}`;
|
||||
}
|
||||
};
|
||||
}
|
8
packages/integrations/image/test/fixtures/basic-image/astro.config.mjs
vendored
Normal file
8
packages/integrations/image/test/fixtures/basic-image/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import image from '@astrojs/image';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'http://localhost:3000',
|
||||
integrations: [image()]
|
||||
});
|
10
packages/integrations/image/test/fixtures/basic-image/package.json
vendored
Normal file
10
packages/integrations/image/test/fixtures/basic-image/package.json
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "@test/sharp",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@astrojs/image": "workspace:*",
|
||||
"@astrojs/node": "workspace:*",
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
BIN
packages/integrations/image/test/fixtures/basic-image/public/favicon.ico
vendored
Normal file
BIN
packages/integrations/image/test/fixtures/basic-image/public/favicon.ico
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
44
packages/integrations/image/test/fixtures/basic-image/server/server.mjs
vendored
Normal file
44
packages/integrations/image/test/fixtures/basic-image/server/server.mjs
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { createServer } from 'http';
|
||||
import fs from 'fs';
|
||||
import mime from 'mime';
|
||||
import { handler as ssrHandler } from '../dist/server/entry.mjs';
|
||||
|
||||
const clientRoot = new URL('../dist/client/', import.meta.url);
|
||||
|
||||
async function handle(req, res) {
|
||||
ssrHandler(req, res, async (err) => {
|
||||
if (err) {
|
||||
res.writeHead(500);
|
||||
res.end(err.stack);
|
||||
return;
|
||||
}
|
||||
|
||||
let local = new URL('.' + req.url, clientRoot);
|
||||
try {
|
||||
const data = await fs.promises.readFile(local);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mime.getType(req.url),
|
||||
});
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
handle(req, res).catch((err) => {
|
||||
console.error(err);
|
||||
res.writeHead(500, {
|
||||
'Content-Type': 'text/plain',
|
||||
});
|
||||
res.end(err.toString());
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(8085);
|
||||
console.log('Serving at http://localhost:8085');
|
||||
|
||||
// Silence weird <time> warning
|
||||
console.error = () => {};
|
BIN
packages/integrations/image/test/fixtures/basic-image/src/assets/blog/introducing-astro.jpg
vendored
Normal file
BIN
packages/integrations/image/test/fixtures/basic-image/src/assets/blog/introducing-astro.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 270 KiB |
BIN
packages/integrations/image/test/fixtures/basic-image/src/assets/social.jpg
vendored
Normal file
BIN
packages/integrations/image/test/fixtures/basic-image/src/assets/social.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
BIN
packages/integrations/image/test/fixtures/basic-image/src/assets/social.png
vendored
Normal file
BIN
packages/integrations/image/test/fixtures/basic-image/src/assets/social.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 MiB |
17
packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro
vendored
Normal file
17
packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
import socialJpg from '../assets/social.jpg';
|
||||
import { Image } from '@astrojs/image/components';
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<!-- Head Stuff -->
|
||||
</head>
|
||||
<body>
|
||||
<Image id="social-jpg" src={socialJpg} width={506} height={253} />
|
||||
<br />
|
||||
<Image id="google" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" width={544} height={184} format="webp" />
|
||||
<br />
|
||||
<Image id='testing' src={import('../assets/social.jpg')} width={506} format="avif" />
|
||||
</body>
|
||||
</html>
|
126
packages/integrations/image/test/image-ssg.test.js
Normal file
126
packages/integrations/image/test/image-ssg.test.js
Normal file
|
@ -0,0 +1,126 @@
|
|||
import { expect } from 'chai';
|
||||
import * as cheerio from 'cheerio';
|
||||
import path from 'path';
|
||||
import sizeOf from 'image-size';
|
||||
import { loadFixture } from './test-utils.js';
|
||||
|
||||
let fixture;
|
||||
|
||||
describe('SSG images', function () {
|
||||
before(async () => {
|
||||
fixture = await loadFixture({ root: './fixtures/basic-image/' });
|
||||
});
|
||||
|
||||
function verifyImage(pathname, expected) {
|
||||
const dist = path.join('test/fixtures/basic-image/dist', pathname);
|
||||
const result = sizeOf(dist);
|
||||
expect(result).to.deep.equal(expected);
|
||||
}
|
||||
|
||||
describe('build', () => {
|
||||
let $;
|
||||
let html;
|
||||
|
||||
before(async () => {
|
||||
await fixture.build();
|
||||
|
||||
html = await fixture.readFile('/index.html');
|
||||
$ = cheerio.load(html);
|
||||
});
|
||||
|
||||
describe('Local images', () => {
|
||||
it('includes src, width, and height attributes', () => {
|
||||
const image = $('#social-jpg');
|
||||
|
||||
expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg');
|
||||
expect(image.attr('width')).to.equal('506');
|
||||
expect(image.attr('height')).to.equal('253');
|
||||
});
|
||||
|
||||
it('built the optimized image', () => {
|
||||
verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' });
|
||||
});
|
||||
|
||||
it('dist includes original image', () => {
|
||||
verifyImage('assets/social.jpg', { width: 2024, height: 1012, type: 'jpg' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote images', () => {
|
||||
it('includes src, width, and height attributes', () => {
|
||||
const image = $('#google');
|
||||
|
||||
expect(image.attr('src')).to.equal('/_image/googlelogo_color_272x92dp_544x184.webp');
|
||||
expect(image.attr('width')).to.equal('544');
|
||||
expect(image.attr('height')).to.equal('184');
|
||||
});
|
||||
|
||||
it('built the optimized image', () => {
|
||||
verifyImage('_image/googlelogo_color_272x92dp_544x184.webp', { width: 544, height: 184, type: 'webp' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev', () => {
|
||||
let devServer;
|
||||
let $;
|
||||
|
||||
before(async () => {
|
||||
devServer = await fixture.startDevServer();
|
||||
const html = await fixture.fetch('/').then((res) => res.text());
|
||||
$ = cheerio.load(html);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await devServer.stop();
|
||||
});
|
||||
|
||||
describe('Local images', () => {
|
||||
it('includes src, width, and height attributes', () => {
|
||||
const image = $('#social-jpg');
|
||||
|
||||
const src = image.attr('src');
|
||||
const [route, params] = src.split('?');
|
||||
|
||||
expect(route).to.equal('/_image');
|
||||
|
||||
const searchParams = new URLSearchParams(params);
|
||||
|
||||
expect(searchParams.get('f')).to.equal('jpg');
|
||||
expect(searchParams.get('w')).to.equal('506');
|
||||
expect(searchParams.get('h')).to.equal('253');
|
||||
// TODO: possible to avoid encoding the full image path?
|
||||
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||
});
|
||||
|
||||
it('returns the optimized image', async () => {
|
||||
const image = $('#social-jpg');
|
||||
|
||||
const res = await fixture.fetch(image.attr('src'));
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.headers.get('Content-Type')).to.equal('image/jpeg');
|
||||
|
||||
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote images', () => {
|
||||
it('includes src, width, and height attributes', () => {
|
||||
const image = $('#google');
|
||||
|
||||
const src = image.attr('src');
|
||||
const [route, params] = src.split('?');
|
||||
|
||||
expect(route).to.equal('/_image');
|
||||
|
||||
const searchParams = new URLSearchParams(params);
|
||||
|
||||
expect(searchParams.get('f')).to.equal('webp');
|
||||
expect(searchParams.get('w')).to.equal('544');
|
||||
expect(searchParams.get('h')).to.equal('184');
|
||||
expect(searchParams.get('href')).to.equal('https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
164
packages/integrations/image/test/image-ssr.test.js
Normal file
164
packages/integrations/image/test/image-ssr.test.js
Normal file
|
@ -0,0 +1,164 @@
|
|||
import { expect } from 'chai';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { loadFixture } from './test-utils.js';
|
||||
import testAdapter from '../../../astro/test/test-adapter.js';
|
||||
|
||||
describe('SSR images - build', function () {
|
||||
let fixture;
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/basic-image/',
|
||||
adapter: testAdapter(),
|
||||
experimental: {
|
||||
ssr: true,
|
||||
},
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
describe('Local images', () => {
|
||||
it('includes src, width, and height attributes', async () => {
|
||||
const app = await fixture.loadTestAdapterApp();
|
||||
|
||||
const request = new Request('http://example.com/');
|
||||
const response = await app.render(request);
|
||||
const html = await response.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const image = $('#social-jpg');
|
||||
|
||||
const src = image.attr('src');
|
||||
const [route, params] = src.split('?');
|
||||
|
||||
expect(route).to.equal('/_image');
|
||||
|
||||
const searchParams = new URLSearchParams(params);
|
||||
|
||||
expect(searchParams.get('f')).to.equal('jpg');
|
||||
expect(searchParams.get('w')).to.equal('506');
|
||||
expect(searchParams.get('h')).to.equal('253');
|
||||
// TODO: possible to avoid encoding the full image path?
|
||||
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||
});
|
||||
|
||||
// TODO: Track down why the fixture.fetch is failing with the test adapter
|
||||
it.skip('built the optimized image', async () => {
|
||||
const app = await fixture.loadTestAdapterApp();
|
||||
|
||||
const request = new Request('http://example.com/');
|
||||
const response = await app.render(request);
|
||||
const html = await response.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const image = $('#social-jpg');
|
||||
|
||||
const res = await fixture.fetch(image.attr('src'));
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.headers.get('Content-Type')).to.equal('image/jpeg');
|
||||
|
||||
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote images', () => {
|
||||
it('includes src, width, and height attributes', async () => {
|
||||
const app = await fixture.loadTestAdapterApp();
|
||||
|
||||
const request = new Request('http://example.com/');
|
||||
const response = await app.render(request);
|
||||
const html = await response.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const image = $('#google');
|
||||
|
||||
const src = image.attr('src');
|
||||
const [route, params] = src.split('?');
|
||||
|
||||
expect(route).to.equal('/_image');
|
||||
|
||||
const searchParams = new URLSearchParams(params);
|
||||
|
||||
expect(searchParams.get('f')).to.equal('webp');
|
||||
expect(searchParams.get('w')).to.equal('544');
|
||||
expect(searchParams.get('h')).to.equal('184');
|
||||
// TODO: possible to avoid encoding the full image path?
|
||||
expect(searchParams.get('href').endsWith('googlelogo_color_272x92dp.png')).to.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSR images - dev', function () {
|
||||
let fixture;
|
||||
let devServer;
|
||||
let $;
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/basic-image/',
|
||||
adapter: testAdapter(),
|
||||
experimental: {
|
||||
ssr: true,
|
||||
},
|
||||
});
|
||||
|
||||
devServer = await fixture.startDevServer();
|
||||
const html = await fixture.fetch('/').then((res) => res.text());
|
||||
$ = cheerio.load(html);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await devServer.stop();
|
||||
});
|
||||
|
||||
describe('Local images', () => {
|
||||
it('includes src, width, and height attributes', () => {
|
||||
const image = $('#social-jpg');
|
||||
|
||||
const src = image.attr('src');
|
||||
const [route, params] = src.split('?');
|
||||
|
||||
expect(route).to.equal('/_image');
|
||||
|
||||
const searchParams = new URLSearchParams(params);
|
||||
|
||||
expect(searchParams.get('f')).to.equal('jpg');
|
||||
expect(searchParams.get('w')).to.equal('506');
|
||||
expect(searchParams.get('h')).to.equal('253');
|
||||
// TODO: possible to avoid encoding the full image path?
|
||||
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||
});
|
||||
|
||||
it('returns the optimized image', async () => {
|
||||
const image = $('#social-jpg');
|
||||
|
||||
const res = await fixture.fetch(image.attr('src'));
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.headers.get('Content-Type')).to.equal('image/jpeg');
|
||||
|
||||
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remote images', () => {
|
||||
it('includes src, width, and height attributes', () => {
|
||||
const image = $('#google');
|
||||
|
||||
const src = image.attr('src');
|
||||
const [route, params] = src.split('?');
|
||||
|
||||
expect(route).to.equal('/_image');
|
||||
|
||||
const searchParams = new URLSearchParams(params);
|
||||
|
||||
expect(searchParams.get('f')).to.equal('webp');
|
||||
expect(searchParams.get('w')).to.equal('544');
|
||||
expect(searchParams.get('h')).to.equal('184');
|
||||
expect(searchParams.get('href')).to.equal(
|
||||
'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
61
packages/integrations/image/test/sharp.test.js
Normal file
61
packages/integrations/image/test/sharp.test.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { expect } from 'chai';
|
||||
import sharp from '../dist/loaders/sharp.js';
|
||||
|
||||
describe('Sharp service', () => {
|
||||
describe('serializeTransform', () => {
|
||||
const src = '/assets/image.png';
|
||||
|
||||
[
|
||||
['only requires src', { src }],
|
||||
['quality', { src, quality: 80 }],
|
||||
['format', { src, format: 'jpeg' }],
|
||||
['width', { src, width: 1280 }],
|
||||
['height', { src, height: 414 }],
|
||||
['width & height', { src, height: 400, width: 200 }],
|
||||
['aspect ratio string', { src, aspectRatio: '16:9' }],
|
||||
['aspect ratio float', { src, aspectRatio: 1.7 }]
|
||||
].forEach(([description, props]) => {
|
||||
it(description, async () => {
|
||||
const { searchParams } = await sharp.serializeTransform(props);
|
||||
|
||||
function verifyProp(expected, search) {
|
||||
if (expected) {
|
||||
expect(searchParams.get(search)).to.equal(expected.toString());
|
||||
} else {
|
||||
expect(searchParams.has(search)).to.be.false;
|
||||
}
|
||||
}
|
||||
|
||||
verifyProp(props.src, 'href');
|
||||
verifyProp(props.quality, 'q');
|
||||
verifyProp(props.format, 'f');
|
||||
verifyProp(props.width, 'w');
|
||||
verifyProp(props.height, 'h');
|
||||
verifyProp(props.aspectRatio, 'ar');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTransform', async () => {
|
||||
const src = '/assets/image.png';
|
||||
const href = encodeURIComponent(src);
|
||||
|
||||
[
|
||||
['only requires src', `href=${href}`, { src }],
|
||||
['quality', `q=80&href=${href}`, { src, quality: 80 }],
|
||||
['format', `f=jpeg&href=${href}`, { src, format: 'jpeg' }],
|
||||
['width', `w=1280&href=${href}`, { src, width: 1280 }],
|
||||
['height', `h=414&href=${href}`, { src, height: 414 }],
|
||||
['width & height', `w=200&h=400&href=${href}`, { src, height: 400, width: 200 }],
|
||||
['aspect ratio string', `ar=16:9&href=${href}`, { src, aspectRatio: '16:9' }],
|
||||
['aspect ratio float', `ar=1.7&href=${href}`, { src, aspectRatio: 1.7 }]
|
||||
].forEach(([description, params, expected]) => {
|
||||
it(description, async () => {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
const props = sharp.parseTransform(searchParams);
|
||||
|
||||
expect(props).to.deep.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
13
packages/integrations/image/test/test-utils.js
Normal file
13
packages/integrations/image/test/test-utils.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js';
|
||||
|
||||
export function loadFixture(inlineConfig) {
|
||||
if (!inlineConfig || !inlineConfig.root)
|
||||
throw new Error("Must provide { root: './fixtures/...' }");
|
||||
|
||||
// resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath
|
||||
// without this, the main `loadFixture` helper will resolve relative to `packages/astro/test`
|
||||
return baseLoadFixture({
|
||||
...inlineConfig,
|
||||
root: new URL(inlineConfig.root, import.meta.url).toString(),
|
||||
});
|
||||
}
|
11
packages/integrations/image/tsconfig.json
Normal file
11
packages/integrations/image/tsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src", "types.d.ts"],
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"module": "ES2020",
|
||||
"outDir": "./dist",
|
||||
"target": "ES2020",
|
||||
"typeRoots": ["node_modules/@types", "node_modules/@netlify"]
|
||||
}
|
||||
}
|
2
packages/webapi/mod.d.ts
vendored
2
packages/webapi/mod.d.ts
vendored
|
@ -1,5 +1,5 @@
|
|||
export { pathToPosix } from './lib/utils';
|
||||
export { AbortController, AbortSignal, alert, atob, Blob, btoa, ByteLengthQueuingStrategy, cancelAnimationFrame, cancelIdleCallback, CanvasRenderingContext2D, CharacterData, clearTimeout, Comment, CountQueuingStrategy, CSSStyleSheet, CustomElementRegistry, CustomEvent, Document, DocumentFragment, DOMException, Element, Event, EventTarget, fetch, File, FormData, Headers, HTMLBodyElement, HTMLCanvasElement, HTMLDivElement, HTMLDocument, HTMLElement, HTMLHeadElement, HTMLHtmlElement, HTMLImageElement, HTMLSpanElement, HTMLStyleElement, HTMLTemplateElement, HTMLUnknownElement, Image, ImageData, IntersectionObserver, MediaQueryList, MutationObserver, Node, NodeFilter, NodeIterator, OffscreenCanvas, ReadableByteStreamController, ReadableStream, ReadableStreamBYOBReader, ReadableStreamBYOBRequest, ReadableStreamDefaultController, ReadableStreamDefaultReader, Request, requestAnimationFrame, requestIdleCallback, ResizeObserver, Response, setTimeout, ShadowRoot, structuredClone, StyleSheet, Text, TransformStream, TreeWalker, URLPattern, Window, WritableStream, WritableStreamDefaultController, WritableStreamDefaultWriter } from './mod.js';
|
||||
export { AbortController, AbortSignal, alert, atob, Blob, btoa, ByteLengthQueuingStrategy, cancelAnimationFrame, cancelIdleCallback, CanvasRenderingContext2D, CharacterData, clearTimeout, Comment, CountQueuingStrategy, CSSStyleSheet, CustomElementRegistry, CustomEvent, Document, DocumentFragment, DOMException, Element, Event, EventTarget, fetch, File, FormData, Headers, HTMLBodyElement, HTMLCanvasElement, HTMLDivElement, HTMLDocument, HTMLElement, HTMLHeadElement, HTMLHtmlElement, HTMLImageElement, HTMLSpanElement, HTMLStyleElement, HTMLTemplateElement, HTMLUnknownElement, Image, ImageData, IntersectionObserver, MediaQueryList, MutationObserver, Node, NodeFilter, NodeIterator, OffscreenCanvas, ReadableByteStreamController, ReadableStream, ReadableStreamBYOBReader, ReadableStreamBYOBRequest, ReadableStreamDefaultController, ReadableStreamDefaultReader, Request, requestAnimationFrame, requestIdleCallback, ResizeObserver, Response, setTimeout, ShadowRoot, structuredClone, StyleSheet, Text, TransformStream, TreeWalker, URLPattern, Window, WritableStream, WritableStreamDefaultController, WritableStreamDefaultWriter, } from './mod.js';
|
||||
export declare const polyfill: {
|
||||
(target: any, options?: PolyfillOptions): any;
|
||||
internals(target: any, name: string): any;
|
||||
|
|
111
pnpm-lock.yaml
111
pnpm-lock.yaml
|
@ -1911,6 +1911,41 @@ importers:
|
|||
'@astrojs/deno': link:../../..
|
||||
astro: link:../../../../../astro
|
||||
|
||||
packages/integrations/image:
|
||||
specifiers:
|
||||
'@types/etag': ^1.8.1
|
||||
'@types/sharp': ^0.30.4
|
||||
astro: workspace:*
|
||||
astro-scripts: workspace:*
|
||||
etag: ^1.8.1
|
||||
image-size: ^1.0.1
|
||||
image-type: ^4.1.0
|
||||
mrmime: ^1.0.0
|
||||
sharp: ^0.30.6
|
||||
slash: ^4.0.0
|
||||
dependencies:
|
||||
etag: 1.8.1
|
||||
image-size: 1.0.1
|
||||
image-type: 4.1.0
|
||||
mrmime: 1.0.1
|
||||
sharp: 0.30.7
|
||||
slash: 4.0.0
|
||||
devDependencies:
|
||||
'@types/etag': 1.8.1
|
||||
'@types/sharp': 0.30.4
|
||||
astro: link:../../astro
|
||||
astro-scripts: link:../../../scripts
|
||||
|
||||
packages/integrations/image/test/fixtures/basic-image:
|
||||
specifiers:
|
||||
'@astrojs/image': workspace:*
|
||||
'@astrojs/node': workspace:*
|
||||
astro: workspace:*
|
||||
dependencies:
|
||||
'@astrojs/image': link:../../..
|
||||
'@astrojs/node': link:../../../../node
|
||||
astro: link:../../../../../astro
|
||||
|
||||
packages/integrations/lit:
|
||||
specifiers:
|
||||
'@lit-labs/ssr': ^2.2.0
|
||||
|
@ -7352,6 +7387,12 @@ packages:
|
|||
resolution: {integrity: sha512-BZWrtCU0bMVAIliIV+HJO1f1PR41M7NKjfxrFJwwhKI1KwhwOxYw1SXg9ao+CIMt774nFuGiG6eU+udtbEI9oQ==}
|
||||
dev: false
|
||||
|
||||
/@types/etag/1.8.1:
|
||||
resolution: {integrity: sha512-bsKkeSqN7HYyYntFRAmzcwx/dKW4Wa+KVMTInANlI72PWLQmOpZu96j0OqHZGArW4VQwCmJPteQlXaUDeOB0WQ==}
|
||||
dependencies:
|
||||
'@types/node': 18.0.0
|
||||
dev: true
|
||||
|
||||
/@types/github-slugger/1.3.0:
|
||||
resolution: {integrity: sha512-J/rMZa7RqiH/rT29TEVZO4nBoDP9XJOjnbbIofg7GQKs4JIduEO3WLpte+6WeUz/TcrXKlY+bM7FYrp8yFB+3g==}
|
||||
dev: true
|
||||
|
@ -7548,6 +7589,12 @@ packages:
|
|||
'@types/node': 18.0.0
|
||||
dev: true
|
||||
|
||||
/@types/sharp/0.30.4:
|
||||
resolution: {integrity: sha512-6oJEzKt7wZeS7e+6x9QFEOWGs0T/6of00+0onZGN1zSmcSjcTDZKgIGZ6YWJnHowpaKUCFBPH52mYljWqU32Eg==}
|
||||
dependencies:
|
||||
'@types/node': 18.0.0
|
||||
dev: true
|
||||
|
||||
/@types/strip-bom/3.0.0:
|
||||
resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==}
|
||||
dev: true
|
||||
|
@ -8331,7 +8378,6 @@ packages:
|
|||
buffer: 5.7.1
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.0
|
||||
dev: true
|
||||
|
||||
/bl/5.0.0:
|
||||
resolution: {integrity: sha512-8vxFNZ0pflFfi0WXA3WQXlj6CaMEwsmh63I1CNp0q+wWv8sD0ARx1KovSQd0l2GkwrMIOyedq0EF1FxI+RCZLQ==}
|
||||
|
@ -8409,7 +8455,6 @@ packages:
|
|||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
dev: true
|
||||
|
||||
/buffer/6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
@ -8684,7 +8729,6 @@ packages:
|
|||
dependencies:
|
||||
color-name: 1.1.4
|
||||
simple-swizzle: 0.2.2
|
||||
dev: true
|
||||
|
||||
/color-support/1.1.3:
|
||||
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||
|
@ -8697,7 +8741,6 @@ packages:
|
|||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
color-string: 1.9.1
|
||||
dev: true
|
||||
|
||||
/colorette/2.0.19:
|
||||
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
|
||||
|
@ -8905,6 +8948,11 @@ packages:
|
|||
|
||||
/debug/3.2.7:
|
||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
dev: false
|
||||
|
@ -8967,7 +9015,6 @@ packages:
|
|||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
dev: true
|
||||
|
||||
/dedent-js/1.0.1:
|
||||
resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==}
|
||||
|
@ -9210,7 +9257,6 @@ packages:
|
|||
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
dev: true
|
||||
|
||||
/enquirer/2.3.6:
|
||||
resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==}
|
||||
|
@ -9694,6 +9740,11 @@ packages:
|
|||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/etag/1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/event-target-shim/5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -9736,7 +9787,6 @@ packages:
|
|||
/expand-template/2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/extend-shallow/2.0.1:
|
||||
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
|
||||
|
@ -9813,6 +9863,11 @@ packages:
|
|||
flat-cache: 3.0.4
|
||||
dev: true
|
||||
|
||||
/file-type/10.11.0:
|
||||
resolution: {integrity: sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/file-uri-to-path/1.0.0:
|
||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||
dev: false
|
||||
|
@ -9914,7 +9969,6 @@ packages:
|
|||
|
||||
/fs-constants/1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
dev: true
|
||||
|
||||
/fs-extra/7.0.1:
|
||||
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
||||
|
@ -10080,7 +10134,6 @@ packages:
|
|||
|
||||
/github-from-package/0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
dev: true
|
||||
|
||||
/github-slugger/1.4.0:
|
||||
resolution: {integrity: sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==}
|
||||
|
@ -10505,6 +10558,21 @@ packages:
|
|||
resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
/image-size/1.0.1:
|
||||
resolution: {integrity: sha512-VAwkvNSNGClRw9mDHhc5Efax8PLlsOGcUTh0T/LIriC8vPA3U5PdqXWqkz406MoYHMKW8Uf9gWr05T/rYB44kQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
queue: 6.0.2
|
||||
dev: false
|
||||
|
||||
/image-type/4.1.0:
|
||||
resolution: {integrity: sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==}
|
||||
engines: {node: '>=6'}
|
||||
dependencies:
|
||||
file-type: 10.11.0
|
||||
dev: false
|
||||
|
||||
/imagetools-core/3.0.3:
|
||||
resolution: {integrity: sha512-JK7kb0ezkzD27zQgZs7mtRd7DekC+/RTePjIzPsuBAp3S/Z17wNt+6TSEdDJRQSV5xiGV+SOG00weSGVdX3Xig==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
@ -10595,7 +10663,6 @@ packages:
|
|||
|
||||
/is-arrayish/0.3.2:
|
||||
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
|
||||
dev: true
|
||||
|
||||
/is-bigint/1.0.4:
|
||||
resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==}
|
||||
|
@ -11794,7 +11861,6 @@ packages:
|
|||
/mimic-response/3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/min-indent/1.0.1:
|
||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||
|
@ -11866,7 +11932,6 @@ packages:
|
|||
|
||||
/mkdirp-classic/0.5.3:
|
||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||
dev: true
|
||||
|
||||
/mkdirp/0.5.6:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
|
@ -11943,7 +12008,6 @@ packages:
|
|||
|
||||
/napi-build-utils/1.0.2:
|
||||
resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==}
|
||||
dev: true
|
||||
|
||||
/natural-compare/1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
@ -11957,6 +12021,8 @@ packages:
|
|||
debug: 3.2.7
|
||||
iconv-lite: 0.4.24
|
||||
sax: 1.2.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/netmask/2.0.2:
|
||||
|
@ -11990,11 +12056,9 @@ packages:
|
|||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
semver: 7.3.7
|
||||
dev: true
|
||||
|
||||
/node-addon-api/5.0.0:
|
||||
resolution: {integrity: sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA==}
|
||||
dev: true
|
||||
|
||||
/node-domexception/1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
|
@ -12040,6 +12104,8 @@ packages:
|
|||
rimraf: 2.7.1
|
||||
semver: 5.7.1
|
||||
tar: 4.4.19
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/node-releases/2.0.5:
|
||||
|
@ -12984,7 +13050,6 @@ packages:
|
|||
simple-get: 4.0.1
|
||||
tar-fs: 2.1.1
|
||||
tunnel-agent: 0.6.0
|
||||
dev: true
|
||||
|
||||
/preferred-pm/3.0.3:
|
||||
resolution: {integrity: sha512-+wZgbxNES/KlJs9q40F/1sfOd/j7f1O9JaHcW5Dsn3aUUOZg3L2bjpVUcKV2jvtElYfoTuQiNeMfQJ4kwUAhCQ==}
|
||||
|
@ -13102,7 +13167,6 @@ packages:
|
|||
dependencies:
|
||||
end-of-stream: 1.4.4
|
||||
once: 1.4.0
|
||||
dev: true
|
||||
|
||||
/punycode/2.1.1:
|
||||
resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==}
|
||||
|
@ -13129,6 +13193,12 @@ packages:
|
|||
/queue-microtask/1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
/queue/6.0.2:
|
||||
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
dev: false
|
||||
|
||||
/quick-lru/4.0.1:
|
||||
resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -13692,7 +13762,6 @@ packages:
|
|||
simple-get: 4.0.1
|
||||
tar-fs: 2.1.1
|
||||
tunnel-agent: 0.6.0
|
||||
dev: true
|
||||
|
||||
/shebang-command/1.2.0:
|
||||
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
|
||||
|
@ -13744,7 +13813,6 @@ packages:
|
|||
|
||||
/simple-concat/1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
dev: true
|
||||
|
||||
/simple-get/4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
@ -13752,13 +13820,11 @@ packages:
|
|||
decompress-response: 6.0.0
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
dev: true
|
||||
|
||||
/simple-swizzle/0.2.2:
|
||||
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
|
||||
dependencies:
|
||||
is-arrayish: 0.3.2
|
||||
dev: true
|
||||
|
||||
/sirv/1.0.19:
|
||||
resolution: {integrity: sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ==}
|
||||
|
@ -14257,7 +14323,6 @@ packages:
|
|||
mkdirp-classic: 0.5.3
|
||||
pump: 3.0.0
|
||||
tar-stream: 2.2.0
|
||||
dev: true
|
||||
|
||||
/tar-stream/2.2.0:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
|
@ -14268,7 +14333,6 @@ packages:
|
|||
fs-constants: 1.0.0
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.0
|
||||
dev: true
|
||||
|
||||
/tar/4.4.19:
|
||||
resolution: {integrity: sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==}
|
||||
|
@ -14469,7 +14533,6 @@ packages:
|
|||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
dev: true
|
||||
|
||||
/turbo-darwin-64/1.2.5:
|
||||
resolution: {integrity: sha512-AjMEF8hlA9vy1gXLHBruqgO42s0M0rKKZLQPM239wli5lKEprmxd8WMSjd9YmxRflS+/fwrXfjVl0QRhHjDIww==}
|
||||
|
|
Loading…
Reference in a new issue