diff options
Diffstat (limited to 'packages/integrations/image/src')
-rw-r--r-- | packages/integrations/image/src/constants.ts | 3 | ||||
-rw-r--r-- | packages/integrations/image/src/get-image.ts | 128 | ||||
-rw-r--r-- | packages/integrations/image/src/get-picture.ts | 79 | ||||
-rw-r--r-- | packages/integrations/image/src/index.ts | 54 | ||||
-rw-r--r-- | packages/integrations/image/src/loaders/sharp.ts | 1 | ||||
-rw-r--r-- | packages/integrations/image/src/types.ts | 21 | ||||
-rw-r--r-- | packages/integrations/image/src/utils.ts | 14 |
7 files changed, 244 insertions, 56 deletions
diff --git a/packages/integrations/image/src/constants.ts b/packages/integrations/image/src/constants.ts new file mode 100644 index 000000000..db52614c5 --- /dev/null +++ b/packages/integrations/image/src/constants.ts @@ -0,0 +1,3 @@ +export const PKG_NAME = '@astrojs/image'; +export const ROUTE_PATTERN = '/_image'; +export const OUTPUT_DIR = '/_image'; diff --git a/packages/integrations/image/src/get-image.ts b/packages/integrations/image/src/get-image.ts new file mode 100644 index 000000000..ae423c3de --- /dev/null +++ b/packages/integrations/image/src/get-image.ts @@ -0,0 +1,128 @@ +import slash from 'slash'; +import { ROUTE_PATTERN } from './constants.js'; +import { ImageAttributes, ImageMetadata, ImageService, isSSRService, OutputFormat, TransformOptions } from './types.js'; +import { parseAspectRatio } from './utils.js'; + +export interface GetImageTransform extends Omit<TransformOptions, 'src'> { + src: string | ImageMetadata | Promise<{ default: ImageMetadata }>; +} + +function resolveSize(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 = Number.parseInt(width) / Number.parseInt(height); + } + + if (transform.width) { + // only width was provided, calculate height + return { + ...transform, + width: transform.width, + height: Math.round(transform.width / aspectRatio) + } as TransformOptions; + } else if (transform.height) { + // only height was provided, calculate width + return { + ...transform, + width: Math.round(transform.height * aspectRatio), + height: transform.height + }; + } + + return transform; +} + +async function resolveTransform(input: GetImageTransform): Promise<TransformOptions> { + // for remote images, only validate the width and height props + if (typeof input.src === 'string') { + return resolveSize(input as TransformOptions); + } + + // resolve the metadata promise, usually when the ESM import is inlined + const metadata = 'then' in input.src + ? (await input.src).default + : input.src; + + let { width, height, aspectRatio, format = metadata.format, ...rest } = input; + + 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 || Math.round(width / ratio); + } else if (height) { + // one dimension was provided, calculate the other + let ratio = parseAspectRatio(aspectRatio) || metadata.width / metadata.height; + width = width || Math.round(height * ratio); + } + + return { + ...rest, + src: metadata.src, + width, + height, + aspectRatio, + format: format as OutputFormat, + } +} + +/** + * 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: ImageService, + transform: GetImageTransform +): Promise<ImageAttributes> { + (globalThis as any).loader = loader; + + const resolved = await resolveTransform(transform); + const attributes = await loader.getImageAttributes(resolved); + + // For SSR services, build URLs for the injected route + if (isSSRService(loader)) { + const { searchParams } = loader.serializeTransform(resolved); + + // cache all images rendered to HTML + if (globalThis && (globalThis as any).addStaticImage) { + (globalThis as any)?.addStaticImage(resolved); + } + + const src = + globalThis && (globalThis as any).filenameFormat + ? (globalThis as any).filenameFormat(resolved, searchParams) + : `${ROUTE_PATTERN}?${searchParams.toString()}`; + + return { + ...attributes, + src: slash(src), // Windows compat + }; + } + + // For hosted services, return the `<img />` attributes as-is + return attributes; +} diff --git a/packages/integrations/image/src/get-picture.ts b/packages/integrations/image/src/get-picture.ts new file mode 100644 index 000000000..370da0678 --- /dev/null +++ b/packages/integrations/image/src/get-picture.ts @@ -0,0 +1,79 @@ +import { lookup } from 'mrmime'; +import { extname } from 'path'; +import { getImage } from './get-image.js'; +import { ImageAttributes, ImageMetadata, ImageService, OutputFormat, TransformOptions } from './types.js'; +import { parseAspectRatio } from './utils.js'; + +export interface GetPictureParams { + loader: ImageService; + src: string | ImageMetadata | Promise<{ default: ImageMetadata }>; + widths: number[]; + formats: OutputFormat[]; + aspectRatio?: TransformOptions['aspectRatio']; +} + +export interface GetPictureResult { + image: ImageAttributes; + sources: { type: string; srcset: string; }[]; +} + +async function resolveAspectRatio({ src, aspectRatio }: GetPictureParams) { + if (typeof src === 'string') { + return parseAspectRatio(aspectRatio); + } else { + const metadata = 'then' in src ? (await src).default : src; + return parseAspectRatio(aspectRatio) || metadata.width / metadata.height; + } +} + +async function resolveFormats({ src, formats }: GetPictureParams) { + const unique = new Set(formats); + + if (typeof src === 'string') { + unique.add(extname(src).replace('.', '') as OutputFormat); + } else { + const metadata = 'then' in src ? (await src).default : src; + unique.add(extname(metadata.src).replace('.', '') as OutputFormat); + } + + return [...unique]; +} + +export async function getPicture(params: GetPictureParams): Promise<GetPictureResult> { + const { loader, src, widths, formats } = params; + + const aspectRatio = await resolveAspectRatio(params); + + if (!aspectRatio) { + throw new Error('`aspectRatio` must be provided for remote images'); + } + + async function getSource(format: OutputFormat) { + const imgs = await Promise.all(widths.map(async (width) => { + const img = await getImage(loader, { src, format, width, height: Math.round(width / aspectRatio!) }); + return `${img.src} ${width}w`; + })) + + return { + type: lookup(format) || format, + srcset: imgs.join(',') + }; + } + + // always include the original image format + const allFormats = await resolveFormats(params); + + const image = await getImage(loader, { + src, + width: Math.max(...widths), + aspectRatio, + format: allFormats[allFormats.length - 1] + }); + + const sources = await Promise.all(allFormats.map(format => getSource(format))); + + return { + sources, + image + } +} diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts index f87fcd4b2..8b06484e7 100644 --- a/packages/integrations/image/src/index.ts +++ b/packages/integrations/image/src/index.ts @@ -1,14 +1,11 @@ import type { AstroConfig, AstroIntegration } from 'astro'; import fs from 'fs/promises'; import path from 'path'; -import slash from 'slash'; import { fileURLToPath } from 'url'; -import type { - ImageAttributes, - IntegrationOptions, - SSRImageService, - TransformOptions, -} from './types'; +import { OUTPUT_DIR, PKG_NAME, ROUTE_PATTERN } from './constants.js'; +export * from './get-image.js'; +export * from './get-picture.js'; +import { IntegrationOptions, TransformOptions } from './types.js'; import { ensureDir, isRemoteImage, @@ -18,49 +15,6 @@ import { } from './utils.js'; import { createPlugin } from './vite-plugin-astro-image.js'; -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', diff --git a/packages/integrations/image/src/loaders/sharp.ts b/packages/integrations/image/src/loaders/sharp.ts index b82a75044..86c18839d 100644 --- a/packages/integrations/image/src/loaders/sharp.ts +++ b/packages/integrations/image/src/loaders/sharp.ts @@ -4,6 +4,7 @@ import { isAspectRatioString, isOutputFormat } from '../utils.js'; class SharpService implements SSRImageService { async getImageAttributes(transform: TransformOptions) { + // strip off the known attributes const { width, height, src, format, quality, aspectRatio, ...rest } = transform; return { diff --git a/packages/integrations/image/src/types.ts b/packages/integrations/image/src/types.ts index b55feb7c5..427aaf7cf 100644 --- a/packages/integrations/image/src/types.ts +++ b/packages/integrations/image/src/types.ts @@ -1,5 +1,6 @@ -export type { Image } from '../components/index'; -export * from './index'; +/// <reference types="astro/astro-jsx" /> +export type { Image, Picture } from '../components/index.js'; +export * from './index.js'; export type InputFormat = | 'heic' @@ -72,7 +73,8 @@ export interface TransformOptions { aspectRatio?: number | `${number}:${number}`; } -export type ImageAttributes = Partial<HTMLImageElement>; +export type ImageAttributes = astroHTML.JSX.ImgHTMLAttributes; +export type PictureAttributes = astroHTML.JSX.HTMLAttributes; export interface HostedImageService<T extends TransformOptions = TransformOptions> { /** @@ -81,10 +83,9 @@ export interface HostedImageService<T extends TransformOptions = TransformOption getImageAttributes(transform: T): Promise<ImageAttributes>; } -export interface SSRImageService<T extends TransformOptions = TransformOptions> - extends HostedImageService<T> { +export interface SSRImageService<T extends TransformOptions = TransformOptions> extends HostedImageService<T> { /** - * Gets the HTML attributes needed for the server rendered `<img />` element. + * Gets tthe HTML attributes needed for the server rendered `<img />` element. */ getImageAttributes(transform: T): Promise<Exclude<ImageAttributes, 'src'>>; /** @@ -115,6 +116,14 @@ export type ImageService<T extends TransformOptions = TransformOptions> = | HostedImageService<T> | SSRImageService<T>; +export function isHostedService(service: ImageService): service is ImageService { + return 'getImageSrc' in service; +} + +export function isSSRService(service: ImageService): service is SSRImageService { + return 'transform' in service; +} + export interface ImageMetadata { src: string; width: number; diff --git a/packages/integrations/image/src/utils.ts b/packages/integrations/image/src/utils.ts index 95e0fb2a1..44c338cf4 100644 --- a/packages/integrations/image/src/utils.ts +++ b/packages/integrations/image/src/utils.ts @@ -58,3 +58,17 @@ export function propsToFilename({ src, width, height, format }: TransformOptions return format ? src.replace(ext, format) : src; } + +export function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) { + if (!aspectRatio) { + return undefined; + } + + // parse aspect ratio strings, if required (ex: "16:9") + if (typeof aspectRatio === 'number') { + return aspectRatio; + } else { + const [width, height] = aspectRatio.split(':'); + return parseInt(width) / parseInt(height); + } +} |