diff --git a/.changeset/lucky-mirrors-type.md b/.changeset/lucky-mirrors-type.md new file mode 100644 index 000000000..b58cb38fb --- /dev/null +++ b/.changeset/lucky-mirrors-type.md @@ -0,0 +1,12 @@ +--- +'@astrojs/image': minor +--- + +`` and `` now support using images in the `/public` directory :tada: + +- Moving handling of local image files into the Vite plugin +- Optimized image files are now built to `/dist` with hashes provided by Vite, removing the need for a `/dist/_image` directory +- Removes three npm dependencies: `etag`, `slash`, and `tiny-glob` +- Replaces `mrmime` with the `mime` package already used by Astro's SSR server +- Simplifies the injected `_image` route to work for both `dev` and `build` +- Adds a new test suite for using images with `@astrojs/mdx` - including optimizing images straight from `/public` diff --git a/packages/integrations/image/README.md b/packages/integrations/image/README.md index bd121e1a6..7557c4ecd 100644 --- a/packages/integrations/image/README.md +++ b/packages/integrations/image/README.md @@ -106,7 +106,11 @@ In addition to the component-specific properties, any valid HTML attribute for t 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. +For images located in your project's `src`: use the file path relative to the `src` directory. (e.g. `src="../assets/source-pic.png"`) + + For images located in your `public` directory: use the URL path relative to the `public` directory. (e.g. `src="/images/public-image.jpg"`) + +For remote images, provide the full URL. (e.g. `src="https://astro.build/assets/blog/astro-1-release-update.avif"`) #### format @@ -182,7 +186,7 @@ A `number` can also be provided, useful when the aspect ratio is calculated at b 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. +For images in your project's repository, use the path relative to the `src` or `public` directory. For remote images, provide the full URL. #### alt @@ -341,6 +345,24 @@ import heroImage from '../assets/hero.png'; ``` +#### Images in `/public` + +Files in the `/public` directory are always served or copied as-is, with no processing. We recommend that local images are always kept in `src/` so that Astro can transform, optimize and bundle them. But if you absolutely must keep an image in `public/`, use its relative URL path as the image's `src=` attribute. It will be treated as a remote image, which requires an `aspectRatio` attribute. + +Alternatively, you can import an image from your `public/` directory in your frontmatter and use a variable in your `src=` attribute. You cannot, however, import this directly inside the component as its `src` value. + +For example, use an image located at `public/social.png` in either static or SSR builds like so: + +```astro title="src/pages/page.astro" +--- +import { Image } from '@astrojs/image/components'; +import socialImage from '/social.png'; +--- +// In static builds: the image will be built and optimized to `/dist`. +// In SSR builds: the image will be optimized by the server when requested by a browser. + +``` + ### 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`. diff --git a/packages/integrations/image/package.json b/packages/integrations/image/package.json index 626830fa6..1cf5d4351 100644 --- a/packages/integrations/image/package.json +++ b/packages/integrations/image/package.json @@ -21,9 +21,8 @@ "homepage": "https://docs.astro.build/en/guides/integrations-guide/image/", "exports": { ".": "./dist/index.js", + "./endpoint": "./dist/endpoint.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", "./client": "./client.d.ts", @@ -41,19 +40,15 @@ "test": "mocha --exit --timeout 20000 test" }, "dependencies": { - "etag": "^1.8.1", - "image-size": "^1.0.1", - "mrmime": "^1.0.0", - "sharp": "^0.30.6", - "slash": "^4.0.0", - "tiny-glob": "^0.2.9" + "image-size": "^1.0.2", + "magic-string": "^0.25.9", + "mime": "^3.0.0", + "sharp": "^0.30.6" }, "devDependencies": { - "@types/etag": "^1.8.1", - "@types/sharp": "^0.30.4", + "@types/sharp": "^0.30.5", "astro": "workspace:*", "astro-scripts": "workspace:*", - "kleur": "^4.1.4", - "tiny-glob": "^0.2.9" + "kleur": "^4.1.4" } } diff --git a/packages/integrations/image/src/build/ssg.ts b/packages/integrations/image/src/build/ssg.ts index e082a128d..09a4aad9c 100644 --- a/packages/integrations/image/src/build/ssg.ts +++ b/packages/integrations/image/src/build/ssg.ts @@ -2,11 +2,11 @@ import { bgGreen, black, cyan, dim, green } from 'kleur/colors'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { OUTPUT_DIR } from '../constants.js'; +import type { AstroConfig } from 'astro'; import type { SSRImageService, TransformOptions } from '../loaders/index.js'; -import { isRemoteImage, loadLocalImage, loadRemoteImage } from '../utils/images.js'; +import { loadLocalImage, loadRemoteImage } from '../utils/images.js'; import { debug, info, LoggerLevel, warn } from '../utils/logger.js'; -import { ensureDir } from '../utils/paths.js'; +import { isRemoteImage } from '../utils/paths.js'; function getTimeStat(timeStart: number, timeEnd: number) { const buildTime = timeEnd - timeStart; @@ -16,12 +16,12 @@ function getTimeStat(timeStart: number, timeEnd: number) { export interface SSGBuildParams { loader: SSRImageService; staticImages: Map>; - srcDir: URL; + config: AstroConfig; outDir: URL; logLevel: LoggerLevel; } -export async function ssgBuild({ loader, staticImages, srcDir, outDir, logLevel }: SSGBuildParams) { +export async function ssgBuild({ loader, staticImages, config, outDir, logLevel }: SSGBuildParams) { const timer = performance.now(); info({ @@ -35,15 +35,21 @@ export async function ssgBuild({ loader, staticImages, srcDir, outDir, logLevel const inputFiles = new Set(); // process transforms one original image file at a time - for (const [src, transformsMap] of staticImages) { + for (let [src, transformsMap] of staticImages) { let inputFile: string | undefined = undefined; let inputBuffer: Buffer | undefined = undefined; + // Vite will prefix a hashed image with the base path, we need to strip this + // off to find the actual file relative to /dist + if (config.base && src.startsWith(config.base)) { + src = src.substring(config.base.length - 1); + } + if (isRemoteImage(src)) { // try to load the remote image inputBuffer = await loadRemoteImage(src); } else { - const inputFileURL = new URL(`.${src}`, srcDir); + const inputFileURL = new URL(`.${src}`, outDir); inputFile = fileURLToPath(inputFileURL); inputBuffer = await loadLocalImage(inputFile); @@ -62,39 +68,21 @@ export async function ssgBuild({ loader, staticImages, srcDir, outDir, logLevel debug({ level: logLevel, prefix: false, message: `${green('▶')} ${src}` }); let timeStart = performance.now(); - if (inputFile) { - const to = inputFile.replace(fileURLToPath(srcDir), fileURLToPath(outDir)); - await ensureDir(path.dirname(to)); - await fs.copyFile(inputFile, to); - - const timeEnd = performance.now(); - const timeChange = getTimeStat(timeStart, timeEnd); - const timeIncrease = `(+${timeChange})`; - const pathRelative = inputFile.replace(fileURLToPath(srcDir), ''); - debug({ - level: logLevel, - prefix: false, - message: ` ${cyan('└─')} ${dim(`(original) ${pathRelative}`)} ${dim(timeIncrease)}`, - }); - } - // process each transformed versiono of the for (const [filename, transform] of transforms) { timeStart = performance.now(); let outputFile: string; if (isRemoteImage(src)) { - const outputFileURL = new URL(path.join('./', OUTPUT_DIR, path.basename(filename)), outDir); + const outputFileURL = new URL(path.join('./', path.basename(filename)), outDir); outputFile = fileURLToPath(outputFileURL); } else { - const outputFileURL = new URL(path.join('./', OUTPUT_DIR, filename), outDir); + const outputFileURL = new URL(path.join('./', filename), outDir); outputFile = fileURLToPath(outputFileURL); } const { data } = await loader.transform(inputBuffer, transform); - ensureDir(path.dirname(outputFile)); - await fs.writeFile(outputFile, data); const timeEnd = performance.now(); diff --git a/packages/integrations/image/src/build/ssr.ts b/packages/integrations/image/src/build/ssr.ts deleted file mode 100644 index 940fc5249..000000000 --- a/packages/integrations/image/src/build/ssr.ts +++ /dev/null @@ -1,29 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import glob from 'tiny-glob'; -import { ensureDir } from '../utils/paths.js'; - -async function globImages(dir: URL) { - const srcPath = fileURLToPath(dir); - return await glob('./**/*.{heic,heif,avif,jpeg,jpg,png,tiff,webp,gif}', { - cwd: fileURLToPath(dir), - }); -} - -export interface SSRBuildParams { - srcDir: URL; - outDir: URL; -} - -export async function ssrBuild({ srcDir, outDir }: SSRBuildParams) { - const images = await globImages(srcDir); - - for (const image of images) { - const from = path.join(fileURLToPath(srcDir), image); - const to = path.join(fileURLToPath(outDir), image); - - await ensureDir(path.dirname(to)); - await fs.copyFile(from, to); - } -} diff --git a/packages/integrations/image/src/constants.ts b/packages/integrations/image/src/constants.ts deleted file mode 100644 index db52614c5..000000000 --- a/packages/integrations/image/src/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const PKG_NAME = '@astrojs/image'; -export const ROUTE_PATTERN = '/_image'; -export const OUTPUT_DIR = '/_image'; diff --git a/packages/integrations/image/src/endpoints/prod.ts b/packages/integrations/image/src/endpoint.ts similarity index 50% rename from packages/integrations/image/src/endpoints/prod.ts rename to packages/integrations/image/src/endpoint.ts index 667410a8b..aa04c3ded 100644 --- a/packages/integrations/image/src/endpoints/prod.ts +++ b/packages/integrations/image/src/endpoint.ts @@ -1,31 +1,39 @@ import type { APIRoute } from 'astro'; -import etag from 'etag'; -import { lookup } from 'mrmime'; +import mime from 'mime'; // @ts-ignore import loader from 'virtual:image-loader'; -import { isRemoteImage, loadLocalImage, loadRemoteImage } from '../utils/images.js'; +import { etag } from './utils/etag.js'; +import { isRemoteImage } from './utils/paths.js'; + +async function loadRemoteImage(src: URL) { + try { + const res = await fetch(src); + + if (!res.ok) { + return undefined; + } + + return Buffer.from(await res.arrayBuffer()); + } catch { + return undefined; + } +} 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 }); - } - let inputBuffer: Buffer | undefined = undefined; - if (isRemoteImage(transform.src)) { - inputBuffer = await loadRemoteImage(transform.src); - } else { - const clientRoot = new URL('../client/', import.meta.url); - const localPath = new URL('.' + transform.src, clientRoot); - inputBuffer = await loadLocalImage(localPath); - } + // TODO: handle config subpaths? + const sourceUrl = isRemoteImage(transform.src) + ? new URL(transform.src) + : new URL(transform.src, url.origin); + inputBuffer = await loadRemoteImage(sourceUrl); if (!inputBuffer) { - return new Response(`"${transform.src} not found`, { status: 404 }); + return new Response('Not Found', { status: 404 }); } const { data, format } = await loader.transform(inputBuffer, transform); @@ -33,13 +41,13 @@ export const get: APIRoute = async ({ request }) => { return new Response(data, { status: 200, headers: { - 'Content-Type': lookup(format) || '', + 'Content-Type': mime.getType(format) || '', 'Cache-Control': 'public, max-age=31536000', - ETag: etag(inputBuffer), + ETag: etag(data.toString()), Date: new Date().toUTCString(), }, }); } catch (err: unknown) { return new Response(`Server Error: ${err}`, { status: 500 }); } -}; +} diff --git a/packages/integrations/image/src/endpoints/dev.ts b/packages/integrations/image/src/endpoints/dev.ts deleted file mode 100644 index dfa7f4900..000000000 --- a/packages/integrations/image/src/endpoints/dev.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { APIRoute } from 'astro'; -import { lookup } from 'mrmime'; -import loader from '../loaders/sharp.js'; -import { loadImage } from '../utils/images.js'; - -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/index.ts b/packages/integrations/image/src/index.ts index 28df1ec38..03dacdcdd 100644 --- a/packages/integrations/image/src/index.ts +++ b/packages/integrations/image/src/index.ts @@ -1,21 +1,19 @@ 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 { ImageService, TransformOptions } from './loaders/index.js'; -import type { LoggerLevel } from './utils/logger.js'; -import { filenameFormat, propsToFilename } from './utils/paths.js'; import { createPlugin } from './vite-plugin-astro-image.js'; +import { ssgBuild } from './build/ssg.js'; +import type { ImageService, TransformOptions } from './loaders/index.js'; +import type { LoggerLevel } from './utils/logger.js'; +import { joinPaths, prependForwardSlash, propsToFilename } from './utils/paths.js'; export { getImage } from './lib/get-image.js'; export { getPicture } from './lib/get-picture.js'; -export * from './loaders/index.js'; -export type { ImageMetadata } from './vite-plugin-astro-image.js'; + +const PKG_NAME = '@astrojs/image'; +const ROUTE_PATTERN = '/_image'; interface ImageIntegration { loader?: ImageService; - addStaticImage?: (transform: TransformOptions) => void; - filenameFormat?: (transform: TransformOptions, searchParams: URLSearchParams) => string; + addStaticImage?: (transform: TransformOptions) => string; } declare global { @@ -38,12 +36,11 @@ export default function integration(options: IntegrationOptions = {}): AstroInte ...options, }; + let _config: AstroConfig; + // During SSG builds, this is used to track all transformed images required. const staticImages = new Map>(); - let _config: AstroConfig; - let output: 'server' | 'static'; - function getViteConfiguration() { return { plugins: [createPlugin(_config, resolvedOptions)], @@ -59,25 +56,18 @@ export default function integration(options: IntegrationOptions = {}): AstroInte return { name: PKG_NAME, hooks: { - 'astro:config:setup': ({ command, config, injectRoute, updateConfig }) => { + 'astro:config:setup': ({ command, config, updateConfig, injectRoute }) => { _config = config; - // Always treat `astro dev` as SSR mode, even without an adapter - output = command === 'dev' ? 'server' : config.output; - updateConfig({ vite: getViteConfiguration() }); - if (output === 'server') { + if (command === 'dev' || config.output === 'server') { injectRoute({ pattern: ROUTE_PATTERN, - entryPoint: - command === 'dev' ? '@astrojs/image/endpoints/dev' : '@astrojs/image/endpoints/prod', + entryPoint: '@astrojs/image/endpoint', }); } }, - 'astro:server:setup': async ({ server }) => { - 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 @@ -86,26 +76,28 @@ export default function integration(options: IntegrationOptions = {}): AstroInte ? staticImages.get(transform.src)! : new Map(); - srcTranforms.set(propsToFilename(transform), transform); + const filename = propsToFilename(transform); + srcTranforms.set(filename, transform); staticImages.set(transform.src, srcTranforms); + + // Prepend the Astro config's base path, if it was used. + // Doing this here makes sure that base is ignored when building + // staticImages to /dist, but the rendered HTML will include the + // base prefix for `src`. + return prependForwardSlash(joinPaths(_config.base, filename)); } // Helpers for building static images should only be available for SSG globalThis.astroImage = - output === 'static' + _config.output === 'static' ? { addStaticImage, - filenameFormat, } : {}; }, 'astro:build:done': async ({ dir }) => { - if (output === 'server') { - // 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 { + if (_config.output === 'static') { // for SSG builds, build all requested image transforms to dist const loader = globalThis?.astroImage?.loader; @@ -113,13 +105,13 @@ export default function integration(options: IntegrationOptions = {}): AstroInte await ssgBuild({ loader, staticImages, - srcDir: _config.srcDir, + config: _config, outDir: dir, logLevel: resolvedOptions.logLevel, }); } } - }, - }, - }; + } + } + } } diff --git a/packages/integrations/image/src/lib/get-image.ts b/packages/integrations/image/src/lib/get-image.ts index e2fabda55..34f39f144 100644 --- a/packages/integrations/image/src/lib/get-image.ts +++ b/packages/integrations/image/src/lib/get-image.ts @@ -1,10 +1,9 @@ /// -import slash from 'slash'; -import { ROUTE_PATTERN } from '../constants.js'; -import { ImageService, isSSRService, OutputFormat, TransformOptions } from '../loaders/index.js'; +import { isSSRService, parseAspectRatio } from '../loaders/index.js'; import sharp from '../loaders/sharp.js'; -import { isRemoteImage, parseAspectRatio } from '../utils/images.js'; -import { ImageMetadata } from '../vite-plugin-astro-image.js'; +import type { ImageService, OutputFormat, TransformOptions } from '../loaders/index.js'; +import { isRemoteImage } from '../utils/paths.js'; +import type { ImageMetadata } from '../vite-plugin-astro-image.js'; export interface GetImageTransform extends Omit { src: string | ImageMetadata | Promise<{ default: ImageMetadata }>; @@ -96,7 +95,7 @@ async function resolveTransform(input: GetImageTransform): Promise` element. */ -export async function getImage( + export async function getImage( transform: GetImageTransform ): Promise { if (!transform.src) { @@ -132,25 +131,26 @@ export async function getImage( throw new Error('@astrojs/image: loader not found!'); } - // For SSR services, build URLs for the injected route - if (isSSRService(_loader)) { - const { searchParams } = _loader.serializeTransform(resolved); + const { searchParams } = isSSRService(_loader) + ? _loader.serializeTransform(resolved) + : sharp.serializeTransform(resolved); - // cache all images rendered to HTML - if (globalThis.astroImage?.addStaticImage) { - globalThis.astroImage.addStaticImage(resolved); - } + let src: string; - const src = globalThis.astroImage?.filenameFormat - ? globalThis.astroImage.filenameFormat(resolved, searchParams) - : `${ROUTE_PATTERN}?${searchParams.toString()}`; - - return { - ...attributes, - src: slash(src), // Windows compat - }; + if (/^[\/\\]?@astroimage/.test(resolved.src)) { + src = `${resolved.src}?${searchParams.toString()}`; + } else { + searchParams.set('href', resolved.src); + src = `/_image?${searchParams.toString()}`; } - // For hosted services, return the `` attributes as-is - return attributes; + // cache all images rendered to HTML + if (globalThis.astroImage?.addStaticImage) { + src = globalThis.astroImage.addStaticImage(resolved); + } + + return { + ...attributes, + src + }; } diff --git a/packages/integrations/image/src/lib/get-picture.ts b/packages/integrations/image/src/lib/get-picture.ts index 0b9521853..9545add1f 100644 --- a/packages/integrations/image/src/lib/get-picture.ts +++ b/packages/integrations/image/src/lib/get-picture.ts @@ -1,8 +1,8 @@ /// -import { lookup } from 'mrmime'; +import mime from 'mime'; import { extname } from 'node:path'; import { OutputFormat, TransformOptions } from '../loaders/index.js'; -import { parseAspectRatio } from '../utils/images.js'; +import { parseAspectRatio } from '../loaders/index.js'; import { ImageMetadata } from '../vite-plugin-astro-image.js'; import { getImage } from './get-image.js'; @@ -71,7 +71,7 @@ export async function getPicture(params: GetPictureParams): Promise + * + * Ported from https://github.com/tjwebb/fnv-plus/blob/master/index.js + * + * Simplified, optimized and add modified for 52 bit, which provides a larger hash space + * and still making use of Javascript's 53-bit integer space. + */ + export const fnv1a52 = (str: string) => { + const len = str.length + let i = 0, + t0 = 0, + v0 = 0x2325, + t1 = 0, + v1 = 0x8422, + t2 = 0, + v2 = 0x9ce4, + t3 = 0, + v3 = 0xcbf2 + + while (i < len) { + v0 ^= str.charCodeAt(i++) + t0 = v0 * 435 + t1 = v1 * 435 + t2 = v2 * 435 + t3 = v3 * 435 + t2 += v0 << 8 + t3 += v1 << 8 + t1 += t0 >>> 16 + v0 = t0 & 65535 + t2 += t1 >>> 16 + v1 = t1 & 65535 + v3 = (t3 + (t2 >>> 16)) & 65535 + v2 = t2 & 65535 + } + + return ( + (v3 & 15) * 281474976710656 + + v2 * 4294967296 + + v1 * 65536 + + (v0 ^ (v3 >> 4)) + ) +} + +export const etag = (payload: string, weak = false) => { + const prefix = weak ? 'W/"' : '"' + return ( + prefix + fnv1a52(payload).toString(36) + payload.length.toString(36) + '"' + ) +} diff --git a/packages/integrations/image/src/utils/images.ts b/packages/integrations/image/src/utils/images.ts index cc5a26cdc..f9b94b1e8 100644 --- a/packages/integrations/image/src/utils/images.ts +++ b/packages/integrations/image/src/utils/images.ts @@ -1,17 +1,4 @@ import fs from 'node:fs/promises'; -import type { OutputFormat, TransformOptions } from '../loaders/index.js'; - -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 isRemoteImage(src: string) { - return /^http(s?):\/\//.test(src); -} export async function loadLocalImage(src: string | URL) { try { @@ -34,21 +21,3 @@ export async function loadRemoteImage(src: string) { return undefined; } } - -export async function loadImage(src: string) { - return isRemoteImage(src) ? await loadRemoteImage(src) : await loadLocalImage(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); - } -} diff --git a/packages/integrations/image/src/utils/metadata.ts b/packages/integrations/image/src/utils/metadata.ts index 349a37535..1c3bebdf0 100644 --- a/packages/integrations/image/src/utils/metadata.ts +++ b/packages/integrations/image/src/utils/metadata.ts @@ -1,9 +1,10 @@ import sizeOf from 'image-size'; import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import { InputFormat } from '../loaders/index.js'; import { ImageMetadata } from '../vite-plugin-astro-image.js'; -export async function metadata(src: string): Promise { +export async function metadata(src: URL): Promise { const file = await fs.readFile(src); const { width, height, type, orientation } = await sizeOf(file); @@ -14,7 +15,7 @@ export async function metadata(src: string): Promise } return { - src, + src: fileURLToPath(src), width: isPortrait ? height : width, height: isPortrait ? width : height, format: type as InputFormat, diff --git a/packages/integrations/image/src/utils/paths.ts b/packages/integrations/image/src/utils/paths.ts index 8521ac41f..68167f167 100644 --- a/packages/integrations/image/src/utils/paths.ts +++ b/packages/integrations/image/src/utils/paths.ts @@ -1,54 +1,74 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { OUTPUT_DIR } from '../constants.js'; -import type { TransformOptions } from '../loaders/index.js'; -import { isRemoteImage } from './images.js'; -import { shorthash } from './shorthash.js'; +import { OutputFormat, TransformOptions } from "../loaders/index.js"; +import { shorthash } from "./shorthash.js"; + +export function isRemoteImage(src: string) { + return /^http(s?):\/\//.test(src); +} function removeQueryString(src: string) { const index = src.lastIndexOf('?'); return index > 0 ? src.substring(0, index) : src; } -function removeExtname(src: string) { - const ext = path.extname(src); +function extname(src: string, format?: OutputFormat) { + const index = src.lastIndexOf('.'); - if (!ext) { + if (index <= 0) { + return undefined; + } + + return src.substring(index); +} + +function removeExtname(src: string) { + const index = src.lastIndexOf('.'); + + if (index <= 0) { return src; } - const index = src.lastIndexOf(ext); return src.substring(0, index); } -export function ensureDir(dir: string) { - fs.mkdirSync(dir, { recursive: true }); +function basename(src: string) { + return src.replace(/^.*[\\\/]/, ''); } -export function propsToFilename({ src, width, height, format }: TransformOptions) { +export function propsToFilename(transform: TransformOptions) { // strip off the querystring first, then remove the file extension - let filename = removeQueryString(src); - const ext = path.extname(filename); + let filename = removeQueryString(transform.src); + filename = basename(filename); filename = removeExtname(filename); - // for remote images, add a hash of the full URL to dedupe images with the same filename - if (isRemoteImage(src)) { - filename += `-${shorthash(src)}`; - } + const ext = transform.format || extname(transform.src)?.substring(1); - 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; + return `/${filename}_${shorthash(JSON.stringify(transform))}.${ext}`; } -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))); +export function appendForwardSlash(path: string) { + return path.endsWith('/') ? path : path + '/'; +} + +export function prependForwardSlash(path: string) { + return path[0] === '/' ? path : '/' + path; +} + +export function removeTrailingForwardSlash(path: string) { + return path.endsWith('/') ? path.slice(0, path.length - 1) : path; +} + +export function removeLeadingForwardSlash(path: string) { + return path.startsWith('/') ? path.substring(1) : path; +} + +export function trimSlashes(path: string) { + return path.replace(/^\/|\/$/g, ''); +} + +function isString(path: unknown): path is string { + return typeof path === 'string' || path instanceof String; +} + +export function joinPaths(...paths: (string | undefined)[]) { + return paths.filter(isString).map(trimSlashes).join('/'); } diff --git a/packages/integrations/image/src/vite-plugin-astro-image.ts b/packages/integrations/image/src/vite-plugin-astro-image.ts index aefc910bb..8c7448a09 100644 --- a/packages/integrations/image/src/vite-plugin-astro-image.ts +++ b/packages/integrations/image/src/vite-plugin-astro-image.ts @@ -1,10 +1,16 @@ +import { basename, extname, join } from 'node:path'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import type { AstroConfig } from 'astro'; -import { pathToFileURL } from 'node:url'; +import MagicString from 'magic-string'; import type { PluginContext } from 'rollup'; import slash from 'slash'; import type { Plugin, ResolvedConfig } from 'vite'; import type { IntegrationOptions } from './index.js'; import type { InputFormat } from './loaders/index.js'; +import sharp from './loaders/sharp.js'; import { metadata } from './utils/metadata.js'; export interface ImageMetadata { @@ -21,19 +27,6 @@ export function createPlugin(config: AstroConfig, options: Required { + if (req.url?.startsWith('/@astroimage/')) { + const [, id] = req.url.split('/@astroimage/'); + + const url = new URL(id, config.srcDir); + const file = await fs.readFile(url); + + const meta = await metadata(url); + + if (!meta) { + return next(); + } + + const transform = await sharp.parseTransform(url.searchParams); + + if (!transform) { + return next(); + } + + const result = await sharp.transform(file, transform); + + res.setHeader('Content-Type', `image/${result.format}`); + res.setHeader('Cache-Control', 'max-age=360000'); + + const stream = Readable.from(result.data); + return stream.pipe(res); + } + + return next(); + }); + }, + async renderChunk(code) { + const assetUrlRE = /__ASTRO_IMAGE_ASSET__([a-z\d]{8})__(?:_(.*?)__)?/g; + + let match; + let s; + while ((match = assetUrlRE.exec(code))) { + s = s || (s = new MagicString(code)); + const [full, hash, postfix = ''] = match; + + const file = this.getFileName(hash); + const outputFilepath = resolvedConfig.base + file + postfix; + + s.overwrite(match.index, match.index + full.length, outputFilepath); + } + + if (s) { + return { + code: s.toString(), + map: resolvedConfig.build.sourcemap ? s.generateMap({ hires: true }) : null, + }; + } else { + return null; + } + } }; } diff --git a/packages/integrations/image/test/fixtures/basic-image/public/hero.jpg b/packages/integrations/image/test/fixtures/basic-image/public/hero.jpg new file mode 100644 index 000000000..c58aacf66 Binary files /dev/null and b/packages/integrations/image/test/fixtures/basic-image/public/hero.jpg differ diff --git a/packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro b/packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro index f83897ddf..85d028171 100644 --- a/packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro +++ b/packages/integrations/image/test/fixtures/basic-image/src/pages/index.astro @@ -8,6 +8,8 @@ import { Image } from '@astrojs/image/components'; + +

diff --git a/packages/integrations/image/test/fixtures/basic-picture/public/hero.jpg b/packages/integrations/image/test/fixtures/basic-picture/public/hero.jpg new file mode 100644 index 000000000..c58aacf66 Binary files /dev/null and b/packages/integrations/image/test/fixtures/basic-picture/public/hero.jpg differ diff --git a/packages/integrations/image/test/fixtures/basic-picture/src/pages/index.astro b/packages/integrations/image/test/fixtures/basic-picture/src/pages/index.astro index fdaf5b6b9..68db37012 100644 --- a/packages/integrations/image/test/fixtures/basic-picture/src/pages/index.astro +++ b/packages/integrations/image/test/fixtures/basic-picture/src/pages/index.astro @@ -8,6 +8,8 @@ import { Picture } from '@astrojs/image/components'; + +

diff --git a/packages/integrations/image/test/fixtures/with-mdx/astro.config.mjs b/packages/integrations/image/test/fixtures/with-mdx/astro.config.mjs new file mode 100644 index 000000000..91fe6ee06 --- /dev/null +++ b/packages/integrations/image/test/fixtures/with-mdx/astro.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'astro/config'; +import image from '@astrojs/image'; +import mdx from '@astrojs/mdx'; + +// https://astro.build/config +export default defineConfig({ + site: 'http://localhost:3000', + integrations: [image({ logLevel: 'silent' }), mdx()] +}); diff --git a/packages/integrations/image/test/fixtures/with-mdx/package.json b/packages/integrations/image/test/fixtures/with-mdx/package.json new file mode 100644 index 000000000..8aba1aba4 --- /dev/null +++ b/packages/integrations/image/test/fixtures/with-mdx/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/with-mdx", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/image": "workspace:*", + "@astrojs/mdx": "workspace:*", + "@astrojs/node": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/image/test/fixtures/with-mdx/public/favicon.ico b/packages/integrations/image/test/fixtures/with-mdx/public/favicon.ico new file mode 100644 index 000000000..578ad458b Binary files /dev/null and b/packages/integrations/image/test/fixtures/with-mdx/public/favicon.ico differ diff --git a/packages/integrations/image/test/fixtures/with-mdx/public/hero.jpg b/packages/integrations/image/test/fixtures/with-mdx/public/hero.jpg new file mode 100644 index 000000000..c58aacf66 Binary files /dev/null and b/packages/integrations/image/test/fixtures/with-mdx/public/hero.jpg differ diff --git a/packages/integrations/image/test/fixtures/with-mdx/server/server.mjs b/packages/integrations/image/test/fixtures/with-mdx/server/server.mjs new file mode 100644 index 000000000..d7a0a7a40 --- /dev/null +++ b/packages/integrations/image/test/fixtures/with-mdx/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