summaryrefslogtreecommitdiff
path: root/packages/integrations/image/src
diff options
context:
space:
mode:
authorGravatar Tony Sullivan <tony.f.sullivan@outlook.com> 2022-07-01 15:47:48 +0000
committerGravatar GitHub <noreply@github.com> 2022-07-01 15:47:48 +0000
commite8593e7eadcf2bfbbbdef879c148ff47235591cc (patch)
treeeb60be06a5de3b017cd60c7cfa3a6d80b407efa5 /packages/integrations/image/src
parent0f73ece26bfc03570a7cab100255de4fbbb1ecfa (diff)
downloadastro-e8593e7eadcf2bfbbbdef879c148ff47235591cc.tar.gz
astro-e8593e7eadcf2bfbbbdef879c148ff47235591cc.tar.zst
astro-e8593e7eadcf2bfbbbdef879c148ff47235591cc.zip
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
Diffstat (limited to 'packages/integrations/image/src')
-rw-r--r--packages/integrations/image/src/endpoints/dev.ts33
-rw-r--r--packages/integrations/image/src/endpoints/prod.ts40
-rw-r--r--packages/integrations/image/src/index.ts139
-rw-r--r--packages/integrations/image/src/loaders/sharp.ts105
-rw-r--r--packages/integrations/image/src/metadata.ts20
-rw-r--r--packages/integrations/image/src/types.ts123
-rw-r--r--packages/integrations/image/src/utils.ts62
-rw-r--r--packages/integrations/image/src/vite-plugin-astro-image.ts71
8 files changed, 593 insertions, 0 deletions
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 `<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;
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<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
+ }
+}
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<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;
+}
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<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)}`;
+ }
+ };
+}