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
6
.changeset/tiny-glasses-play.md
Normal file
|
@ -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
|
|
@ -1,8 +1,7 @@
|
||||||
---
|
---
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import loader from 'virtual:image-loader';
|
import { getImage } from '../dist/index.js';
|
||||||
import { getImage } from '../src/index.js';
|
import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../dist/types';
|
||||||
import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../src/types.js';
|
|
||||||
|
|
||||||
export interface LocalImageProps extends Omit<TransformOptions, 'src'>, Omit<ImageAttributes, 'src' | 'width' | 'height'> {
|
export interface LocalImageProps extends Omit<TransformOptions, 'src'>, Omit<ImageAttributes, 'src' | 'width' | 'height'> {
|
||||||
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
|
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 { loading = "lazy", decoding = "async", ...props } = Astro.props as Props;
|
||||||
|
|
||||||
const attrs = await getImage(loader, props);
|
const attrs = await getImage(props);
|
||||||
---
|
---
|
||||||
|
|
||||||
<img {...attrs} {loading} {decoding} />
|
<img {...attrs} {loading} {decoding} />
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
---
|
---
|
||||||
// @ts-ignore
|
import { getPicture } from '../dist/index.js';
|
||||||
import loader from 'virtual:image-loader';
|
import type { ImageAttributes, ImageMetadata, OutputFormat, PictureAttributes, TransformOptions } from '../dist/types';
|
||||||
import { getPicture } from '../src/get-picture.js';
|
|
||||||
import type { ImageAttributes, ImageMetadata, OutputFormat, PictureAttributes, TransformOptions } from '../src/types.js';
|
|
||||||
|
|
||||||
export interface LocalImageProps extends Omit<PictureAttributes, 'src' | 'width' | 'height'>, Omit<TransformOptions, 'src'>, Pick<ImageAttributes, 'loading' | 'decoding'> {
|
export interface LocalImageProps extends Omit<PictureAttributes, 'src' | 'width' | 'height'>, Omit<TransformOptions, 'src'>, Pick<ImageAttributes, 'loading' | 'decoding'> {
|
||||||
src: ImageMetadata | Promise<{ default: ImageMetadata }>;
|
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 { 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 });
|
||||||
---
|
---
|
||||||
|
|
||||||
<picture {...attrs}>
|
<picture {...attrs}>
|
||||||
|
|
|
@ -54,6 +54,7 @@
|
||||||
"@types/etag": "^1.8.1",
|
"@types/etag": "^1.8.1",
|
||||||
"@types/sharp": "^0.30.4",
|
"@types/sharp": "^0.30.4",
|
||||||
"astro": "workspace:*",
|
"astro": "workspace:*",
|
||||||
"astro-scripts": "workspace:*"
|
"astro-scripts": "workspace:*",
|
||||||
|
"tiny-glob": "^0.2.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
79
packages/integrations/image/src/build/ssg.ts
Normal file
|
@ -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<string, Map<string, TransformOptions>>;
|
||||||
|
srcDir: URL;
|
||||||
|
outDir: URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ssgBuild({
|
||||||
|
loader,
|
||||||
|
staticImages,
|
||||||
|
srcDir,
|
||||||
|
outDir,
|
||||||
|
}: SSGBuildParams) {
|
||||||
|
const inputFiles = new Set<string>();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
29
packages/integrations/image/src/build/ssr.ts
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,9 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { lookup } from 'mrmime';
|
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 }) => {
|
export const get: APIRoute = async ({ request }) => {
|
||||||
const loader = globalThis.astroImage.ssrLoader;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const transform = loader.parseTransform(url.searchParams);
|
const transform = loader.parseTransform(url.searchParams);
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import etag from 'etag';
|
import etag from 'etag';
|
||||||
import { lookup } from 'mrmime';
|
import { lookup } from 'mrmime';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import loader from 'virtual:image-loader';
|
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 }) => {
|
export const get: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
|
@ -14,12 +15,14 @@ export const get: APIRoute = async ({ request }) => {
|
||||||
return new Response('Bad Request', { status: 400 });
|
return new Response('Bad Request', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Can we lean on fs to load local images in SSR prod builds?
|
let inputBuffer: Buffer | undefined = undefined;
|
||||||
const href = isRemoteImage(transform.src)
|
|
||||||
? new URL(transform.src)
|
|
||||||
: new URL(transform.src, url.origin);
|
|
||||||
|
|
||||||
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) {
|
if (!inputBuffer) {
|
||||||
return new Response(`"${transform.src} not found`, { status: 404 });
|
return new Response(`"${transform.src} not found`, { status: 404 });
|
||||||
|
|
|
@ -1,137 +1,5 @@
|
||||||
import type { AstroConfig, AstroIntegration } from 'astro';
|
import integration from './integration.js';
|
||||||
import fs from 'fs/promises';
|
export * from './lib/get-image.js';
|
||||||
import path from 'path';
|
export * from './lib/get-picture.js';
|
||||||
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';
|
|
||||||
|
|
||||||
const createIntegration = (options: IntegrationOptions = {}): AstroIntegration => {
|
export default integration;
|
||||||
const resolvedOptions = {
|
|
||||||
serviceEntryPoint: '@astrojs/image/sharp',
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
|
|
||||||
// During SSG builds, this is used to track all transformed images required.
|
|
||||||
const staticImages = new Map<string, TransformOptions>();
|
|
||||||
|
|
||||||
let _config: AstroConfig;
|
|
||||||
|
|
||||||
function getViteConfiguration() {
|
|
||||||
return {
|
|
||||||
plugins: [createPlugin(_config, resolvedOptions)],
|
|
||||||
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;
|
|
||||||
|
|
93
packages/integrations/image/src/integration.ts
Normal file
|
@ -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<string, Map<string, TransformOptions>>();
|
||||||
|
|
||||||
|
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<string, TransformOptions>();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import slash from 'slash';
|
import slash from 'slash';
|
||||||
import { ROUTE_PATTERN } from './constants.js';
|
import { ROUTE_PATTERN } from '../constants.js';
|
||||||
|
import sharp from '../loaders/sharp.js';
|
||||||
import {
|
import {
|
||||||
ImageAttributes,
|
ImageAttributes,
|
||||||
ImageMetadata,
|
ImageMetadata,
|
||||||
|
@ -7,8 +8,8 @@ import {
|
||||||
isSSRService,
|
isSSRService,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
TransformOptions,
|
TransformOptions,
|
||||||
} from './types.js';
|
} from '../types.js';
|
||||||
import { isRemoteImage, parseAspectRatio } from './utils.js';
|
import { isRemoteImage, parseAspectRatio } from '../utils/images.js';
|
||||||
|
|
||||||
export interface GetImageTransform extends Omit<TransformOptions, 'src'> {
|
export interface GetImageTransform extends Omit<TransformOptions, 'src'> {
|
||||||
src: string | ImageMetadata | Promise<{ default: ImageMetadata }>;
|
src: string | ImageMetadata | Promise<{ default: ImageMetadata }>;
|
||||||
|
@ -97,24 +98,35 @@ async function resolveTransform(input: GetImageTransform): Promise<TransformOpti
|
||||||
/**
|
/**
|
||||||
* Gets the HTML attributes required to build an `<img />` for the transformed image.
|
* Gets the HTML attributes required to build an `<img />` for the transformed image.
|
||||||
*
|
*
|
||||||
* @param loader @type {ImageService} The image service used for transforming images.
|
|
||||||
* @param transform @type {TransformOptions} The transformations requested for the optimized image.
|
* @param transform @type {TransformOptions} The transformations requested for the optimized image.
|
||||||
* @returns @type {ImageAttributes} The HTML attributes to be included on the built `<img />` element.
|
* @returns @type {ImageAttributes} The HTML attributes to be included on the built `<img />` element.
|
||||||
*/
|
*/
|
||||||
export async function getImage(
|
export async function getImage(
|
||||||
loader: ImageService,
|
|
||||||
transform: GetImageTransform
|
transform: GetImageTransform
|
||||||
): Promise<ImageAttributes> {
|
): Promise<ImageAttributes> {
|
||||||
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 resolved = await resolveTransform(transform);
|
||||||
|
|
||||||
const attributes = await loader.getImageAttributes(resolved);
|
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 isLocalImage = !isRemoteImage(resolved.src);
|
||||||
|
|
||||||
const _loader = isDev && isLocalImage ? globalThis.astroImage.ssrLoader : loader;
|
const _loader = isDev && isLocalImage ? sharp : loader;
|
||||||
|
|
||||||
if (!_loader) {
|
if (!_loader) {
|
||||||
throw new Error('@astrojs/image: loader not found!');
|
throw new Error('@astrojs/image: loader not found!');
|
||||||
|
@ -125,11 +137,11 @@ export async function getImage(
|
||||||
const { searchParams } = _loader.serializeTransform(resolved);
|
const { searchParams } = _loader.serializeTransform(resolved);
|
||||||
|
|
||||||
// cache all images rendered to HTML
|
// cache all images rendered to HTML
|
||||||
if (globalThis?.astroImage) {
|
if (globalThis.astroImage?.addStaticImage) {
|
||||||
globalThis.astroImage.addStaticImage(resolved);
|
globalThis.astroImage.addStaticImage(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
const src = globalThis?.astroImage
|
const src = globalThis.astroImage?.filenameFormat
|
||||||
? globalThis.astroImage.filenameFormat(resolved, searchParams)
|
? globalThis.astroImage.filenameFormat(resolved, searchParams)
|
||||||
: `${ROUTE_PATTERN}?${searchParams.toString()}`;
|
: `${ROUTE_PATTERN}?${searchParams.toString()}`;
|
||||||
|
|
|
@ -4,14 +4,12 @@ import { getImage } from './get-image.js';
|
||||||
import {
|
import {
|
||||||
ImageAttributes,
|
ImageAttributes,
|
||||||
ImageMetadata,
|
ImageMetadata,
|
||||||
ImageService,
|
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
TransformOptions,
|
TransformOptions,
|
||||||
} from './types.js';
|
} from '../types.js';
|
||||||
import { parseAspectRatio } from './utils.js';
|
import { parseAspectRatio } from '../utils/images.js';
|
||||||
|
|
||||||
export interface GetPictureParams {
|
export interface GetPictureParams {
|
||||||
loader: ImageService;
|
|
||||||
src: string | ImageMetadata | Promise<{ default: ImageMetadata }>;
|
src: string | ImageMetadata | Promise<{ default: ImageMetadata }>;
|
||||||
widths: number[];
|
widths: number[];
|
||||||
formats: OutputFormat[];
|
formats: OutputFormat[];
|
||||||
|
@ -46,7 +44,15 @@ async function resolveFormats({ src, formats }: GetPictureParams) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPicture(params: GetPictureParams): Promise<GetPictureResult> {
|
export async function getPicture(params: GetPictureParams): Promise<GetPictureResult> {
|
||||||
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);
|
const aspectRatio = await resolveAspectRatio(params);
|
||||||
|
|
||||||
|
@ -57,7 +63,7 @@ export async function getPicture(params: GetPictureParams): Promise<GetPictureRe
|
||||||
async function getSource(format: OutputFormat) {
|
async function getSource(format: OutputFormat) {
|
||||||
const imgs = await Promise.all(
|
const imgs = await Promise.all(
|
||||||
widths.map(async (width) => {
|
widths.map(async (width) => {
|
||||||
const img = await getImage(loader, {
|
const img = await getImage({
|
||||||
src,
|
src,
|
||||||
format,
|
format,
|
||||||
width,
|
width,
|
||||||
|
@ -76,7 +82,7 @@ export async function getPicture(params: GetPictureParams): Promise<GetPictureRe
|
||||||
// always include the original image format
|
// always include the original image format
|
||||||
const allFormats = await resolveFormats(params);
|
const allFormats = await resolveFormats(params);
|
||||||
|
|
||||||
const image = await getImage(loader, {
|
const image = await getImage({
|
||||||
src,
|
src,
|
||||||
width: Math.max(...widths),
|
width: Math.max(...widths),
|
||||||
aspectRatio,
|
aspectRatio,
|
|
@ -1,6 +1,6 @@
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import type { OutputFormat, SSRImageService, TransformOptions } from '../types';
|
import type { OutputFormat, SSRImageService, TransformOptions } from '../types.js';
|
||||||
import { isAspectRatioString, isOutputFormat } from '../utils.js';
|
import { isAspectRatioString, isOutputFormat } from '../utils/images.js';
|
||||||
|
|
||||||
class SharpService implements SSRImageService {
|
class SharpService implements SSRImageService {
|
||||||
async getImageAttributes(transform: TransformOptions) {
|
async getImageAttributes(transform: TransformOptions) {
|
||||||
|
@ -84,6 +84,9 @@ class SharpService implements SSRImageService {
|
||||||
async transform(inputBuffer: Buffer, transform: TransformOptions) {
|
async transform(inputBuffer: Buffer, transform: TransformOptions) {
|
||||||
const sharpImage = sharp(inputBuffer, { failOnError: false });
|
const sharpImage = sharp(inputBuffer, { failOnError: false });
|
||||||
|
|
||||||
|
// always call rotate to adjust for EXIF data orientation
|
||||||
|
sharpImage.rotate();
|
||||||
|
|
||||||
if (transform.width || transform.height) {
|
if (transform.width || transform.height) {
|
||||||
const width = transform.width && Math.round(transform.width);
|
const width = transform.width && Math.round(transform.width);
|
||||||
const height = transform.height && Math.round(transform.height);
|
const height = transform.height && Math.round(transform.height);
|
||||||
|
|
|
@ -3,15 +3,13 @@ export * from './index.js';
|
||||||
|
|
||||||
interface ImageIntegration {
|
interface ImageIntegration {
|
||||||
loader?: ImageService;
|
loader?: ImageService;
|
||||||
ssrLoader: SSRImageService;
|
addStaticImage?: (transform: TransformOptions) => void;
|
||||||
command: 'dev' | 'build';
|
filenameFormat?: (transform: TransformOptions, searchParams: URLSearchParams) => string;
|
||||||
addStaticImage: (transform: TransformOptions) => void;
|
|
||||||
filenameFormat: (transform: TransformOptions, searchParams: URLSearchParams) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
var astroImage: ImageIntegration;
|
var astroImage: ImageIntegration | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InputFormat =
|
export type InputFormat =
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import type { OutputFormat, TransformOptions } from '../types.js';
|
||||||
import { shorthash } from './shorthash.js';
|
|
||||||
import type { OutputFormat, TransformOptions } from './types';
|
|
||||||
|
|
||||||
export function isOutputFormat(value: string): value is OutputFormat {
|
export function isOutputFormat(value: string): value is OutputFormat {
|
||||||
return ['avif', 'jpeg', 'png', 'webp'].includes(value);
|
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);
|
return /^\d*:\d*$/.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureDir(dir: string) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRemoteImage(src: string) {
|
export function isRemoteImage(src: string) {
|
||||||
return /^http(s?):\/\//.test(src);
|
return /^http(s?):\/\//.test(src);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLocalImage(src: string) {
|
export async function loadLocalImage(src: string) {
|
||||||
try {
|
try {
|
||||||
return await fs.promises.readFile(src);
|
return await fs.readFile(src);
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -45,26 +39,6 @@ export async function loadImage(src: string) {
|
||||||
return isRemoteImage(src) ? await loadRemoteImage(src) : await loadLocalImage(src);
|
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']) {
|
export function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) {
|
||||||
if (!aspectRatio) {
|
if (!aspectRatio) {
|
||||||
return undefined;
|
return undefined;
|
|
@ -1,6 +1,6 @@
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import sizeOf from 'image-size';
|
import sizeOf from 'image-size';
|
||||||
import { ImageMetadata, InputFormat } from './types';
|
import { ImageMetadata, InputFormat } from '../types.js';
|
||||||
|
|
||||||
export async function metadata(src: string): Promise<ImageMetadata | undefined> {
|
export async function metadata(src: string): Promise<ImageMetadata | undefined> {
|
||||||
const file = await fs.readFile(src);
|
const file = await fs.readFile(src);
|
40
packages/integrations/image/src/utils/paths.ts
Normal file
|
@ -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))
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,11 +1,10 @@
|
||||||
import type { AstroConfig } from 'astro';
|
import type { AstroConfig } from 'astro';
|
||||||
import fs from 'fs/promises';
|
|
||||||
import type { PluginContext } from 'rollup';
|
import type { PluginContext } from 'rollup';
|
||||||
import slash from 'slash';
|
import slash from 'slash';
|
||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL } from 'url';
|
||||||
import type { Plugin, ResolvedConfig } from 'vite';
|
import type { Plugin, ResolvedConfig } from 'vite';
|
||||||
import { metadata } from './metadata.js';
|
import { metadata } from './utils/metadata.js';
|
||||||
import type { IntegrationOptions } from './types';
|
import type { IntegrationOptions } from './types.js';
|
||||||
|
|
||||||
export function createPlugin(config: AstroConfig, options: Required<IntegrationOptions>): Plugin {
|
export function createPlugin(config: AstroConfig, options: Required<IntegrationOptions>): Plugin {
|
||||||
const filter = (id: string) =>
|
const filter = (id: string) =>
|
||||||
|
@ -60,15 +59,7 @@ export function createPlugin(config: AstroConfig, options: Required<IntegrationO
|
||||||
src: slash(src), // Windows compat
|
src: slash(src), // Windows compat
|
||||||
};
|
};
|
||||||
|
|
||||||
if (resolvedConfig.isProduction) {
|
|
||||||
this.emitFile({
|
|
||||||
fileName: output.src.replace(/^\//, ''),
|
|
||||||
source: await fs.readFile(id),
|
|
||||||
type: 'asset',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return `export default ${JSON.stringify(output)}`;
|
return `export default ${JSON.stringify(output)}`;
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
8
packages/integrations/image/test/fixtures/rotation/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import image from '@astrojs/image';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'http://localhost:3000',
|
||||||
|
integrations: [image()]
|
||||||
|
});
|
10
packages/integrations/image/test/fixtures/rotation/package.json
vendored
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "@test/basic-image",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/image": "workspace:*",
|
||||||
|
"@astrojs/node": "workspace:*",
|
||||||
|
"astro": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
BIN
packages/integrations/image/test/fixtures/rotation/public/favicon.ico
vendored
Normal file
After Width: | Height: | Size: 4.2 KiB |
44
packages/integrations/image/test/fixtures/rotation/server/server.mjs
vendored
Normal file
|
@ -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 <time> warning
|
||||||
|
console.error = () => {};
|
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_0.jpg
vendored
Normal file
After Width: | Height: | Size: 342 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_1.jpg
vendored
Normal file
After Width: | Height: | Size: 339 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_2.jpg
vendored
Normal file
After Width: | Height: | Size: 341 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_3.jpg
vendored
Normal file
After Width: | Height: | Size: 341 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_4.jpg
vendored
Normal file
After Width: | Height: | Size: 340 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_5.jpg
vendored
Normal file
After Width: | Height: | Size: 343 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_6.jpg
vendored
Normal file
After Width: | Height: | Size: 344 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_7.jpg
vendored
Normal file
After Width: | Height: | Size: 344 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Landscape_8.jpg
vendored
Normal file
After Width: | Height: | Size: 344 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_0.jpg
vendored
Normal file
After Width: | Height: | Size: 243 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_1.jpg
vendored
Normal file
After Width: | Height: | Size: 240 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_2.jpg
vendored
Normal file
After Width: | Height: | Size: 241 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_3.jpg
vendored
Normal file
After Width: | Height: | Size: 242 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_4.jpg
vendored
Normal file
After Width: | Height: | Size: 241 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_5.jpg
vendored
Normal file
After Width: | Height: | Size: 246 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_6.jpg
vendored
Normal file
After Width: | Height: | Size: 246 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_7.jpg
vendored
Normal file
After Width: | Height: | Size: 245 KiB |
BIN
packages/integrations/image/test/fixtures/rotation/src/assets/Portrait_8.jpg
vendored
Normal file
After Width: | Height: | Size: 246 KiB |
48
packages/integrations/image/test/fixtures/rotation/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
import { Image } from '@astrojs/image/components';
|
||||||
|
---
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!-- Head Stuff -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Image id='landscape-0' src={import('../assets/Landscape_0.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='landscape-1' src={import('../assets/Landscape_1.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='landscape-2' src={import('../assets/Landscape_2.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='landscape-3' src={import('../assets/Landscape_3.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='landscape-4' src={import('../assets/Landscape_4.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='landscape-5' src={import('../assets/Landscape_5.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='landscape-6' src={import('../assets/Landscape_6.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='landscape-7' src={import('../assets/Landscape_7.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='landscape-8' src={import('../assets/Landscape_8.jpg')} />
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<Image id='portrait-0' src={import('../assets/Portrait_0.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='portrait-1' src={import('../assets/Portrait_1.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='portrait-2' src={import('../assets/Portrait_2.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='portrait-3' src={import('../assets/Portrait_3.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='portrait-4' src={import('../assets/Portrait_4.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='portrait-5' src={import('../assets/Portrait_5.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='portrait-6' src={import('../assets/Portrait_6.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='portrait-7' src={import('../assets/Portrait_7.jpg')} />
|
||||||
|
<br />
|
||||||
|
<Image id='portrait-8' src={import('../assets/Portrait_8.jpg')} />
|
||||||
|
<br />
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -30,7 +30,7 @@ describe('SSG images', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Local images', () => {
|
describe('Local images', () => {
|
||||||
it('includes src, width, and height attributes', () => {
|
it('includes <img> attributes', () => {
|
||||||
const image = $('#social-jpg');
|
const image = $('#social-jpg');
|
||||||
|
|
||||||
expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg');
|
expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg');
|
||||||
|
@ -40,7 +40,7 @@ describe('SSG images', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Inline imports', () => {
|
describe('Inline imports', () => {
|
||||||
it('includes src, width, and height attributes', () => {
|
it('includes <img> attributes', () => {
|
||||||
const image = $('#inline');
|
const image = $('#inline');
|
||||||
|
|
||||||
expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg');
|
expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg');
|
||||||
|
@ -62,7 +62,7 @@ describe('SSG images', function () {
|
||||||
// on the static `src` string
|
// on the static `src` string
|
||||||
const HASH = 'Z1iI4xW';
|
const HASH = 'Z1iI4xW';
|
||||||
|
|
||||||
it('includes src, width, and height attributes', () => {
|
it('includes <img> attributes', () => {
|
||||||
const image = $('#google');
|
const image = $('#google');
|
||||||
|
|
||||||
expect(image.attr('src')).to.equal(
|
expect(image.attr('src')).to.equal(
|
||||||
|
@ -97,7 +97,7 @@ describe('SSG images', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Local images', () => {
|
describe('Local images', () => {
|
||||||
it('includes src, width, and height attributes', () => {
|
it('includes <img> attributes', () => {
|
||||||
const image = $('#social-jpg');
|
const image = $('#social-jpg');
|
||||||
|
|
||||||
const src = image.attr('src');
|
const src = image.attr('src');
|
||||||
|
@ -127,7 +127,7 @@ describe('SSG images', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Local images with inline imports', () => {
|
describe('Local images with inline imports', () => {
|
||||||
it('includes src, width, and height attributes', () => {
|
it('includes <img> attributes', () => {
|
||||||
const image = $('#inline');
|
const image = $('#inline');
|
||||||
|
|
||||||
const src = image.attr('src');
|
const src = image.attr('src');
|
||||||
|
@ -157,7 +157,7 @@ describe('SSG images', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Remote images', () => {
|
describe('Remote images', () => {
|
||||||
it('includes src, width, and height attributes', () => {
|
it('includes <img> attributes', () => {
|
||||||
const image = $('#google');
|
const image = $('#google');
|
||||||
|
|
||||||
const src = image.attr('src');
|
const src = image.attr('src');
|
||||||
|
|
|
@ -1,15 +1,24 @@
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
import sizeOf from 'image-size';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import { loadFixture } from './test-utils.js';
|
import { loadFixture } from './test-utils.js';
|
||||||
import testAdapter from '../../../astro/test/test-adapter.js';
|
import testAdapter from '../../../astro/test/test-adapter.js';
|
||||||
|
|
||||||
describe('SSR images - build', function () {
|
describe('SSR images - build', function () {
|
||||||
let fixture;
|
let fixture;
|
||||||
|
|
||||||
|
function verifyImage(pathname) {
|
||||||
|
const url = new URL('./fixtures/basic-image/dist/client' + pathname, import.meta.url);
|
||||||
|
const dist = fileURLToPath(url);
|
||||||
|
const result = sizeOf(dist);
|
||||||
|
expect(result).not.be.be.undefined;
|
||||||
|
}
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
fixture = await loadFixture({
|
fixture = await loadFixture({
|
||||||
root: './fixtures/basic-image/',
|
root: './fixtures/basic-image/',
|
||||||
adapter: testAdapter(),
|
adapter: testAdapter({ streaming: false }),
|
||||||
experimental: {
|
experimental: {
|
||||||
ssr: true,
|
ssr: true,
|
||||||
},
|
},
|
||||||
|
@ -42,8 +51,7 @@ describe('SSR images - build', function () {
|
||||||
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Track down why the fixture.fetch is failing with the test adapter
|
it('built the optimized image', async () => {
|
||||||
it.skip('built the optimized image', async () => {
|
|
||||||
const app = await fixture.loadTestAdapterApp();
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
|
||||||
const request = new Request('http://example.com/');
|
const request = new Request('http://example.com/');
|
||||||
|
@ -53,13 +61,18 @@ describe('SSR images - build', function () {
|
||||||
|
|
||||||
const image = $('#social-jpg');
|
const image = $('#social-jpg');
|
||||||
|
|
||||||
const res = await fixture.fetch(image.attr('src'));
|
const imgRequest = new Request(`http://example.com${image.attr('src')}`);
|
||||||
|
const imgResponse = await app.render(imgRequest);
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(imgResponse.status).to.equal(200);
|
||||||
expect(res.headers.get('Content-Type')).to.equal('image/jpeg');
|
expect(imgResponse.headers.get('Content-Type')).to.equal('image/jpeg');
|
||||||
|
|
||||||
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes the original images', () => {
|
||||||
|
['/assets/social.jpg', '/assets/social.png', '/assets/blog/introducing-astro.jpg'].map(verifyImage);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Inline imports', () => {
|
describe('Inline imports', () => {
|
||||||
|
|
|
@ -62,6 +62,10 @@ describe('SSG pictures', function () {
|
||||||
verifyImage('_image/assets/social_506x253.webp', { width: 506, height: 253, type: 'webp' });
|
verifyImage('_image/assets/social_506x253.webp', { width: 506, height: 253, type: 'webp' });
|
||||||
verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' });
|
verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dist includes original image', () => {
|
||||||
|
verifyImage('assets/social.jpg', { width: 2024, height: 1012, type: 'jpg' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Inline imports', () => {
|
describe('Inline imports', () => {
|
||||||
|
|
|
@ -1,11 +1,20 @@
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
import sizeOf from 'image-size';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import { loadFixture } from './test-utils.js';
|
import { loadFixture } from './test-utils.js';
|
||||||
import testAdapter from '../../../astro/test/test-adapter.js';
|
import testAdapter from '../../../astro/test/test-adapter.js';
|
||||||
|
|
||||||
describe('SSR pictures - build', function () {
|
describe('SSR pictures - build', function () {
|
||||||
let fixture;
|
let fixture;
|
||||||
|
|
||||||
|
function verifyImage(pathname) {
|
||||||
|
const url = new URL('./fixtures/basic-image/dist/client' + pathname, import.meta.url);
|
||||||
|
const dist = fileURLToPath(url);
|
||||||
|
const result = sizeOf(dist);
|
||||||
|
expect(result).not.be.be.undefined;
|
||||||
|
}
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
fixture = await loadFixture({
|
fixture = await loadFixture({
|
||||||
root: './fixtures/basic-picture/',
|
root: './fixtures/basic-picture/',
|
||||||
|
@ -58,8 +67,7 @@ describe('SSR pictures - build', function () {
|
||||||
expect(image.attr('alt')).to.equal('Social image');
|
expect(image.attr('alt')).to.equal('Social image');
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Track down why the fixture.fetch is failing with the test adapter
|
it('built the optimized image', async () => {
|
||||||
it.skip('built the optimized image', async () => {
|
|
||||||
const app = await fixture.loadTestAdapterApp();
|
const app = await fixture.loadTestAdapterApp();
|
||||||
|
|
||||||
const request = new Request('http://example.com/');
|
const request = new Request('http://example.com/');
|
||||||
|
@ -69,13 +77,18 @@ describe('SSR pictures - build', function () {
|
||||||
|
|
||||||
const image = $('#social-jpg img');
|
const image = $('#social-jpg img');
|
||||||
|
|
||||||
const res = await fixture.fetch(image.attr('src'));
|
const imgRequest = new Request(`http://example.com${image.attr('src')}`);
|
||||||
|
const imgResponse = await app.render(imgRequest);
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(imgResponse.status).to.equal(200);
|
||||||
expect(res.headers.get('Content-Type')).to.equal('image/jpeg');
|
expect(imgResponse.headers.get('Content-Type')).to.equal('image/jpeg');
|
||||||
|
|
||||||
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
// TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes the original images', () => {
|
||||||
|
['/assets/social.jpg', '/assets/social.png', '/assets/blog/introducing-astro.jpg'].map(verifyImage);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Inline imports', () => {
|
describe('Inline imports', () => {
|
||||||
|
|
68
packages/integrations/image/test/rotation.test.js
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import sizeOf from 'image-size';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { loadFixture } from './test-utils.js';
|
||||||
|
|
||||||
|
let fixture;
|
||||||
|
|
||||||
|
describe('Image rotation', function () {
|
||||||
|
before(async () => {
|
||||||
|
fixture = await loadFixture({ root: './fixtures/rotation/' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function verifyImage(pathname, expected) {
|
||||||
|
const url = new URL('./fixtures/rotation/dist/' + pathname, import.meta.url);
|
||||||
|
const dist = fileURLToPath(url);
|
||||||
|
const result = sizeOf(dist);
|
||||||
|
expect(result).to.deep.equal(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('build', () => {
|
||||||
|
let $;
|
||||||
|
let html;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await fixture.build();
|
||||||
|
|
||||||
|
html = await fixture.readFile('/index.html');
|
||||||
|
$ = cheerio.load(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Landscape images', () => {
|
||||||
|
it('includes <img> attributes', () => {
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const image = $(`#landscape-${i}`);
|
||||||
|
|
||||||
|
expect(image.attr('src')).to.equal(`/_image/assets/Landscape_${i}_1800x1200.jpg`);
|
||||||
|
expect(image.attr('width')).to.equal('1800');
|
||||||
|
expect(image.attr('height')).to.equal('1200');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('built the optimized image', () => {
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
verifyImage(`/_image/assets/Landscape_${i}_1800x1200.jpg`, { width: 1800, height: 1200, type: 'jpg' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Portait images', () => {
|
||||||
|
it('includes <img> attributes', () => {
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const image = $(`#portrait-${i}`);
|
||||||
|
|
||||||
|
expect(image.attr('src')).to.equal(`/_image/assets/Portrait_${i}_1200x1800.jpg`);
|
||||||
|
expect(image.attr('width')).to.equal('1200');
|
||||||
|
expect(image.attr('height')).to.equal('1800');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('built the optimized image', () => {
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
verifyImage(`/_image/assets/Portrait_${i}_1200x1800.jpg`, { width: 1200, height: 1800, type: 'jpg' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -2082,6 +2082,7 @@ importers:
|
||||||
mrmime: ^1.0.0
|
mrmime: ^1.0.0
|
||||||
sharp: ^0.30.6
|
sharp: ^0.30.6
|
||||||
slash: ^4.0.0
|
slash: ^4.0.0
|
||||||
|
tiny-glob: ^0.2.9
|
||||||
dependencies:
|
dependencies:
|
||||||
etag: 1.8.1
|
etag: 1.8.1
|
||||||
image-size: 1.0.2
|
image-size: 1.0.2
|
||||||
|
@ -2094,6 +2095,7 @@ importers:
|
||||||
'@types/sharp': 0.30.4
|
'@types/sharp': 0.30.4
|
||||||
astro: link:../../astro
|
astro: link:../../astro
|
||||||
astro-scripts: link:../../../scripts
|
astro-scripts: link:../../../scripts
|
||||||
|
tiny-glob: 0.2.9
|
||||||
|
|
||||||
packages/integrations/image/test/fixtures/basic-image:
|
packages/integrations/image/test/fixtures/basic-image:
|
||||||
specifiers:
|
specifiers:
|
||||||
|
@ -2115,6 +2117,16 @@ importers:
|
||||||
'@astrojs/node': link:../../../../node
|
'@astrojs/node': link:../../../../node
|
||||||
astro: link:../../../../../astro
|
astro: link:../../../../../astro
|
||||||
|
|
||||||
|
packages/integrations/image/test/fixtures/rotation:
|
||||||
|
specifiers:
|
||||||
|
'@astrojs/image': workspace:*
|
||||||
|
'@astrojs/node': workspace:*
|
||||||
|
astro: workspace:*
|
||||||
|
dependencies:
|
||||||
|
'@astrojs/image': link:../../..
|
||||||
|
'@astrojs/node': link:../../../../node
|
||||||
|
astro: link:../../../../../astro
|
||||||
|
|
||||||
packages/integrations/lit:
|
packages/integrations/lit:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@lit-labs/ssr': ^2.2.0
|
'@lit-labs/ssr': ^2.2.0
|
||||||
|
|