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