From e8593e7eadcf2bfbbbdef879c148ff47235591cc Mon Sep 17 00:00:00 2001 From: Tony Sullivan Date: Fri, 1 Jul 2022 15:47:48 +0000 Subject: [PATCH] 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 --- .../src/vite-plugin-astro-server/index.ts | 4 +- packages/integrations/image/.npmignore | 1 + packages/integrations/image/README.md | 171 ++++++++++++++++++ .../integrations/image/components/Image.astro | 125 +++++++++++++ .../integrations/image/components/index.ts | 1 + packages/integrations/image/package.json | 53 ++++++ .../integrations/image/src/endpoints/dev.ts | 33 ++++ .../integrations/image/src/endpoints/prod.ts | 40 ++++ packages/integrations/image/src/index.ts | 139 ++++++++++++++ .../integrations/image/src/loaders/sharp.ts | 105 +++++++++++ packages/integrations/image/src/metadata.ts | 20 ++ packages/integrations/image/src/types.ts | 123 +++++++++++++ packages/integrations/image/src/utils.ts | 62 +++++++ .../image/src/vite-plugin-astro-image.ts | 71 ++++++++ .../fixtures/basic-image/astro.config.mjs | 8 + .../test/fixtures/basic-image/package.json | 10 + .../fixtures/basic-image/public/favicon.ico | Bin 0 -> 4286 bytes .../fixtures/basic-image/server/server.mjs | 44 +++++ .../src/assets/blog/introducing-astro.jpg | Bin 0 -> 276382 bytes .../basic-image/src/assets/social.jpg | Bin 0 -> 25266 bytes .../basic-image/src/assets/social.png | Bin 0 -> 1512228 bytes .../basic-image/src/pages/index.astro | 17 ++ .../integrations/image/test/image-ssg.test.js | 126 +++++++++++++ .../integrations/image/test/image-ssr.test.js | 164 +++++++++++++++++ .../integrations/image/test/sharp.test.js | 61 +++++++ .../integrations/image/test/test-utils.js | 13 ++ packages/integrations/image/tsconfig.json | 11 ++ packages/webapi/mod.d.ts | 2 +- pnpm-lock.yaml | 111 +++++++++--- 29 files changed, 1489 insertions(+), 26 deletions(-) create mode 100644 packages/integrations/image/.npmignore create mode 100644 packages/integrations/image/README.md create mode 100644 packages/integrations/image/components/Image.astro create mode 100644 packages/integrations/image/components/index.ts create mode 100644 packages/integrations/image/package.json create mode 100644 packages/integrations/image/src/endpoints/dev.ts create mode 100644 packages/integrations/image/src/endpoints/prod.ts create mode 100644 packages/integrations/image/src/index.ts create mode 100644 packages/integrations/image/src/loaders/sharp.ts create mode 100644 packages/integrations/image/src/metadata.ts create mode 100644 packages/integrations/image/src/types.ts create mode 100644 packages/integrations/image/src/utils.ts create mode 100644 packages/integrations/image/src/vite-plugin-astro-image.ts create mode 100644 packages/integrations/image/test/fixtures/basic-image/astro.config.mjs create mode 100644 packages/integrations/image/test/fixtures/basic-image/package.json create mode 100644 packages/integrations/image/test/fixtures/basic-image/public/favicon.ico create mode 100644 packages/integrations/image/test/fixtures/basic-image/server/server.mjs create mode 100644 packages/integrations/image/test/fixtures/basic-image/src/assets/blog/introducing-astro.jpg create mode 100644 packages/integrations/image/test/fixtures/basic-image/src/assets/social.jpg create mode 100644 packages/integrations/image/test/fixtures/basic-image/src/assets/social.png create mode 100644 packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro create mode 100644 packages/integrations/image/test/image-ssg.test.js create mode 100644 packages/integrations/image/test/image-ssr.test.js create mode 100644 packages/integrations/image/test/sharp.test.js create mode 100644 packages/integrations/image/test/test-utils.js create mode 100644 packages/integrations/image/tsconfig.json diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts index b3bbd9726..a628247ec 100644 --- a/packages/astro/src/vite-plugin-astro-server/index.ts +++ b/packages/astro/src/vite-plugin-astro-server/index.ts @@ -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. diff --git a/packages/integrations/image/.npmignore b/packages/integrations/image/.npmignore new file mode 100644 index 000000000..65e3ba2ed --- /dev/null +++ b/packages/integrations/image/.npmignore @@ -0,0 +1 @@ +test/ diff --git a/packages/integrations/image/README.md b/packages/integrations/image/README.md new file mode 100644 index 000000000..f37013b43 --- /dev/null +++ b/packages/integrations/image/README.md @@ -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! + +- [Why `@astrojs/image`?](#why-astrojs-image) +- [Installation](#installation) +- [Usage](#usage) +- [Configuration](#configuration) +- [Examples](#examples) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [Changelog](#changelog) + +## 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 `` 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 + +
+ Quick Install +
+ +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. +
+ +
+ Manual Install + +
+ +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. +
+ +## Usage + +The built-in `` component is used to create an optimized `` 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. + +
+ config.serviceEntryPoint + +
+ + 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' + })], +} +``` +
+ +## Examples + +
+ Local images + +
+ + Image files in your project's `src` directory can be imported in frontmatter and passed directly to the `` 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 + + +// height will be recalculated to match the original aspect ratio + + +// cropping to a specific width and height + + +// cropping to a specific aspect ratio and converting to an avif format + +``` +
+ +
+ Remote images + +
+ + Remote images can be transformed with the `` component. The `` component needs to know the final dimensions for the `` 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 + + +// height will be recalculated to match the aspect ratio + + +// cropping to a specific height and aspect ratio and converting to an avif format + +``` +
+ +## 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 diff --git a/packages/integrations/image/components/Image.astro b/packages/integrations/image/components/Image.astro new file mode 100644 index 000000000..6b10959d4 --- /dev/null +++ b/packages/integrations/image/components/Image.astro @@ -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, Omit { + 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 { + // 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 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); +--- + + diff --git a/packages/integrations/image/components/index.ts b/packages/integrations/image/components/index.ts new file mode 100644 index 000000000..fa9809650 --- /dev/null +++ b/packages/integrations/image/components/index.ts @@ -0,0 +1 @@ +export { default as Image } from './Image.astro'; diff --git a/packages/integrations/image/package.json b/packages/integrations/image/package.json new file mode 100644 index 000000000..2626f951d --- /dev/null +++ b/packages/integrations/image/package.json @@ -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:*" + } +} diff --git a/packages/integrations/image/src/endpoints/dev.ts b/packages/integrations/image/src/endpoints/dev.ts new file mode 100644 index 000000000..9b1c2eff2 --- /dev/null +++ b/packages/integrations/image/src/endpoints/dev.ts @@ -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 }); + } +} diff --git a/packages/integrations/image/src/endpoints/prod.ts b/packages/integrations/image/src/endpoints/prod.ts new file mode 100644 index 000000000..65a8202a0 --- /dev/null +++ b/packages/integrations/image/src/endpoints/prod.ts @@ -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 }); + } +} diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts new file mode 100644 index 000000000..7f1e1b456 --- /dev/null +++ b/packages/integrations/image/src/index.ts @@ -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 `` 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 `` element. + */ +export async function getImage(loader: SSRImageService, transform: TransformOptions): Promise { + (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 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(); + + 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; diff --git a/packages/integrations/image/src/loaders/sharp.ts b/packages/integrations/image/src/loaders/sharp.ts new file mode 100644 index 000000000..5c79c7338 --- /dev/null +++ b/packages/integrations/image/src/loaders/sharp.ts @@ -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; diff --git a/packages/integrations/image/src/metadata.ts b/packages/integrations/image/src/metadata.ts new file mode 100644 index 000000000..3d344ad96 --- /dev/null +++ b/packages/integrations/image/src/metadata.ts @@ -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 { + 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 + } +} diff --git a/packages/integrations/image/src/types.ts b/packages/integrations/image/src/types.ts new file mode 100644 index 000000000..b161c15ed --- /dev/null +++ b/packages/integrations/image/src/types.ts @@ -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; + +export interface HostedImageService { + /** + * Gets the HTML attributes needed for the server rendered `` element. + */ + getImageAttributes(transform: T): Promise; +} + +export interface SSRImageService extends HostedImageService { + /** + * Gets the HTML attributes needed for the server rendered `` element. + */ + getImageAttributes(transform: T): Promise>; + /** + * 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 = HostedImageService | SSRImageService; + +export interface ImageMetadata { + src: string; + width: number; + height: number; + format: InputFormat; +} diff --git a/packages/integrations/image/src/utils.ts b/packages/integrations/image/src/utils.ts new file mode 100644 index 000000000..48249aff1 --- /dev/null +++ b/packages/integrations/image/src/utils.ts @@ -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; +} diff --git a/packages/integrations/image/src/vite-plugin-astro-image.ts b/packages/integrations/image/src/vite-plugin-astro-image.ts new file mode 100644 index 000000000..852e9c58f --- /dev/null +++ b/packages/integrations/image/src/vite-plugin-astro-image.ts @@ -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): 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)}`; + } + }; +} diff --git a/packages/integrations/image/test/fixtures/basic-image/astro.config.mjs b/packages/integrations/image/test/fixtures/basic-image/astro.config.mjs new file mode 100644 index 000000000..45a11dc9d --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-image/astro.config.mjs @@ -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()] +}); diff --git a/packages/integrations/image/test/fixtures/basic-image/package.json b/packages/integrations/image/test/fixtures/basic-image/package.json new file mode 100644 index 000000000..42b4411a4 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-image/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/sharp", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/image": "workspace:*", + "@astrojs/node": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/image/test/fixtures/basic-image/public/favicon.ico b/packages/integrations/image/test/fixtures/basic-image/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..578ad458b8906c08fbed84f42b045fea04db89d1 GIT binary patch literal 4286 zcmchZF=!M)6ox0}Fc8GdTHG!cdIY>nA!3n2f|wxIl0rn}Hl#=uf>?-!2r&jMEF^_k zh**lGut*gwBmoNv7AaB&2~nbzULg{WBhPQ{ZVzvF_HL8Cb&hv$_s#qN|IO^o>?+mA zuTW6tU%k~z<&{z+7$G%*nRsTcEO|90xy<-G5&JTt%CgZZCDT4%R?+{Vd^wh>P8_)} z`+dF$HQb9!>1o`Ivn;GInlCw{9T@Rt%q+d^T3Ke%cxkk;$v`{s^zCB9nHAv6w$Vbn z8fb<+eQTNM`;rf9#obfGnV#3+OQEUv4gU;{oA@zol%keY9-e>4W>p7AHmH~&!P7f7!Uj` zwgFeQ=<3G4O;mwWO`L!=R-=y3_~-DPjH3W^3f&jjCfC$o#|oGaahSL`_=f?$&Aa+W z2h8oZ+@?NUcjGW|aWJfbM*ZzxzmCPY`b~RobNrrj=rd`=)8-j`iSW64@0_b6?;GYk zNB+-fzOxlqZ?`y{OA$WigtZXa8)#p#=DPYxH=VeC_Q5q9Cv`mvW6*zU&Gnp1;oPM6 zaK_B3j(l^FyJgYeE9RrmDyhE7W2}}nW%ic#0v@i1E!yTey$W)U>fyd+!@2hWQ!Wa==NAtKoj`f3tp4y$Al`e;?)76?AjdaRR>|?&r)~3Git> zb1)a?uiv|R0_{m#A9c;7)eZ1y6l@yQ#oE*>(Z2fG-&&smPa2QTW>m*^K65^~`coP$ z8y5Y?iS<4Gz{Zg##$1mk)u-0;X|!xu^FCr;ce~X<&UWE&pBgqfYmEJTzpK9I%vr%b z3Ksd6qlPJLI%HFfeXK_^|BXiKZC>Ocu(Kk6hD3G-8usLzVG^q00Qh gz)s7ge@$ApxGu7=(6IGIk+uG&HTev01^#CH3$(Wk5&!@I literal 0 HcmV?d00001 diff --git a/packages/integrations/image/test/fixtures/basic-image/server/server.mjs b/packages/integrations/image/test/fixtures/basic-image/server/server.mjs new file mode 100644 index 000000000..d7a0a7a40 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-image/server/server.mjs @@ -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