From ef9345767b898b436acc6da32da4936b882fd926 Mon Sep 17 00:00:00 2001 From: Tony Sullivan Date: Fri, 22 Jul 2022 23:01:56 +0000 Subject: [PATCH] WIP: [image] Fixing SSR support and improving error validation (#4013) * fix: SSR builds were hitting an undefined error and skipping the step for copying original assets * chore: update lockfile * chore: adding better error validation to getImage and getPicture * refactor: cleaning up index.ts * refactor: moving SSG build generation logic out of the integration * splitting build to ssg & ssr helpers, re-enabling SSR image build tests * sharp should automatically rotate based on EXIF * cleaning up how static images are tracked for SSG builds * undo unrelated mod.d.ts change * chore: add changeset --- .changeset/tiny-glasses-play.md | 6 + .../integrations/image/components/Image.astro | 7 +- .../image/components/Picture.astro | 8 +- packages/integrations/image/package.json | 3 +- packages/integrations/image/src/build/ssg.ts | 79 ++++++++++ packages/integrations/image/src/build/ssr.ts | 29 ++++ .../integrations/image/src/endpoints/dev.ts | 5 +- .../integrations/image/src/endpoints/prod.ts | 15 +- packages/integrations/image/src/index.ts | 140 +----------------- .../integrations/image/src/integration.ts | 93 ++++++++++++ .../image/src/{ => lib}/get-image.ts | 32 ++-- .../image/src/{ => lib}/get-picture.ts | 20 ++- .../integrations/image/src/loaders/sharp.ts | 7 +- packages/integrations/image/src/types.ts | 8 +- .../image/src/{utils.ts => utils/images.ts} | 32 +--- .../image/src/{ => utils}/metadata.ts | 2 +- .../integrations/image/src/utils/paths.ts | 40 +++++ .../image/src/{ => utils}/shorthash.ts | 0 .../image/src/vite-plugin-astro-image.ts | 15 +- .../test/fixtures/rotation/astro.config.mjs | 8 + .../image/test/fixtures/rotation/package.json | 10 ++ .../test/fixtures/rotation/public/favicon.ico | Bin 0 -> 4286 bytes .../test/fixtures/rotation/server/server.mjs | 44 ++++++ .../rotation/src/assets/Landscape_0.jpg | Bin 0 -> 349915 bytes .../rotation/src/assets/Landscape_1.jpg | Bin 0 -> 347327 bytes .../rotation/src/assets/Landscape_2.jpg | Bin 0 -> 349209 bytes .../rotation/src/assets/Landscape_3.jpg | Bin 0 -> 348796 bytes .../rotation/src/assets/Landscape_4.jpg | Bin 0 -> 348052 bytes .../rotation/src/assets/Landscape_5.jpg | Bin 0 -> 351275 bytes .../rotation/src/assets/Landscape_6.jpg | Bin 0 -> 352727 bytes .../rotation/src/assets/Landscape_7.jpg | Bin 0 -> 351856 bytes .../rotation/src/assets/Landscape_8.jpg | Bin 0 -> 352067 bytes .../rotation/src/assets/Portrait_0.jpg | Bin 0 -> 248531 bytes .../rotation/src/assets/Portrait_1.jpg | Bin 0 -> 245684 bytes .../rotation/src/assets/Portrait_2.jpg | Bin 0 -> 246915 bytes .../rotation/src/assets/Portrait_3.jpg | Bin 0 -> 247276 bytes .../rotation/src/assets/Portrait_4.jpg | Bin 0 -> 246554 bytes .../rotation/src/assets/Portrait_5.jpg | Bin 0 -> 251487 bytes .../rotation/src/assets/Portrait_6.jpg | Bin 0 -> 251800 bytes .../rotation/src/assets/Portrait_7.jpg | Bin 0 -> 250892 bytes .../rotation/src/assets/Portrait_8.jpg | Bin 0 -> 251978 bytes .../fixtures/rotation/src/pages/index.astro | 48 ++++++ .../integrations/image/test/image-ssg.test.js | 12 +- .../integrations/image/test/image-ssr.test.js | 25 +++- .../image/test/picture-ssg.test.js | 4 + .../image/test/picture-ssr.test.js | 23 ++- .../integrations/image/test/rotation.test.js | 68 +++++++++ pnpm-lock.yaml | 12 ++ 48 files changed, 557 insertions(+), 238 deletions(-) create mode 100644 .changeset/tiny-glasses-play.md create mode 100644 packages/integrations/image/src/build/ssg.ts create mode 100644 packages/integrations/image/src/build/ssr.ts create mode 100644 packages/integrations/image/src/integration.ts rename packages/integrations/image/src/{ => lib}/get-image.ts (83%) rename packages/integrations/image/src/{ => lib}/get-picture.ts (84%) rename packages/integrations/image/src/{utils.ts => utils/images.ts} (57%) rename packages/integrations/image/src/{ => utils}/metadata.ts (89%) create mode 100644 packages/integrations/image/src/utils/paths.ts rename packages/integrations/image/src/{ => utils}/shorthash.ts (100%) create mode 100644 packages/integrations/image/test/fixtures/rotation/astro.config.mjs create mode 100644 packages/integrations/image/test/fixtures/rotation/package.json create mode 100644 packages/integrations/image/test/fixtures/rotation/public/favicon.ico create mode 100644 packages/integrations/image/test/fixtures/rotation/server/server.mjs create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_0.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_1.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_2.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_3.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_4.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_5.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_6.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_7.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_8.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_0.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_1.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_2.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_3.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_4.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_5.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_6.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_7.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_8.jpg create mode 100644 packages/integrations/image/test/fixtures/rotation/src/pages/index.astro create mode 100644 packages/integrations/image/test/rotation.test.js diff --git a/.changeset/tiny-glasses-play.md b/.changeset/tiny-glasses-play.md new file mode 100644 index 000000000..1515d63ee --- /dev/null +++ b/.changeset/tiny-glasses-play.md @@ -0,0 +1,6 @@ +--- +'@astrojs/image': minor +--- + +- Fixes two bugs that were blocking SSR support when deployed to a hosting service +- The built-in `sharp` service now automatically rotates images based on EXIF data diff --git a/packages/integrations/image/components/Image.astro b/packages/integrations/image/components/Image.astro index 326c1bc6c..18e35d1a6 100644 --- a/packages/integrations/image/components/Image.astro +++ b/packages/integrations/image/components/Image.astro @@ -1,8 +1,7 @@ --- // @ts-ignore -import loader from 'virtual:image-loader'; -import { getImage } from '../src/index.js'; -import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../src/types.js'; +import { getImage } from '../dist/index.js'; +import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../dist/types'; export interface LocalImageProps extends Omit, Omit { src: ImageMetadata | Promise<{ default: ImageMetadata }>; @@ -19,7 +18,7 @@ export type Props = LocalImageProps | RemoteImageProps; const { loading = "lazy", decoding = "async", ...props } = Astro.props as Props; -const attrs = await getImage(loader, props); +const attrs = await getImage(props); --- diff --git a/packages/integrations/image/components/Picture.astro b/packages/integrations/image/components/Picture.astro index bff6aad89..badfc7f46 100644 --- a/packages/integrations/image/components/Picture.astro +++ b/packages/integrations/image/components/Picture.astro @@ -1,8 +1,6 @@ --- -// @ts-ignore -import loader from 'virtual:image-loader'; -import { getPicture } from '../src/get-picture.js'; -import type { ImageAttributes, ImageMetadata, OutputFormat, PictureAttributes, TransformOptions } from '../src/types.js'; +import { getPicture } from '../dist/index.js'; +import type { ImageAttributes, ImageMetadata, OutputFormat, PictureAttributes, TransformOptions } from '../dist/types'; export interface LocalImageProps extends Omit, Omit, Pick { src: ImageMetadata | Promise<{ default: ImageMetadata }>; @@ -25,7 +23,7 @@ export type Props = LocalImageProps | RemoteImageProps; const { src, alt, sizes, widths, aspectRatio, formats = ['avif', 'webp'], loading = 'lazy', decoding = 'async', ...attrs } = Astro.props as Props; -const { image, sources } = await getPicture({ loader, src, widths, formats, aspectRatio }); +const { image, sources } = await getPicture({ src, widths, formats, aspectRatio }); --- diff --git a/packages/integrations/image/package.json b/packages/integrations/image/package.json index e4d1f26f9..816f08141 100644 --- a/packages/integrations/image/package.json +++ b/packages/integrations/image/package.json @@ -54,6 +54,7 @@ "@types/etag": "^1.8.1", "@types/sharp": "^0.30.4", "astro": "workspace:*", - "astro-scripts": "workspace:*" + "astro-scripts": "workspace:*", + "tiny-glob": "^0.2.9" } } diff --git a/packages/integrations/image/src/build/ssg.ts b/packages/integrations/image/src/build/ssg.ts new file mode 100644 index 000000000..a3e410709 --- /dev/null +++ b/packages/integrations/image/src/build/ssg.ts @@ -0,0 +1,79 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { OUTPUT_DIR } from '../constants.js'; +import { ensureDir } from '../utils/paths.js'; +import { isRemoteImage, loadRemoteImage, loadLocalImage } from '../utils/images.js'; +import type { SSRImageService, TransformOptions } from '../types.js'; + +export interface SSGBuildParams { + loader: SSRImageService; + staticImages: Map>; + srcDir: URL; + outDir: URL; +} + +export async function ssgBuild({ + loader, + staticImages, + srcDir, + outDir, +}: SSGBuildParams) { + const inputFiles = new Set(); + + // process transforms one original image file at a time + for await (const [src, transformsMap] of staticImages) { + let inputFile: string | undefined = undefined; + let inputBuffer: Buffer | undefined = undefined; + + if (isRemoteImage(src)) { + // try to load the remote image + inputBuffer = await loadRemoteImage(src); + } else { + const inputFileURL = new URL(`.${src}`, srcDir); + inputFile = fileURLToPath(inputFileURL); + inputBuffer = await loadLocalImage(inputFile); + + // track the local file used so the original can be copied over + inputFiles.add(inputFile); + } + + if (!inputBuffer) { + // eslint-disable-next-line no-console + console.warn(`"${src}" image could not be fetched`); + continue; + } + + const transforms = Array.from(transformsMap.entries()); + + // process each transformed versiono of the + for await (const [filename, transform] of transforms) { + let outputFile: string; + + if (isRemoteImage(src)) { + const outputFileURL = new URL( + path.join('./', OUTPUT_DIR, path.basename(filename)), + outDir + ); + outputFile = fileURLToPath(outputFileURL); + } else { + const outputFileURL = new URL(path.join('./', OUTPUT_DIR, filename), outDir); + outputFile = fileURLToPath(outputFileURL); + } + + const { data } = await loader.transform(inputBuffer, transform); + + ensureDir(path.dirname(outputFile)); + + await fs.writeFile(outputFile, data); + } + } + + // copy all original local images to dist + for await (const original of inputFiles) { + const to = original.replace(fileURLToPath(srcDir), fileURLToPath(outDir)); + + await ensureDir(path.dirname(to)); + await fs.copyFile(original, to); + } +} diff --git a/packages/integrations/image/src/build/ssr.ts b/packages/integrations/image/src/build/ssr.ts new file mode 100644 index 000000000..90a699451 --- /dev/null +++ b/packages/integrations/image/src/build/ssr.ts @@ -0,0 +1,29 @@ +import fs from 'fs/promises'; +import path from 'path'; +import glob from 'tiny-glob'; +import { fileURLToPath } from 'url'; +import { ensureDir } from '../utils/paths.js'; + +async function globImages(dir: URL) { + const srcPath = fileURLToPath(dir); + return await glob( + `${srcPath}/**/*.{heic,heif,avif,jpeg,jpg,png,tiff,webp,gif}`, + { absolute: true } + ); +} + +export interface SSRBuildParams { + srcDir: URL; + outDir: URL; +} + +export async function ssrBuild({ srcDir, outDir }: SSRBuildParams) { + const images = await globImages(srcDir); + + for await (const image of images) { + const to = image.replace(fileURLToPath(srcDir), fileURLToPath(outDir)); + + await ensureDir(path.dirname(to)); + await fs.copyFile(image, to); + } +} diff --git a/packages/integrations/image/src/endpoints/dev.ts b/packages/integrations/image/src/endpoints/dev.ts index 67b37b177..dfa7f4900 100644 --- a/packages/integrations/image/src/endpoints/dev.ts +++ b/packages/integrations/image/src/endpoints/dev.ts @@ -1,10 +1,9 @@ import type { APIRoute } from 'astro'; import { lookup } from 'mrmime'; -import { loadImage } from '../utils.js'; +import loader from '../loaders/sharp.js'; +import { loadImage } from '../utils/images.js'; export const get: APIRoute = async ({ request }) => { - const loader = globalThis.astroImage.ssrLoader; - try { const url = new URL(request.url); const transform = loader.parseTransform(url.searchParams); diff --git a/packages/integrations/image/src/endpoints/prod.ts b/packages/integrations/image/src/endpoints/prod.ts index 921b54853..8a15c2e88 100644 --- a/packages/integrations/image/src/endpoints/prod.ts +++ b/packages/integrations/image/src/endpoints/prod.ts @@ -1,9 +1,10 @@ import type { APIRoute } from 'astro'; import etag from 'etag'; import { lookup } from 'mrmime'; +import { fileURLToPath } from 'url'; // @ts-ignore import loader from 'virtual:image-loader'; -import { isRemoteImage, loadRemoteImage } from '../utils.js'; +import { isRemoteImage, loadLocalImage, loadRemoteImage } from '../utils/images.js'; export const get: APIRoute = async ({ request }) => { try { @@ -14,12 +15,14 @@ export const get: APIRoute = async ({ request }) => { 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); + let inputBuffer: Buffer | undefined = undefined; - const inputBuffer = await loadRemoteImage(href.toString()); + if (isRemoteImage(transform.src)) { + inputBuffer = await loadRemoteImage(transform.src); + } else { + const pathname = fileURLToPath(new URL(`../client${transform.src}`, import.meta.url)); + inputBuffer = await loadLocalImage(pathname); + } if (!inputBuffer) { return new Response(`"${transform.src} not found`, { status: 404 }); diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts index f857bdc70..81ef8c6b9 100644 --- a/packages/integrations/image/src/index.ts +++ b/packages/integrations/image/src/index.ts @@ -1,137 +1,5 @@ -import type { AstroConfig, AstroIntegration } from 'astro'; -import fs from 'fs/promises'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { OUTPUT_DIR, PKG_NAME, ROUTE_PATTERN } from './constants.js'; -import sharp from './loaders/sharp.js'; -import { IntegrationOptions, TransformOptions } from './types.js'; -import { - ensureDir, - isRemoteImage, - loadLocalImage, - loadRemoteImage, - propsToFilename, -} from './utils.js'; -import { createPlugin } from './vite-plugin-astro-image.js'; -export * from './get-image.js'; -export * from './get-picture.js'; +import integration from './integration.js'; +export * from './lib/get-image.js'; +export * from './lib/get-picture.js'; -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)], - optimizeDeps: { - include: ['image-size', 'sharp'], - }, - ssr: { - noExternal: ['@astrojs/image', resolvedOptions.serviceEntryPoint], - }, - }; - } - - 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 - function addStaticImage(transform: TransformOptions) { - staticImages.set(propsToFilename(transform), transform); - } - - // TODO: Add support for custom, user-provided filename format functions - function 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()}`; - } - } - - // Initialize the integration's globalThis namespace - // This is needed to share scope between Node and Vite - globalThis.astroImage = { - loader: undefined, // initialized in first getImage() call - ssrLoader: sharp, - command, - addStaticImage, - filenameFormat, - }; - - 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.astroImage.loader; - - if (!loader || !('transform' in loader)) { - // this should never be hit, how was a staticImage added without an SSR service? - return; - } - - 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) { - // eslint-disable-next-line no-console - 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; +export default integration; diff --git a/packages/integrations/image/src/integration.ts b/packages/integrations/image/src/integration.ts new file mode 100644 index 000000000..0b9542caa --- /dev/null +++ b/packages/integrations/image/src/integration.ts @@ -0,0 +1,93 @@ +import type { AstroConfig, AstroIntegration } from 'astro'; +import { ssgBuild } from './build/ssg.js'; +import { ssrBuild } from './build/ssr.js'; +import { PKG_NAME, ROUTE_PATTERN } from './constants.js'; +import { filenameFormat, propsToFilename } from './utils/paths.js'; +import { IntegrationOptions, TransformOptions } from './types.js'; +import { createPlugin } from './vite-plugin-astro-image.js'; + +export default function integration(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; + let mode: 'ssr' | 'ssg'; + + function getViteConfiguration() { + return { + plugins: [createPlugin(_config, resolvedOptions)], + optimizeDeps: { + include: ['image-size', 'sharp'], + }, + ssr: { + noExternal: ['@astrojs/image', resolvedOptions.serviceEntryPoint], + }, + }; + } + + 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 + mode = command === 'dev' || config.adapter ? 'ssr' : 'ssg'; + + updateConfig({ vite: getViteConfiguration() }); + + if (mode === 'ssr') { + injectRoute({ + pattern: ROUTE_PATTERN, + entryPoint: + command === 'dev' ? '@astrojs/image/endpoints/dev' : '@astrojs/image/endpoints/prod', + }); + } + }, + 'astro:server:setup': async () => { + globalThis.astroImage = {}; + }, + 'astro:build:setup': () => { + // Used to cache all images rendered to HTML + // Added to globalThis to share the same map in Node and Vite + function addStaticImage(transform: TransformOptions) { + const srcTranforms = staticImages.has(transform.src) + ? staticImages.get(transform.src)! + : new Map(); + + srcTranforms.set(propsToFilename(transform), transform); + + staticImages.set(transform.src, srcTranforms); + } + + // Helpers for building static images should only be available for SSG + globalThis.astroImage = + mode === 'ssg' + ? { + addStaticImage, + filenameFormat, + } + : {}; + }, + 'astro:build:done': async ({ dir }) => { + if (mode === 'ssr') { + // for SSR builds, copy all image files from src to dist + // to make sure they are available for use in production + await ssrBuild({ srcDir: _config.srcDir, outDir: dir }); + } else { + // for SSG builds, build all requested image transforms to dist + const loader = globalThis?.astroImage?.loader; + + if (loader && 'transform' in loader && staticImages.size > 0) { + await ssgBuild({ loader, staticImages, srcDir: _config.srcDir, outDir: dir }); + } + } + }, + }, + }; +} diff --git a/packages/integrations/image/src/get-image.ts b/packages/integrations/image/src/lib/get-image.ts similarity index 83% rename from packages/integrations/image/src/get-image.ts rename to packages/integrations/image/src/lib/get-image.ts index 10de5c039..60a6b60da 100644 --- a/packages/integrations/image/src/get-image.ts +++ b/packages/integrations/image/src/lib/get-image.ts @@ -1,5 +1,6 @@ import slash from 'slash'; -import { ROUTE_PATTERN } from './constants.js'; +import { ROUTE_PATTERN } from '../constants.js'; +import sharp from '../loaders/sharp.js'; import { ImageAttributes, ImageMetadata, @@ -7,8 +8,8 @@ import { isSSRService, OutputFormat, TransformOptions, -} from './types.js'; -import { isRemoteImage, parseAspectRatio } from './utils.js'; +} from '../types.js'; +import { isRemoteImage, parseAspectRatio } from '../utils/images.js'; export interface GetImageTransform extends Omit { src: string | ImageMetadata | Promise<{ default: ImageMetadata }>; @@ -97,24 +98,35 @@ async function resolveTransform(input: GetImageTransform): Promise` 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: ImageService, transform: GetImageTransform ): Promise { - globalThis.astroImage.loader = loader; + if (!transform.src) { + throw new Error('[@astrojs/image] `src` is required'); + } + + let loader = globalThis.astroImage?.loader; + + if (!loader) { + // @ts-ignore + const { default: mod } = await import('virtual:image-loader'); + loader = mod as ImageService; + globalThis.astroImage = globalThis.astroImage || {}; + globalThis.astroImage.loader = loader; + } const resolved = await resolveTransform(transform); const attributes = await loader.getImageAttributes(resolved); - const isDev = globalThis.astroImage.command === 'dev'; + // @ts-ignore + const isDev = import.meta.env.DEV; const isLocalImage = !isRemoteImage(resolved.src); - const _loader = isDev && isLocalImage ? globalThis.astroImage.ssrLoader : loader; + const _loader = isDev && isLocalImage ? sharp : loader; if (!_loader) { throw new Error('@astrojs/image: loader not found!'); @@ -125,11 +137,11 @@ export async function getImage( const { searchParams } = _loader.serializeTransform(resolved); // cache all images rendered to HTML - if (globalThis?.astroImage) { + if (globalThis.astroImage?.addStaticImage) { globalThis.astroImage.addStaticImage(resolved); } - const src = globalThis?.astroImage + const src = globalThis.astroImage?.filenameFormat ? globalThis.astroImage.filenameFormat(resolved, searchParams) : `${ROUTE_PATTERN}?${searchParams.toString()}`; diff --git a/packages/integrations/image/src/get-picture.ts b/packages/integrations/image/src/lib/get-picture.ts similarity index 84% rename from packages/integrations/image/src/get-picture.ts rename to packages/integrations/image/src/lib/get-picture.ts index f8ca694ad..a214e1fe6 100644 --- a/packages/integrations/image/src/get-picture.ts +++ b/packages/integrations/image/src/lib/get-picture.ts @@ -4,14 +4,12 @@ import { getImage } from './get-image.js'; import { ImageAttributes, ImageMetadata, - ImageService, OutputFormat, TransformOptions, -} from './types.js'; -import { parseAspectRatio } from './utils.js'; +} from '../types.js'; +import { parseAspectRatio } from '../utils/images.js'; export interface GetPictureParams { - loader: ImageService; src: string | ImageMetadata | Promise<{ default: ImageMetadata }>; widths: number[]; formats: OutputFormat[]; @@ -46,7 +44,15 @@ async function resolveFormats({ src, formats }: GetPictureParams) { } export async function getPicture(params: GetPictureParams): Promise { - const { loader, src, widths, formats } = params; + const { src, widths } = params; + + if (!src) { + throw new Error('[@astrojs/image] `src` is required'); + } + + if (!widths || !Array.isArray(widths)) { + throw new Error('[@astrojs/image] at least one `width` is required'); + } const aspectRatio = await resolveAspectRatio(params); @@ -57,7 +63,7 @@ export async function getPicture(params: GetPictureParams): Promise { - const img = await getImage(loader, { + const img = await getImage({ src, format, width, @@ -76,7 +82,7 @@ export async function getPicture(params: GetPictureParams): Promise void; - filenameFormat: (transform: TransformOptions, searchParams: URLSearchParams) => string; + addStaticImage?: (transform: TransformOptions) => void; + filenameFormat?: (transform: TransformOptions, searchParams: URLSearchParams) => string; } declare global { // eslint-disable-next-line no-var - var astroImage: ImageIntegration; + var astroImage: ImageIntegration | undefined; } export type InputFormat = diff --git a/packages/integrations/image/src/utils.ts b/packages/integrations/image/src/utils/images.ts similarity index 57% rename from packages/integrations/image/src/utils.ts rename to packages/integrations/image/src/utils/images.ts index 80dff1b6e..55a45d1ce 100644 --- a/packages/integrations/image/src/utils.ts +++ b/packages/integrations/image/src/utils/images.ts @@ -1,7 +1,5 @@ -import fs from 'fs'; -import path from 'path'; -import { shorthash } from './shorthash.js'; -import type { OutputFormat, TransformOptions } from './types'; +import fs from 'fs/promises'; +import type { OutputFormat, TransformOptions } from '../types.js'; export function isOutputFormat(value: string): value is OutputFormat { return ['avif', 'jpeg', 'png', 'webp'].includes(value); @@ -11,17 +9,13 @@ 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); + return await fs.readFile(src); } catch { return undefined; } @@ -45,26 +39,6 @@ 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, ''); - - // for remote images, add a hash of the full URL to dedupe images with the same filename - if (isRemoteImage(src)) { - filename += `-${shorthash(src)}`; - } - - 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; -} - export function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) { if (!aspectRatio) { return undefined; diff --git a/packages/integrations/image/src/metadata.ts b/packages/integrations/image/src/utils/metadata.ts similarity index 89% rename from packages/integrations/image/src/metadata.ts rename to packages/integrations/image/src/utils/metadata.ts index 823862ea7..38859b817 100644 --- a/packages/integrations/image/src/metadata.ts +++ b/packages/integrations/image/src/utils/metadata.ts @@ -1,6 +1,6 @@ import fs from 'fs/promises'; import sizeOf from 'image-size'; -import { ImageMetadata, InputFormat } from './types'; +import { ImageMetadata, InputFormat } from '../types.js'; export async function metadata(src: string): Promise { const file = await fs.readFile(src); diff --git a/packages/integrations/image/src/utils/paths.ts b/packages/integrations/image/src/utils/paths.ts new file mode 100644 index 000000000..90e744252 --- /dev/null +++ b/packages/integrations/image/src/utils/paths.ts @@ -0,0 +1,40 @@ +import fs from 'fs'; +import path from 'path'; +import { OUTPUT_DIR } from '../constants.js'; +import { isRemoteImage } from './images.js'; +import { shorthash } from './shorthash.js'; +import type { TransformOptions } from '../types.js'; + +export function ensureDir(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +export function propsToFilename({ src, width, height, format }: TransformOptions) { + const ext = path.extname(src); + let filename = src.replace(ext, ''); + + // for remote images, add a hash of the full URL to dedupe images with the same filename + if (isRemoteImage(src)) { + filename += `-${shorthash(src)}`; + } + + 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; +} + +export function filenameFormat(transform: TransformOptions) { + return isRemoteImage(transform.src) + ? path.join(OUTPUT_DIR, path.basename(propsToFilename(transform))) + : path.join( + OUTPUT_DIR, + path.dirname(transform.src), + path.basename(propsToFilename(transform)) + ); +} diff --git a/packages/integrations/image/src/shorthash.ts b/packages/integrations/image/src/utils/shorthash.ts similarity index 100% rename from packages/integrations/image/src/shorthash.ts rename to packages/integrations/image/src/utils/shorthash.ts diff --git a/packages/integrations/image/src/vite-plugin-astro-image.ts b/packages/integrations/image/src/vite-plugin-astro-image.ts index 2dfda8fa5..5ca9c1571 100644 --- a/packages/integrations/image/src/vite-plugin-astro-image.ts +++ b/packages/integrations/image/src/vite-plugin-astro-image.ts @@ -1,11 +1,10 @@ import type { AstroConfig } from 'astro'; -import fs from 'fs/promises'; import type { PluginContext } from 'rollup'; import slash from 'slash'; import { pathToFileURL } from 'url'; import type { Plugin, ResolvedConfig } from 'vite'; -import { metadata } from './metadata.js'; -import type { IntegrationOptions } from './types'; +import { metadata } from './utils/metadata.js'; +import type { IntegrationOptions } from './types.js'; export function createPlugin(config: AstroConfig, options: Required): Plugin { const filter = (id: string) => @@ -60,15 +59,7 @@ export function createPlugin(config: AstroConfig, options: RequirednA!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/rotation/server/server.mjs b/packages/integrations/image/test/fixtures/rotation/server/server.mjs new file mode 100644 index 000000000..d7a0a7a40 --- /dev/null +++ b/packages/integrations/image/test/fixtures/rotation/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