diff options
Diffstat (limited to 'packages/integrations/image/src')
-rw-r--r-- | packages/integrations/image/src/endpoints/dev.ts | 33 | ||||
-rw-r--r-- | packages/integrations/image/src/endpoints/prod.ts | 40 | ||||
-rw-r--r-- | packages/integrations/image/src/index.ts | 139 | ||||
-rw-r--r-- | packages/integrations/image/src/loaders/sharp.ts | 105 | ||||
-rw-r--r-- | packages/integrations/image/src/metadata.ts | 20 | ||||
-rw-r--r-- | packages/integrations/image/src/types.ts | 123 | ||||
-rw-r--r-- | packages/integrations/image/src/utils.ts | 62 | ||||
-rw-r--r-- | packages/integrations/image/src/vite-plugin-astro-image.ts | 71 |
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)}`; + } + }; +} |