diff options
42 files changed, 2211 insertions, 763 deletions
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 +--- + +`<Image />` and `<Picture />` 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'; <Image src={import('../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. +<Image src={socialImage} width={1280} aspectRatio="16:9" /> +``` + ### Remote images Remote images can be transformed with the `<Image />` component. The `<Image />` component needs to know the final dimensions for the `<img />` 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<string, Map<string, TransformOptions>>; - 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<string>(); // 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 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 { createPlugin } from './vite-plugin-astro-image.js'; 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 { 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 { 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<string, Map<string, TransformOptions>>(); - 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<string, TransformOptions>(); - 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 @@ /// <reference types="astro/astro-jsx" /> -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<TransformOptions, 'src'> { src: string | ImageMetadata | Promise<{ default: ImageMetadata }>; @@ -96,7 +95,7 @@ async function resolveTransform(input: GetImageTransform): Promise<TransformOpti * @param transform @type {TransformOptions} The transformations requested for the optimized image. * @returns @type {ImageAttributes} The HTML attributes to be included on the built `<img />` element. */ -export async function getImage( + export async function getImage( transform: GetImageTransform ): Promise<astroHTML.JSX.ImgHTMLAttributes> { 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()}`; + if (/^[\/\\]?@astroimage/.test(resolved.src)) { + src = `${resolved.src}?${searchParams.toString()}`; + } else { + searchParams.set('href', resolved.src); + src = `/_image?${searchParams.toString()}`; + } - return { - ...attributes, - src: slash(src), // Windows compat - }; + // cache all images rendered to HTML + if (globalThis.astroImage?.addStaticImage) { + src = globalThis.astroImage.addStaticImage(resolved); } - // For hosted services, return the `<img />` attributes as-is - return attributes; + 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 @@ /// <reference types="astro/astro-jsx" /> -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<GetPictureRe ); return { - type: lookup(format) || format, + type: mime.getType(format) || format, srcset: imgs.join(','), }; } diff --git a/packages/integrations/image/src/loaders/index.ts b/packages/integrations/image/src/loaders/index.ts index 7681f25d4..58a9924a8 100644 --- a/packages/integrations/image/src/loaders/index.ts +++ b/packages/integrations/image/src/loaders/index.ts @@ -12,6 +12,28 @@ export type InputFormat = export type OutputFormat = 'avif' | 'jpeg' | 'png' | 'webp'; +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 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); + } +} + /** * Defines the original image and transforms that need to be applied to it. */ diff --git a/packages/integrations/image/src/loaders/sharp.ts b/packages/integrations/image/src/loaders/sharp.ts index 2368e43d1..4e7b3f104 100644 --- a/packages/integrations/image/src/loaders/sharp.ts +++ b/packages/integrations/image/src/loaders/sharp.ts @@ -1,5 +1,5 @@ import sharp from 'sharp'; -import { isAspectRatioString, isOutputFormat } from '../utils/images.js'; +import { isAspectRatioString, isOutputFormat } from '../loaders/index.js'; import type { OutputFormat, SSRImageService, TransformOptions } from './index.js'; class SharpService implements SSRImageService { @@ -37,16 +37,10 @@ class SharpService implements SSRImageService { searchParams.append('ar', transform.aspectRatio.toString()); } - searchParams.append('href', transform.src); - return { searchParams }; } parseTransform(searchParams: URLSearchParams) { - if (!searchParams.has('href')) { - return undefined; - } - let transform: TransformOptions = { src: searchParams.get('href')! }; if (searchParams.has('q')) { diff --git a/packages/integrations/image/src/utils/etag.ts b/packages/integrations/image/src/utils/etag.ts new file mode 100644 index 000000000..1e9096c7a --- /dev/null +++ b/packages/integrations/image/src/utils/etag.ts @@ -0,0 +1,51 @@ +/** + * FNV-1a Hash implementation + * @author Travis Webb (tjwebb) <me@traviswebb.com> + * + * 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<ImageMetadata | undefined> { +export async function metadata(src: URL): Promise<ImageMetadata | undefined> { 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<ImageMetadata | undefined> } 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 extname(src: string, format?: OutputFormat) { + const index = src.lastIndexOf('.'); + + if (index <= 0) { + return undefined; + } + + return src.substring(index); +} + function removeExtname(src: string) { - const ext = path.extname(src); + const index = src.lastIndexOf('.'); - if (!ext) { + 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 `/${filename}_${shorthash(JSON.stringify(transform))}.${ext}`; +} + +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, ''); +} - return format ? src.replace(ext, format) : src; +function isString(path: unknown): path is string { + return typeof path === 'string' || path instanceof String; } -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 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<IntegrationO const virtualModuleId = 'virtual:image-loader'; let resolvedConfig: ResolvedConfig; - let loaderModuleId: string; - - async function resolveLoader(context: PluginContext) { - if (!loaderModuleId) { - const module = await context.resolve(options.serviceEntryPoint); - if (!module) { - throw new Error(`"${options.serviceEntryPoint}" could not be found`); - } - loaderModuleId = module.id; - } - - return loaderModuleId; - } return { name: '@astrojs/image', @@ -46,7 +39,7 @@ export function createPlugin(config: AstroConfig, options: Required<IntegrationO // This ensures the module is available in `astro dev` and is included // in the SSR server bundle. if (id === virtualModuleId) { - return await resolveLoader(this); + return await this.resolve(options.serviceEntryPoint); } }, async load(id) { @@ -55,19 +48,90 @@ export function createPlugin(config: AstroConfig, options: Required<IntegrationO return null; } - const meta = await metadata(id); + const url = pathToFileURL(id); + + const meta = await metadata(url); + + if (!meta) { + return; + } + + if (!this.meta.watchMode) { + const filename = basename(url.pathname, extname(url.pathname)) + `.${meta.format}`; - const fileUrl = pathToFileURL(id); - const src = resolvedConfig.isProduction - ? fileUrl.pathname.replace(config.srcDir.pathname, '/') - : id; + const handle = this.emitFile({ + name: filename, + source: await fs.readFile(url), + type: 'asset', + }); - const output = { - ...meta, - src: slash(src), // Windows compat - }; + meta.src = `__ASTRO_IMAGE_ASSET__${handle}__`; + } else { + const relId = path.relative(fileURLToPath(config.srcDir), id); - return `export default ${JSON.stringify(output)}`; + meta.src = join('/@astroimage', relId); + + // Windows compat + meta.src = slash(meta.src); + } + + return `export default ${JSON.stringify(meta)}`; }, + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + 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 Binary files differnew file mode 100644 index 000000000..c58aacf66 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-image/public/hero.jpg 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'; <!-- Head Stuff --> </head> <body> + <Image id="hero" src="/hero.jpg" width={768} height={414} format="webp" /> + <br /> <Image id="social-jpg" src={socialJpg} width={506} height={253} /> <br /> <Image id="google" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" width={544} height={184} format="webp" /> diff --git a/packages/integrations/image/test/fixtures/basic-picture/public/hero.jpg b/packages/integrations/image/test/fixtures/basic-picture/public/hero.jpg Binary files differnew file mode 100644 index 000000000..c58aacf66 --- /dev/null +++ b/packages/integrations/image/test/fixtures/basic-picture/public/hero.jpg 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'; <!-- Head Stuff --> </head> <body> + <Picture id="hero" src="/hero.jpg" sizes="100vw" widths={[384, 768]} aspectRatio={768/414} alt="Hero image" /> + <br /> <Picture id="social-jpg" src={socialJpg} sizes="(min-width: 640px) 50vw, 100vw" widths={[253, 506]} alt="Social image" /> <br /> <Picture id="google" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" sizes="(min-width: 640px) 50vw, 100vw" widths={[272, 544]} aspectRatio={544/184} alt="Google logo" /> 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 Binary files differnew file mode 100644 index 000000000..578ad458b --- /dev/null +++ b/packages/integrations/image/test/fixtures/with-mdx/public/favicon.ico diff --git a/packages/integrations/image/test/fixtures/with-mdx/public/hero.jpg b/packages/integrations/image/test/fixtures/with-mdx/public/hero.jpg Binary files differnew file mode 100644 index 000000000..c58aacf66 --- /dev/null +++ b/packages/integrations/image/test/fixtures/with-mdx/public/hero.jpg 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 <time> warning +console.error = () => {}; diff --git a/packages/integrations/image/test/fixtures/with-mdx/src/assets/blog/introducing-astro.jpg b/packages/integrations/image/test/fixtures/with-mdx/src/assets/blog/introducing-astro.jpg Binary files differnew file mode 100644 index 000000000..c58aacf66 --- /dev/null +++ b/packages/integrations/image/test/fixtures/with-mdx/src/assets/blog/introducing-astro.jpg diff --git a/packages/integrations/image/test/fixtures/with-mdx/src/assets/social.jpg b/packages/integrations/image/test/fixtures/with-mdx/src/assets/social.jpg Binary files differnew file mode 100644 index 000000000..906c76144 --- /dev/null +++ b/packages/integrations/image/test/fixtures/with-mdx/src/assets/social.jpg diff --git a/packages/integrations/image/test/fixtures/with-mdx/src/assets/social.png b/packages/integrations/image/test/fixtures/with-mdx/src/assets/social.png Binary files differnew file mode 100644 index 000000000..1399856f1 --- /dev/null +++ b/packages/integrations/image/test/fixtures/with-mdx/src/assets/social.png diff --git a/packages/integrations/image/test/fixtures/with-mdx/src/layouts/Base.astro b/packages/integrations/image/test/fixtures/with-mdx/src/layouts/Base.astro new file mode 100644 index 000000000..c0c286cd5 --- /dev/null +++ b/packages/integrations/image/test/fixtures/with-mdx/src/layouts/Base.astro @@ -0,0 +1,8 @@ +<html> + <head> + <!-- Head Stuff --> + </head> + <body> + <slot /> + </body> +</html> diff --git a/packages/integrations/image/test/fixtures/with-mdx/src/pages/index.mdx b/packages/integrations/image/test/fixtures/with-mdx/src/pages/index.mdx new file mode 100644 index 000000000..a713f4418 --- /dev/null +++ b/packages/integrations/image/test/fixtures/with-mdx/src/pages/index.mdx @@ -0,0 +1,17 @@ +--- +layout: '../layouts/Base.astro' +--- + +import socialJpg from '../assets/social.jpg'; +import { Image } from '@astrojs/image/components'; + + +<Image id="hero" src="/hero.jpg" width={768} height={414} format="webp" /> +<br /> +<Image id="social-jpg" src={socialJpg} width={506} height={253} /> +<br /> +<Image id="google" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" width={544} height={184} format="webp" /> +<br /> +<Image id="inline" src={import('../assets/social.jpg')} width={506} /> +<br /> +<Image id="query" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc" width={544} height={184} format="webp" /> diff --git a/packages/integrations/image/test/image-ssg.test.js b/packages/integrations/image/test/image-ssg.test.js index 5b1b65b49..6d254e089 100644 --- a/packages/integrations/image/test/image-ssg.test.js +++ b/packages/integrations/image/test/image-ssg.test.js @@ -4,203 +4,368 @@ import sizeOf from 'image-size'; import { fileURLToPath } from 'url'; import { loadFixture } from './test-utils.js'; -let fixture; - -describe('SSG images', function () { +describe('SSG images - dev', function () { + let fixture; + let devServer; + let $; + before(async () => { fixture = await loadFixture({ root: './fixtures/basic-image/' }); + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/').then((res) => res.text()); + $ = cheerio.load(html); }); - function verifyImage(pathname, expected) { - const url = new URL('./fixtures/basic-image/dist/' + pathname, import.meta.url); - const dist = fileURLToPath(url); - const result = sizeOf(dist); - expect(result).to.deep.equal(expected); - } + after(async () => { + await devServer.stop(); + }); - describe('build', () => { - let $; - let html; + describe('Local images', () => { + it('includes <img> attributes', () => { + const image = $('#social-jpg'); - before(async () => { - await fixture.build(); + const src = image.attr('src'); + const [route, params] = src.split('?'); - html = await fixture.readFile('/index.html'); - $ = cheerio.load(html); - }); + expect(route).to.equal('/@astroimage/assets/social.jpg'); - describe('Local images', () => { - it('includes <img> attributes', () => { - const image = $('#social-jpg'); + const searchParams = new URLSearchParams(params); - expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg'); - expect(image.attr('width')).to.equal('506'); - expect(image.attr('height')).to.equal('253'); - }); + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); }); + }); - describe('Inline imports', () => { - it('includes <img> attributes', () => { - const image = $('#inline'); + describe('Local images with inline imports', () => { + it('includes <img> attributes', () => { + const image = $('#inline'); - expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg'); - expect(image.attr('width')).to.equal('506'); - expect(image.attr('height')).to.equal('253'); - }); + const src = image.attr('src'); + const [route, params] = src.split('?'); - it('built the optimized image', () => { - verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' }); - }); + expect(route).to.equal('/@astroimage/assets/social.jpg'); - it('dist includes original image', () => { - verifyImage('assets/social.jpg', { width: 2024, height: 1012, type: 'jpg' }); - }); + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); }); + }); - describe('Remote images', () => { - // Hard-coding in the test! These should never change since the hash is based - // on the static `src` string - const HASH = 'Z1iI4xW'; - const HASH_WITH_QUERY = '18Aq0m'; + describe('Remote images', () => { + it('includes <img> attributes', () => { + const image = $('#google'); - it('includes <img> attributes', () => { - const image = $('#google'); + const src = image.attr('src'); + const [route, params] = src.split('?'); - expect(image.attr('src')).to.equal( - `/_image/googlelogo_color_272x92dp-${HASH}_544x184.webp` - ); - expect(image.attr('width')).to.equal('544'); - expect(image.attr('height')).to.equal('184'); - }); + expect(route).to.equal('/_image'); - it('built the optimized image', () => { - verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_544x184.webp`, { - width: 544, - height: 184, - type: 'webp', - }); - }); + const searchParams = new URLSearchParams(params); - it('removes query strings', () => { - verifyImage(`_image/googlelogo_color_272x92dp-${HASH_WITH_QUERY}_544x184.webp`, { - width: 544, - height: 184, - type: 'webp', - }); - }); + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal('https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png'); }); }); - describe('dev', () => { - let devServer; - let $; + describe('/public images', () => { + it('includes <img> attributes', () => { + const image = $('#hero'); - before(async () => { - devServer = await fixture.startDevServer(); - const html = await fixture.fetch('/').then((res) => res.text()); + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('href')).to.equal('/hero.jpg'); + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + }); + }); +}); + +describe('SSG images with subpath - dev', function () { + let fixture; + let devServer; + let $; + + before(async () => { + fixture = await loadFixture({ root: './fixtures/basic-image/', base: '/docs' }); + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/docs/').then((res) => res.text()); $ = cheerio.load(html); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Local images', () => { + it('includes <img> attributes', () => { + const image = $('#social-jpg'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); }); + }); + + describe('Local images with inline imports', () => { + it('includes <img> attributes', () => { + const image = $('#inline'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); - after(async () => { - await devServer.stop(); + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); }); + }); - describe('Local images', () => { - it('includes <img> attributes', () => { - const image = $('#social-jpg'); + describe('Remote images', () => { + it('includes <img> attributes', () => { + const image = $('#google'); - const src = image.attr('src'); - const [route, params] = src.split('?'); + const src = image.attr('src'); + const [route, params] = src.split('?'); - expect(route).to.equal('/_image'); + expect(route).to.equal('/_image'); - const searchParams = new URLSearchParams(params); + const searchParams = new URLSearchParams(params); - expect(searchParams.get('f')).to.equal('jpg'); - expect(searchParams.get('w')).to.equal('506'); - expect(searchParams.get('h')).to.equal('253'); - // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); - }); + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal('https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png'); + }); + }); - it('returns the optimized image', async () => { - const image = $('#social-jpg'); + describe('/public images', () => { + it('includes <img> attributes', () => { + const image = $('#hero'); - const res = await fixture.fetch(image.attr('src')); + const src = image.attr('src'); + const [route, params] = src.split('?'); - expect(res.status).to.equal(200); - expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + expect(route).to.equal('/_image'); - // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers - }); + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('href')).to.equal('/hero.jpg'); + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + }); + }); +}); + +describe('SSG images - build', function () { + let fixture; + let $; + let html; + + before(async () => { + fixture = await loadFixture({ root: './fixtures/basic-image/' }); + await fixture.build(); + + html = await fixture.readFile('/index.html'); + $ = cheerio.load(html); + }); + + function verifyImage(pathname, expected) { + const url = new URL('./fixtures/basic-image/dist/' + pathname, import.meta.url); + const dist = fileURLToPath(url); + const result = sizeOf(dist); + expect(result).to.deep.equal(expected); + } + + describe('Local images', () => { + it('includes <img> attributes', () => { + const image = $('#social-jpg'); + + expect(image.attr('src')).to.equal('/social.cece8c77_1zwatP.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); }); - describe('Local images with inline imports', () => { - it('includes <img> attributes', () => { - const image = $('#inline'); + it('built the optimized image', () => { + verifyImage('social.cece8c77_1zwatP.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); - const src = image.attr('src'); - const [route, params] = src.split('?'); + describe('Inline imports', () => { + it('includes <img> attributes', () => { + const image = $('#inline'); - expect(route).to.equal('/_image'); + expect(image.attr('src')).to.equal('/social.cece8c77_Z2tF99.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + }); - const searchParams = new URLSearchParams(params); + it('built the optimized image', () => { + verifyImage('social.cece8c77_Z2tF99.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); - expect(searchParams.get('f')).to.equal('jpg'); - expect(searchParams.get('w')).to.equal('506'); - expect(searchParams.get('h')).to.equal('253'); - // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); - }); + describe('Remote images', () => { + // Hard-coding in the test! These should never change since the hash is based + // on the static `src` string + const HASH = 'Z1RBHqs'; + const HASH_WITH_QUERY = 'Z17oujH'; - it('returns the optimized image', async () => { - const image = $('#inline'); + it('includes <img> attributes', () => { + const image = $('#google'); - const res = await fixture.fetch(image.attr('src')); + expect(image.attr('src')).to.equal( + `/googlelogo_color_272x92dp_${HASH}.webp` + ); + expect(image.attr('width')).to.equal('544'); + expect(image.attr('height')).to.equal('184'); + }); - expect(res.status).to.equal(200); - expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + it('built the optimized image', () => { + verifyImage(`/googlelogo_color_272x92dp_${HASH}.webp`, { + width: 544, + height: 184, + type: 'webp', + }); + }); - // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + it('removes query strings', () => { + verifyImage(`/googlelogo_color_272x92dp_${HASH_WITH_QUERY}.webp`, { + width: 544, + height: 184, + type: 'webp', }); }); + }); - describe('Remote images', () => { - it('includes <img> attributes', () => { - const image = $('#google'); + describe('/public images', () => { + it('includes <img> attributes', () => { + const image = $('#hero'); - const src = image.attr('src'); - const [route, params] = src.split('?'); + expect(image.attr('src')).to.equal('/hero_Z2k1JGN.webp'); + expect(image.attr('width')).to.equal('768'); + expect(image.attr('height')).to.equal('414'); + }); - expect(route).to.equal('/_image'); + it('built the optimized image', () => { + verifyImage('hero_Z2k1JGN.webp', { width: 768, height: 414, type: 'webp' }); + }); + }); +}); - const searchParams = new URLSearchParams(params); +describe('SSG images with subpath - build', function () { + let fixture; + let $; + let html; - expect(searchParams.get('f')).to.equal('webp'); - expect(searchParams.get('w')).to.equal('544'); - expect(searchParams.get('h')).to.equal('184'); - expect(searchParams.get('href')).to.equal( - 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' - ); - }); + before(async () => { + fixture = await loadFixture({ root: './fixtures/basic-image/', base: '/docs' }); + await fixture.build(); - it('keeps remote image query params', () => { - const image = $('#query'); + html = await fixture.readFile('/index.html'); + $ = cheerio.load(html); + }); - const src = image.attr('src'); - const [route, params] = src.split('?'); + function verifyImage(pathname, expected) { + const url = new URL('./fixtures/basic-image/dist/' + pathname, import.meta.url); + const dist = fileURLToPath(url); + const result = sizeOf(dist); + expect(result).to.deep.equal(expected); + } + + describe('Local images', () => { + it('includes <img> attributes', () => { + const image = $('#social-jpg'); + + expect(image.attr('src')).to.equal('/docs/social.cece8c77_iK4oy.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + }); + + it('built the optimized image', () => { + verifyImage('social.cece8c77_iK4oy.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); + + describe('Inline imports', () => { + it('includes <img> attributes', () => { + const image = $('#inline'); + + expect(image.attr('src')).to.equal('/docs/social.cece8c77_1YIUw1.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + }); - expect(route).to.equal('/_image'); + it('built the optimized image', () => { + verifyImage('social.cece8c77_1YIUw1.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); + + describe('Remote images', () => { + // Hard-coding in the test! These should never change since the hash is based + // on the static `src` string + const HASH = 'Z1RBHqs'; + const HASH_WITH_QUERY = 'Z17oujH'; + + it('includes <img> attributes', () => { + const image = $('#google'); - const searchParams = new URLSearchParams(params); + expect(image.attr('src')).to.equal( + `/docs/googlelogo_color_272x92dp_${HASH}.webp` + ); + expect(image.attr('width')).to.equal('544'); + expect(image.attr('height')).to.equal('184'); + }); + + it('built the optimized image', () => { + verifyImage(`/googlelogo_color_272x92dp_${HASH}.webp`, { + width: 544, + height: 184, + type: 'webp', + }); + }); - expect(searchParams.get('f')).to.equal('webp'); - expect(searchParams.get('w')).to.equal('544'); - expect(searchParams.get('h')).to.equal('184'); - expect(searchParams.get('href')).to.equal( - 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc' - ); + it('removes query strings', () => { + verifyImage(`/googlelogo_color_272x92dp_${HASH_WITH_QUERY}.webp`, { + width: 544, + height: 184, + type: 'webp', }); }); }); + + describe('/public images', () => { + it('includes <img> attributes', () => { + const image = $('#hero'); + + expect(image.attr('src')).to.equal('/docs/hero_Z2k1JGN.webp'); + expect(image.attr('width')).to.equal('768'); + expect(image.attr('height')).to.equal('414'); + }); + + it('built the optimized image', () => { + verifyImage('hero_Z2k1JGN.webp', { width: 768, height: 414, type: 'webp' }); + }); + }); }); diff --git a/packages/integrations/image/test/image-ssr.test.js b/packages/integrations/image/test/image-ssr-build.test.js index 10ac32a37..e2303ab4a 100644 --- a/packages/integrations/image/test/image-ssr.test.js +++ b/packages/integrations/image/test/image-ssr-build.test.js @@ -1,25 +1,16 @@ import { expect } from 'chai'; import * as cheerio from 'cheerio'; -import sizeOf from 'image-size'; -import { fileURLToPath } from 'url'; import { loadFixture } from './test-utils.js'; import testAdapter from '../../../astro/test/test-adapter.js'; describe('SSR images - build', function () { 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 () => { fixture = await loadFixture({ root: './fixtures/basic-image/', adapter: testAdapter({ streaming: false }), - output: 'server', + output: 'server' }); await fixture.build(); }); @@ -46,32 +37,7 @@ describe('SSR images - build', function () { expect(searchParams.get('w')).to.equal('506'); expect(searchParams.get('h')).to.equal('253'); // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); - }); - - it('built the optimized image', async () => { - const app = await fixture.loadTestAdapterApp(); - - const request = new Request('http://example.com/'); - const response = await app.render(request); - const html = await response.text(); - const $ = cheerio.load(html); - - const image = $('#social-jpg'); - - const imgRequest = new Request(`http://example.com${image.attr('src')}`); - const imgResponse = await app.render(imgRequest); - - expect(imgResponse.status).to.equal(200); - expect(imgResponse.headers.get('Content-Type')).to.equal('image/jpeg'); - - // 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 - ); + expect(searchParams.get('href')).to.equal('/assets/social.cece8c77.jpg'); }); }); @@ -97,7 +63,7 @@ describe('SSR images - build', function () { expect(searchParams.get('w')).to.equal('506'); expect(searchParams.get('h')).to.equal('253'); // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + expect(searchParams.get('href')).to.equal('/assets/social.cece8c77.jpg'); }); }); @@ -122,7 +88,9 @@ describe('SSR images - build', function () { expect(searchParams.get('f')).to.equal('webp'); expect(searchParams.get('w')).to.equal('544'); expect(searchParams.get('h')).to.equal('184'); - expect(searchParams.get('href').endsWith('googlelogo_color_272x92dp.png')).to.equal(true); + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' + ); }); it('keeps remote image query params', async () => { @@ -145,36 +113,61 @@ describe('SSR images - build', function () { expect(searchParams.get('f')).to.equal('webp'); expect(searchParams.get('w')).to.equal('544'); expect(searchParams.get('h')).to.equal('184'); - expect(searchParams.get('href').endsWith('googlelogo_color_272x92dp.png?token=abc')).to.equal( - true + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc' ); }); }); + + describe('/public images', () => { + it('includes src, width, and height attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const image = $('#hero'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href')).to.equal('/hero.jpg'); + }); + }); }); -describe('SSR images - dev', function () { +describe('SSR images with subpath - build', function () { let fixture; - let devServer; - let $; before(async () => { fixture = await loadFixture({ root: './fixtures/basic-image/', - adapter: testAdapter(), + adapter: testAdapter({ streaming: false }), output: 'server', + base: '/docs' }); - - devServer = await fixture.startDevServer(); - const html = await fixture.fetch('/').then((res) => res.text()); - $ = cheerio.load(html); - }); - - after(async () => { - await devServer.stop(); + await fixture.build(); }); describe('Local images', () => { - it('includes src, width, and height attributes', () => { + it('includes src, width, and height attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + const image = $('#social-jpg'); const src = image.attr('src'); @@ -188,23 +181,19 @@ describe('SSR images - dev', function () { expect(searchParams.get('w')).to.equal('506'); expect(searchParams.get('h')).to.equal('253'); // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); - }); - - it('returns the optimized image', async () => { - const image = $('#social-jpg'); - - const res = await fixture.fetch(image.attr('src')); - - expect(res.status).to.equal(200); - expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); - - // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + expect(searchParams.get('href')).to.equal('/docs/assets/social.cece8c77.jpg'); }); }); describe('Inline imports', () => { - it('includes src, width, and height attributes', () => { + it('includes src, width, and height attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + const image = $('#inline'); const src = image.attr('src'); @@ -218,12 +207,19 @@ describe('SSR images - dev', function () { expect(searchParams.get('w')).to.equal('506'); expect(searchParams.get('h')).to.equal('253'); // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + expect(searchParams.get('href')).to.equal('/docs/assets/social.cece8c77.jpg'); }); }); describe('Remote images', () => { - it('includes src, width, and height attributes', () => { + it('includes src, width, and height attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + const image = $('#google'); const src = image.attr('src'); @@ -236,12 +232,17 @@ describe('SSR images - dev', function () { expect(searchParams.get('f')).to.equal('webp'); expect(searchParams.get('w')).to.equal('544'); expect(searchParams.get('h')).to.equal('184'); - expect(searchParams.get('href')).to.equal( - 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' - ); + expect(searchParams.get('href')).to.equal('https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png'); }); - it('keeps remote image query params', () => { + it('keeps remote image query params', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + const image = $('#query'); const src = image.attr('src'); @@ -259,4 +260,30 @@ describe('SSR images - dev', function () { ); }); }); + + describe('/public images', () => { + it('includes src, width, and height attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const image = $('#hero'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + // TODO: possible to avoid encoding the full image path? + expect(searchParams.get('href')).to.equal('/hero.jpg'); + }); + }); }); diff --git a/packages/integrations/image/test/image-ssr-dev.test.js b/packages/integrations/image/test/image-ssr-dev.test.js new file mode 100644 index 000000000..62a92a53c --- /dev/null +++ b/packages/integrations/image/test/image-ssr-dev.test.js @@ -0,0 +1,273 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; +import testAdapter from '../../../astro/test/test-adapter.js'; + +describe('SSR images - dev', function () { + let fixture; + let devServer; + let $; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/basic-image/', + adapter: testAdapter(), + output: 'server', + }); + + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/').then((res) => res.text()); + $ = cheerio.load(html); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Local images', () => { + it('includes src, width, and height attributes', () => { + const image = $('#social-jpg'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + }); + + it('returns the optimized image', async () => { + const image = $('#social-jpg'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); + + describe('Inline imports', () => { + it('includes src, width, and height attributes', () => { + const image = $('#inline'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + }); + }); + + describe('Remote images', () => { + it('includes src, width, and height attributes', () => { + const image = $('#google'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' + ); + }); + + it('keeps remote image query params', () => { + const image = $('#query'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc' + ); + }); + }); + + describe('/public images', () => { + it('includes src, width, and height attributes', () => { + const image = $('#hero'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + expect(searchParams.get('href')).to.equal('/hero.jpg'); + }); + + it('returns the optimized image', async () => { + const image = $('#hero'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/webp'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); +}); + +describe('SSR images with subpath - dev', function () { + let fixture; + let devServer; + let $; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/basic-image/', + adapter: testAdapter(), + output: 'server', + base: '/docs' + }); + + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/docs/').then((res) => res.text()); + $ = cheerio.load(html); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Local images', () => { + it('includes src, width, and height attributes', () => { + const image = $('#social-jpg'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + }); + + it('returns the optimized image', async () => { + const image = $('#social-jpg'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); + + describe('Inline imports', () => { + it('includes src, width, and height attributes', () => { + const image = $('#inline'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + }); + }); + + describe('Remote images', () => { + it('includes src, width, and height attributes', () => { + const image = $('#google'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' + ); + }); + + it('keeps remote image query params', () => { + const image = $('#query'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc' + ); + }); + }); + + describe('/public images', () => { + it('includes src, width, and height attributes', () => { + const image = $('#hero'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('webp'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + expect(searchParams.get('href')).to.equal('/hero.jpg'); + }); + + it('returns the optimized image', async () => { + const image = $('#hero'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/webp'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); +}); diff --git a/packages/integrations/image/test/picture-ssg.test.js b/packages/integrations/image/test/picture-ssg.test.js index d8719af29..d23e72700 100644 --- a/packages/integrations/image/test/picture-ssg.test.js +++ b/packages/integrations/image/test/picture-ssg.test.js @@ -5,11 +5,261 @@ import sizeOf from 'image-size'; import { fileURLToPath } from 'url'; import { loadFixture } from './test-utils.js'; -let fixture; +describe('SSG pictures - dev', function () { + let fixture; + let devServer; + let $; -describe('SSG pictures', function () { before(async () => { fixture = await loadFixture({ root: './fixtures/basic-picture/' }); + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/').then((res) => res.text()); + $ = cheerio.load(html); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Local images', () => { + it('includes sources', () => { + const sources = $('#social-jpg source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes <img> attributes', () => { + const image = $('#social-jpg img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + expect(image.attr('alt')).to.equal('Social image'); + }); + }); + + describe('Local images with inline imports', () => { + it('includes sources', () => { + const sources = $('#inline source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes <img> attributes', () => { + const image = $('#inline img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + expect(image.attr('alt')).to.equal('Inline social image'); + }); + }); + + describe('Remote images', () => { + it('includes sources', () => { + const sources = $('#google source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes <img> attributes', () => { + const image = $('#google img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('png'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal('https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png'); + expect(image.attr('alt')).to.equal('Google logo'); + }); + }); + + describe('/public images', () => { + it('includes sources', () => { + const sources = $('#hero source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes <img> attributes', () => { + const image = $('#hero img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + expect(image.attr('alt')).to.equal('Hero image'); + }); + }); +}); + +describe('SSG pictures with subpath - dev', function () { + let fixture; + let devServer; + let $; + + before(async () => { + fixture = await loadFixture({ root: './fixtures/basic-picture/', base: '/docs' }); + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/docs/').then((res) => res.text()); + $ = cheerio.load(html); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Local images', () => { + it('includes sources', () => { + const sources = $('#social-jpg source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes <img> attributes', () => { + const image = $('#social-jpg img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + expect(image.attr('alt')).to.equal('Social image'); + }); + }); + + describe('Local images with inline imports', () => { + it('includes sources', () => { + const sources = $('#inline source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes <img> attributes', () => { + const image = $('#inline img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + expect(image.attr('alt')).to.equal('Inline social image'); + }); + }); + + describe('Remote images', () => { + it('includes sources', () => { + const sources = $('#google source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes <img> attributes', () => { + const image = $('#google img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('png'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal('https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png'); + expect(image.attr('alt')).to.equal('Google logo'); + }); + }); + + describe('/public images', () => { + it('includes sources', () => { + const sources = $('#hero source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes <img> attributes', () => { + const image = $('#hero img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + expect(image.attr('alt')).to.equal('Hero image'); + }); + }); +}); + +describe('SSG pictures - build', function () { + let fixture; + let $; + let html; + + before(async () => { + fixture = await loadFixture({ root: './fixtures/basic-picture/' }); + await fixture.build(); + + html = await fixture.readFile('/index.html'); + $ = cheerio.load(html); }); function verifyImage(pathname, expected) { @@ -25,253 +275,310 @@ describe('SSG pictures', function () { } } - describe('build', () => { - let $; - let html; + describe('Local images', () => { + it('includes sources', () => { + const sources = $('#social-jpg source'); - before(async () => { - await fixture.build(); + expect(sources.length).to.equal(3); - html = await fixture.readFile('/index.html'); - $ = cheerio.load(html); + // TODO: better coverage to verify source props }); - describe('Local images', () => { - it('includes sources', () => { - const sources = $('#social-jpg source'); - - expect(sources.length).to.equal(3); + it('includes <img> attributes', () => { + const image = $('#social-jpg img'); - // TODO: better coverage to verify source props - }); + expect(image.attr('src')).to.equal('/social.cece8c77_isw36.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + expect(image.attr('alt')).to.equal('Social image'); + }); - it('includes <img> attributes', () => { - const image = $('#social-jpg img'); + it('built the optimized image', () => { + verifyImage('social.cece8c77_Z1qCkLW.avif', { width: 253, height: 127, type: 'avif' }); + verifyImage('social.cece8c77_ZHhvOb.webp', { width: 253, height: 127, type: 'webp' }); + verifyImage('social.cece8c77_ZwfMjf.jpg', { width: 253, height: 127, type: 'jpg' }); + verifyImage('social.cece8c77_6t5Xo.avif', { width: 506, height: 253, type: 'avif' }); + verifyImage('social.cece8c77_ONTVa.webp', { width: 506, height: 253, type: 'webp' }); + verifyImage('social.cece8c77_isw36.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); - expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg'); - expect(image.attr('width')).to.equal('506'); - expect(image.attr('height')).to.equal('253'); - expect(image.attr('alt')).to.equal('Social image'); - }); + describe('Inline imports', () => { + it('includes sources', () => { + const sources = $('#inline source'); - it('built the optimized image', () => { - verifyImage('_image/assets/social_253x127.avif', { width: 253, height: 127, type: 'avif' }); - verifyImage('_image/assets/social_253x127.webp', { width: 253, height: 127, type: 'webp' }); - verifyImage('_image/assets/social_253x127.jpg', { width: 253, height: 127, type: 'jpg' }); - verifyImage('_image/assets/social_506x253.avif', { width: 506, height: 253, type: 'avif' }); - verifyImage('_image/assets/social_506x253.webp', { width: 506, height: 253, type: 'webp' }); - verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' }); - }); + expect(sources.length).to.equal(3); - it('dist includes original image', () => { - verifyImage('assets/social.jpg', { width: 2024, height: 1012, type: 'jpg' }); - }); + // TODO: better coverage to verify source props }); - describe('Inline imports', () => { - it('includes sources', () => { - const sources = $('#inline source'); + it('includes <img> attributes', () => { + const image = $('#inline img'); - expect(sources.length).to.equal(3); + expect(image.attr('src')).to.equal('/social.cece8c77_isw36.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + expect(image.attr('alt')).to.equal('Inline social image'); + }); - // TODO: better coverage to verify source props - }); + it('built the optimized image', () => { + verifyImage('social.cece8c77_Z1qCkLW.avif', { width: 253, height: 127, type: 'avif' }); + verifyImage('social.cece8c77_ZHhvOb.webp', { width: 253, height: 127, type: 'webp' }); + verifyImage('social.cece8c77_ZwfMjf.jpg', { width: 253, height: 127, type: 'jpg' }); + verifyImage('social.cece8c77_6t5Xo.avif', { width: 506, height: 253, type: 'avif' }); + verifyImage('social.cece8c77_ONTVa.webp', { width: 506, height: 253, type: 'webp' }); + verifyImage('social.cece8c77_isw36.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); - it('includes <img> attributes', () => { - const image = $('#inline img'); + describe('Remote images', () => { + // Hard-coding in the test! This should never change since the hash is based + // on the static `src` string + const HASH = 'ZWW1pg'; - expect(image.attr('src')).to.equal('/_image/assets/social_506x253.jpg'); - expect(image.attr('width')).to.equal('506'); - expect(image.attr('height')).to.equal('253'); - expect(image.attr('alt')).to.equal('Inline social image'); - }); + it('includes sources', () => { + const sources = $('#google source'); - it('built the optimized image', () => { - verifyImage('_image/assets/social_253x127.avif', { width: 253, height: 127, type: 'avif' }); - verifyImage('_image/assets/social_253x127.webp', { width: 253, height: 127, type: 'webp' }); - verifyImage('_image/assets/social_253x127.jpg', { width: 253, height: 127, type: 'jpg' }); - verifyImage('_image/assets/social_506x253.avif', { width: 506, height: 253, type: 'avif' }); - verifyImage('_image/assets/social_506x253.webp', { width: 506, height: 253, type: 'webp' }); - verifyImage('_image/assets/social_506x253.jpg', { width: 506, height: 253, type: 'jpg' }); - }); - }); + expect(sources.length).to.equal(3); - describe('Remote images', () => { - // Hard-coding in the test! This should never change since the hash is based - // on the static `src` string - const HASH = 'Z1iI4xW'; + // TODO: better coverage to verify source props + }); - it('includes sources', () => { - const sources = $('#google source'); + it('includes <img> attributes', () => { + const image = $('#google img'); - expect(sources.length).to.equal(3); + expect(image.attr('src')).to.equal(`/googlelogo_color_272x92dp_${HASH}.png`); + expect(image.attr('width')).to.equal('544'); + expect(image.attr('height')).to.equal('184'); + expect(image.attr('alt')).to.equal('Google logo'); + }); - // TODO: better coverage to verify source props + it('built the optimized image', () => { + verifyImage(`googlelogo_color_272x92dp_1YsbPJ.avif`, { + width: 272, + height: 92, + type: 'avif', }); - - it('includes <img> attributes', () => { - const image = $('#google img'); - - expect(image.attr('src')).to.equal(`/_image/googlelogo_color_272x92dp-${HASH}_544x184.png`); - expect(image.attr('width')).to.equal('544'); - expect(image.attr('height')).to.equal('184'); - expect(image.attr('alt')).to.equal('Google logo'); + verifyImage(`googlelogo_color_272x92dp_1OJIxd.webp`, { + width: 272, + height: 92, + type: 'webp', }); - - it('built the optimized image', () => { - verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_272x92.avif`, { - width: 272, - height: 92, - type: 'avif', - }); - verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_272x92.webp`, { - width: 272, - height: 92, - type: 'webp', - }); - verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_272x92.png`, { - width: 272, - height: 92, - type: 'png', - }); - verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_544x184.avif`, { - width: 544, - height: 184, - type: 'avif', - }); - verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_544x184.webp`, { - width: 544, - height: 184, - type: 'webp', - }); - verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_544x184.png`, { - width: 544, - height: 184, - type: 'png', - }); + verifyImage(`googlelogo_color_272x92dp_ZaELrV.png`, { + width: 272, + height: 92, + type: 'png', + }); + verifyImage(`googlelogo_color_272x92dp_I7OBe.avif`, { + width: 544, + height: 184, + type: 'avif', + }); + verifyImage(`googlelogo_color_272x92dp_ReA0T.webp`, { + width: 544, + height: 184, + type: 'webp', + }); + verifyImage(`googlelogo_color_272x92dp_ZWW1pg.png`, { + width: 544, + height: 184, + type: 'png', }); }); }); - describe('dev', () => { - let devServer; - let $; + describe('/public images', () => { + it('includes sources', () => { + const sources = $('#hero source'); - before(async () => { - devServer = await fixture.startDevServer(); - const html = await fixture.fetch('/').then((res) => res.text()); - $ = cheerio.load(html); - }); + expect(sources.length).to.equal(3); - after(async () => { - await devServer.stop(); + // TODO: better coverage to verify source props }); - describe('Local images', () => { - it('includes sources', () => { - const sources = $('#social-jpg source'); - - expect(sources.length).to.equal(3); + it('includes <img> attributes', () => { + const image = $('#hero img'); - // TODO: better coverage to verify source props - }); + expect(image.attr('src')).to.equal('/hero_1ql1f0.jpg'); + expect(image.attr('width')).to.equal('768'); + expect(image.attr('height')).to.equal('414'); + expect(image.attr('alt')).to.equal('Hero image'); + }); - it('includes <img> attributes', () => { - const image = $('#social-jpg img'); + it('built the optimized image', () => { + verifyImage('hero_ZOXU0F.avif', { width: 384, height: 207, type: 'avif' }); + verifyImage('hero_ZFR9B0.webp', { width: 384, height: 207, type: 'webp' }); + verifyImage('hero_Z1rYjFx.jpg', { width: 384, height: 207, type: 'jpg' }); + verifyImage('hero_Z1kkIMd.avif', { width: 768, height: 414, type: 'avif' }); + verifyImage('hero_Z1bdXnx.webp', { width: 768, height: 414, type: 'webp' }); + verifyImage('hero_Z1Wl8s5.jpg', { width: 768, height: 414, type: 'jpg' }); + }); + }); +}); - const src = image.attr('src'); - const [route, params] = src.split('?'); +describe('SSG pictures with subpath - build', function () { + let fixture; + let $; + let html; - expect(route).to.equal('/_image'); + before(async () => { + fixture = await loadFixture({ root: './fixtures/basic-picture/', base: '/docs' }); + await fixture.build(); - const searchParams = new URLSearchParams(params); + html = await fixture.readFile('/index.html'); + $ = cheerio.load(html); + }); - expect(searchParams.get('f')).to.equal('jpg'); - expect(searchParams.get('w')).to.equal('506'); - expect(searchParams.get('h')).to.equal('253'); - // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); - expect(image.attr('alt')).to.equal('Social image'); - }); + function verifyImage(pathname, expected) { + const url = new URL('./fixtures/basic-picture/dist/' + pathname, import.meta.url); + const dist = fileURLToPath(url); - it('returns the optimized image', async () => { - const image = $('#social-jpg img'); + // image-size doesn't support AVIF files + if (expected.type !== 'avif') { + const result = sizeOf(dist); + expect(result).to.deep.equal(expected); + } else { + expect(fs.statSync(dist)).not.to.be.undefined; + } + } - const res = await fixture.fetch(image.attr('src')); + describe('Local images', () => { + it('includes sources', () => { + const sources = $('#social-jpg source'); - expect(res.status).to.equal(200); - expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + expect(sources.length).to.equal(3); - // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers - }); + // TODO: better coverage to verify source props }); - describe('Local images with inline imports', () => { - it('includes sources', () => { - const sources = $('#inline source'); + it('includes <img> attributes', () => { + const image = $('#social-jpg img'); - expect(sources.length).to.equal(3); + expect(image.attr('src')).to.equal('/docs/social.cece8c77_VWX3S.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + expect(image.attr('alt')).to.equal('Social image'); + }); - // TODO: better coverage to verify source props - }); + it('built the optimized image', () => { + verifyImage('social.cece8c77_2wbTqo.avif', { width: 253, height: 127, type: 'avif' }); + verifyImage('social.cece8c77_Z1OEppL.webp', { width: 253, height: 127, type: 'webp' }); + verifyImage('social.cece8c77_Z1xuFVD.jpg', { width: 253, height: 127, type: 'jpg' }); + verifyImage('social.cece8c77_Z10SMCc.avif', { width: 506, height: 253, type: 'avif' }); + verifyImage('social.cece8c77_ZhxXEq.webp', { width: 506, height: 253, type: 'webp' }); + verifyImage('social.cece8c77_Z1ks7l5.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); - it('includes <img> attributes', () => { - const image = $('#inline img'); + describe('Inline imports', () => { + it('includes sources', () => { + const sources = $('#inline source'); - const src = image.attr('src'); - const [route, params] = src.split('?'); + expect(sources.length).to.equal(3); - expect(route).to.equal('/_image'); + // TODO: better coverage to verify source props + }); - const searchParams = new URLSearchParams(params); + it('includes <img> attributes', () => { + const image = $('#inline img'); - expect(searchParams.get('f')).to.equal('jpg'); - expect(searchParams.get('w')).to.equal('506'); - expect(searchParams.get('h')).to.equal('253'); - // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); - expect(image.attr('alt')).to.equal('Inline social image'); - }); + expect(image.attr('src')).to.equal('/docs/social.cece8c77_VWX3S.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + expect(image.attr('alt')).to.equal('Inline social image'); + }); - it('returns the optimized image', async () => { - const image = $('#inline img'); + it('built the optimized image', () => { + verifyImage('social.cece8c77_2wbTqo.avif', { width: 253, height: 127, type: 'avif' }); + verifyImage('social.cece8c77_Z1OEppL.webp', { width: 253, height: 127, type: 'webp' }); + verifyImage('social.cece8c77_Z1xuFVD.jpg', { width: 253, height: 127, type: 'jpg' }); + verifyImage('social.cece8c77_Z10SMCc.avif', { width: 506, height: 253, type: 'avif' }); + verifyImage('social.cece8c77_ZhxXEq.webp', { width: 506, height: 253, type: 'webp' }); + verifyImage('social.cece8c77_Z1ks7l5.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); - const res = await fixture.fetch(image.attr('src')); + describe('Remote images', () => { + // Hard-coding in the test! This should never change since the hash is based + // on the static `src` string + const HASH = 'ZWW1pg'; - expect(res.status).to.equal(200); - expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + it('includes sources', () => { + const sources = $('#google source'); - // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers - }); + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props }); - describe('Remote images', () => { - it('includes sources', () => { - const sources = $('#google source'); + it('includes <img> attributes', () => { + const image = $('#google img'); - expect(sources.length).to.equal(3); + expect(image.attr('src')).to.equal(`/docs/googlelogo_color_272x92dp_${HASH}.png`); + expect(image.attr('width')).to.equal('544'); + expect(image.attr('height')).to.equal('184'); + expect(image.attr('alt')).to.equal('Google logo'); + }); - // TODO: better coverage to verify source props + it('built the optimized image', () => { + verifyImage(`googlelogo_color_272x92dp_1YsbPJ.avif`, { + width: 272, + height: 92, + type: 'avif', + }); + verifyImage(`googlelogo_color_272x92dp_1OJIxd.webp`, { + width: 272, + height: 92, + type: 'webp', }); + verifyImage(`googlelogo_color_272x92dp_ZaELrV.png`, { + width: 272, + height: 92, + type: 'png', + }); + verifyImage(`googlelogo_color_272x92dp_I7OBe.avif`, { + width: 544, + height: 184, + type: 'avif', + }); + verifyImage(`googlelogo_color_272x92dp_ReA0T.webp`, { + width: 544, + height: 184, + type: 'webp', + }); + verifyImage(`googlelogo_color_272x92dp_ZWW1pg.png`, { + width: 544, + height: 184, + type: 'png', + }); + }); + }); - it('includes <img> attributes', () => { - const image = $('#google img'); + describe('/public images', () => { + it('includes sources', () => { + const sources = $('#hero source'); - const src = image.attr('src'); - const [route, params] = src.split('?'); + expect(sources.length).to.equal(3); - expect(route).to.equal('/_image'); + // TODO: better coverage to verify source props + }); - const searchParams = new URLSearchParams(params); + it('includes <img> attributes', () => { + const image = $('#hero img'); - expect(searchParams.get('f')).to.equal('png'); - expect(searchParams.get('w')).to.equal('544'); - expect(searchParams.get('h')).to.equal('184'); - expect(searchParams.get('href')).to.equal( - 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' - ); - expect(image.attr('alt')).to.equal('Google logo'); - }); + expect(image.attr('src')).to.equal('/docs/hero_1ql1f0.jpg'); + expect(image.attr('width')).to.equal('768'); + expect(image.attr('height')).to.equal('414'); + expect(image.attr('alt')).to.equal('Hero image'); + }); + + it('built the optimized image', () => { + verifyImage('hero_ZOXU0F.avif', { width: 384, height: 207, type: 'avif' }); + verifyImage('hero_ZFR9B0.webp', { width: 384, height: 207, type: 'webp' }); + verifyImage('hero_Z1rYjFx.jpg', { width: 384, height: 207, type: 'jpg' }); + verifyImage('hero_Z1kkIMd.avif', { width: 768, height: 414, type: 'avif' }); + verifyImage('hero_Z1bdXnx.webp', { width: 768, height: 414, type: 'webp' }); + verifyImage('hero_Z1Wl8s5.jpg', { width: 768, height: 414, type: 'jpg' }); }); }); }); diff --git a/packages/integrations/image/test/picture-ssr.test.js b/packages/integrations/image/test/picture-ssr-build.test.js index ebfbdf749..e6b66ffa3 100644 --- a/packages/integrations/image/test/picture-ssr.test.js +++ b/packages/integrations/image/test/picture-ssr-build.test.js @@ -1,20 +1,11 @@ import { expect } from 'chai'; import * as cheerio from 'cheerio'; -import sizeOf from 'image-size'; -import { fileURLToPath } from 'url'; import { loadFixture } from './test-utils.js'; import testAdapter from '../../../astro/test/test-adapter.js'; describe('SSR pictures - build', function () { 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 () => { fixture = await loadFixture({ root: './fixtures/basic-picture/', @@ -60,35 +51,9 @@ describe('SSR pictures - build', function () { expect(searchParams.get('f')).to.equal('jpg'); expect(searchParams.get('w')).to.equal('506'); expect(searchParams.get('h')).to.equal('253'); - // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + expect(searchParams.get('href')).to.equal('/assets/social.cece8c77.jpg'); expect(image.attr('alt')).to.equal('Social image'); }); - - it('built the optimized image', async () => { - const app = await fixture.loadTestAdapterApp(); - - const request = new Request('http://example.com/'); - const response = await app.render(request); - const html = await response.text(); - const $ = cheerio.load(html); - - const image = $('#social-jpg img'); - - const imgRequest = new Request(`http://example.com${image.attr('src')}`); - const imgResponse = await app.render(imgRequest); - - expect(imgResponse.status).to.equal(200); - expect(imgResponse.headers.get('Content-Type')).to.equal('image/jpeg'); - - // 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', () => { @@ -128,7 +93,7 @@ describe('SSR pictures - build', function () { expect(searchParams.get('w')).to.equal('506'); expect(searchParams.get('h')).to.equal('253'); // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + expect(searchParams.get('href')).to.equal('/assets/social.cece8c77.jpg'); expect(image.attr('alt')).to.equal('Inline social image'); }); }); @@ -170,35 +135,77 @@ describe('SSR pictures - build', function () { expect(searchParams.get('w')).to.equal('544'); expect(searchParams.get('h')).to.equal('184'); // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('googlelogo_color_272x92dp.png')).to.equal(true); + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' + ); expect(image.attr('alt')).to.equal('Google logo'); }); }); + + describe('/public images', () => { + it('includes sources', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const sources = $('#hero source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes <img> attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const image = $('#hero img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + expect(searchParams.get('href')).to.equal('/hero.jpg'); + expect(image.attr('alt')).to.equal('Hero image'); + }); + }); }); -describe('SSR images - dev', function () { +describe('SSR pictures with subpath - build', function () { let fixture; - let devServer; - let $; before(async () => { fixture = await loadFixture({ root: './fixtures/basic-picture/', adapter: testAdapter(), output: 'server', + base: '/docs' }); - - devServer = await fixture.startDevServer(); - const html = await fixture.fetch('/').then((res) => res.text()); - $ = cheerio.load(html); - }); - - after(async () => { - await devServer.stop(); + await fixture.build(); }); describe('Local images', () => { - it('includes sources', () => { + it('includes sources', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + const sources = $('#social-jpg source'); expect(sources.length).to.equal(3); @@ -206,7 +213,14 @@ describe('SSR images - dev', function () { // TODO: better coverage to verify source props }); - it('includes src, width, and height attributes', () => { + it('includes <img> attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + const image = $('#social-jpg img'); const src = image.attr('src'); @@ -219,25 +233,20 @@ describe('SSR images - dev', function () { expect(searchParams.get('f')).to.equal('jpg'); expect(searchParams.get('w')).to.equal('506'); expect(searchParams.get('h')).to.equal('253'); - // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + expect(searchParams.get('href')).to.equal('/docs/assets/social.cece8c77.jpg'); expect(image.attr('alt')).to.equal('Social image'); }); - - it('returns the optimized image', async () => { - const image = $('#social-jpg img'); - - const res = await fixture.fetch(image.attr('src')); - - expect(res.status).to.equal(200); - expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); - - // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers - }); }); describe('Inline imports', () => { - it('includes sources', () => { + it('includes sources', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + const sources = $('#inline source'); expect(sources.length).to.equal(3); @@ -245,7 +254,14 @@ describe('SSR images - dev', function () { // TODO: better coverage to verify source props }); - it('includes src, width, and height attributes', () => { + it('includes <img> attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + const image = $('#inline img'); const src = image.attr('src'); @@ -259,13 +275,20 @@ describe('SSR images - dev', function () { expect(searchParams.get('w')).to.equal('506'); expect(searchParams.get('h')).to.equal('253'); // TODO: possible to avoid encoding the full image path? - expect(searchParams.get('href').endsWith('/assets/social.jpg')).to.equal(true); + expect(searchParams.get('href')).to.equal('/docs/assets/social.cece8c77.jpg'); expect(image.attr('alt')).to.equal('Inline social image'); }); }); describe('Remote images', () => { - it('includes sources', () => { + it('includes sources', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + const sources = $('#google source'); expect(sources.length).to.equal(3); @@ -273,7 +296,14 @@ describe('SSR images - dev', function () { // TODO: better coverage to verify source props }); - it('includes src, width, and height attributes', () => { + it('includes <img> attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + const image = $('#google img'); const src = image.attr('src'); @@ -286,10 +316,52 @@ describe('SSR images - dev', function () { expect(searchParams.get('f')).to.equal('png'); expect(searchParams.get('w')).to.equal('544'); expect(searchParams.get('h')).to.equal('184'); + // TODO: possible to avoid encoding the full image path? expect(searchParams.get('href')).to.equal( 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' ); expect(image.attr('alt')).to.equal('Google logo'); }); }); + + describe('/public images', () => { + it('includes sources', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const sources = $('#hero source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes <img> attributes', async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const image = $('#hero img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + expect(searchParams.get('href')).to.equal('/hero.jpg'); + expect(image.attr('alt')).to.equal('Hero image'); + }); + }); }); diff --git a/packages/integrations/image/test/picture-ssr-dev.test.js b/packages/integrations/image/test/picture-ssr-dev.test.js new file mode 100644 index 000000000..392d5a0cf --- /dev/null +++ b/packages/integrations/image/test/picture-ssr-dev.test.js @@ -0,0 +1,309 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; +import testAdapter from '../../../astro/test/test-adapter.js'; + +describe('SSR pictures - dev', function () { + let fixture; + let devServer; + let $; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/basic-picture/', + adapter: testAdapter(), + output: 'server', + }); + + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/').then((res) => res.text()); + $ = cheerio.load(html); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Local images', () => { + it('includes sources', () => { + const sources = $('#social-jpg source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#social-jpg img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + expect(image.attr('alt')).to.equal('Social image'); + }); + + it('returns the optimized image', async () => { + const image = $('#social-jpg img'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); + + describe('Inline imports', () => { + it('includes sources', () => { + const sources = $('#inline source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#inline img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + expect(image.attr('alt')).to.equal('Inline social image'); + }); + }); + + describe('Remote images', () => { + it('includes sources', () => { + const sources = $('#google source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#google img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('png'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' + ); + expect(image.attr('alt')).to.equal('Google logo'); + }); + }); + + describe('/public images', () => { + it('includes sources', () => { + const sources = $('#hero source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#hero img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + expect(searchParams.get('href')).to.equal('/hero.jpg'); + expect(image.attr('alt')).to.equal('Hero image'); + }); + + it('returns the optimized image', async () => { + const image = $('#hero img'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); +}); + +describe('SSR pictures with subpath - dev', function () { + let fixture; + let devServer; + let $; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/basic-picture/', + adapter: testAdapter(), + output: 'server', + base: '/docs' + }); + + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/docs/').then((res) => res.text()); + $ = cheerio.load(html); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Local images', () => { + it('includes sources', () => { + const sources = $('#social-jpg source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#social-jpg img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + expect(image.attr('alt')).to.equal('Social image'); + }); + + it('returns the optimized image', async () => { + const image = $('#social-jpg img'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); + + describe('Inline imports', () => { + it('includes sources', () => { + const sources = $('#inline source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#inline img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/@astroimage/assets/social.jpg'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('506'); + expect(searchParams.get('h')).to.equal('253'); + expect(image.attr('alt')).to.equal('Inline social image'); + }); + }); + + describe('Remote images', () => { + it('includes sources', () => { + const sources = $('#google source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#google img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('png'); + expect(searchParams.get('w')).to.equal('544'); + expect(searchParams.get('h')).to.equal('184'); + expect(searchParams.get('href')).to.equal( + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png' + ); + expect(image.attr('alt')).to.equal('Google logo'); + }); + }); + + describe('/public images', () => { + it('includes sources', () => { + const sources = $('#hero source'); + + expect(sources.length).to.equal(3); + + // TODO: better coverage to verify source props + }); + + it('includes src, width, and height attributes', () => { + const image = $('#hero img'); + + const src = image.attr('src'); + const [route, params] = src.split('?'); + + expect(route).to.equal('/_image'); + + const searchParams = new URLSearchParams(params); + + expect(searchParams.get('f')).to.equal('jpg'); + expect(searchParams.get('w')).to.equal('768'); + expect(searchParams.get('h')).to.equal('414'); + expect(searchParams.get('href')).to.equal('/hero.jpg'); + expect(image.attr('alt')).to.equal('Hero image'); + }); + + it('returns the optimized image', async () => { + const image = $('#hero img'); + + const res = await fixture.fetch(image.attr('src')); + + expect(res.status).to.equal(200); + expect(res.headers.get('Content-Type')).to.equal('image/jpeg'); + + // TODO: verify image file? It looks like sizeOf doesn't support ArrayBuffers + }); + }); +}); diff --git a/packages/integrations/image/test/rotation.test.js b/packages/integrations/image/test/rotation.test.js index 19333e572..42d9d5ec6 100644 --- a/packages/integrations/image/test/rotation.test.js +++ b/packages/integrations/image/test/rotation.test.js @@ -30,11 +30,23 @@ describe('Image rotation', function () { }); describe('Landscape images', () => { + const hashes = [ + '/Landscape_0.080ebd7a_ZdTMkT.jpg', + '/Landscape_1.c92e81c9_4Eikw.jpg', + '/Landscape_2.f54c85e5_1iKxtI.jpg', + '/Landscape_3.8e20af03_Z2sFwFL.jpg', + '/Landscape_4.15f511b0_1dNJQt.jpg', + '/Landscape_5.6d88c17f_ZtLntP.jpg', + '/Landscape_6.1a88f6d8_Z1Pl4xy.jpg', + '/Landscape_7.cb1008c2_Z1JYr40.jpg', + '/Landscape_8.3d2837d2_1xTOBN.jpg' + ]; + 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('src')).to.equal(hashes[i]); expect(image.attr('width')).to.equal('1800'); expect(image.attr('height')).to.equal('1200'); } @@ -42,7 +54,7 @@ describe('Image rotation', function () { it('built the optimized image', () => { for (let i = 0; i < 9; i++) { - verifyImage(`/_image/assets/Landscape_${i}_1800x1200.jpg`, { + verifyImage(hashes[i], { width: 1800, height: 1200, type: 'jpg', @@ -52,11 +64,23 @@ describe('Image rotation', function () { }); describe('Portait images', () => { + const hashes = [ + '/Portrait_0.e09ae908_5e5uz.jpg', + '/Portrait_1.c7b4942e_1RJQep.jpg', + '/Portrait_2.8e8be39f_T6sr4.jpg', + '/Portrait_3.1dcc58b4_Z1uaoxA.jpg', + '/Portrait_4.2f89d418_ZLQlNB.jpg', + '/Portrait_5.b3b6cc6f_Z23Ek26.jpg', + '/Portrait_6.94e06390_ak2Ek.jpg', + '/Portrait_7.9ffdecfe_Z1S4klG.jpg', + '/Portrait_8.9d01343d_2dak03.jpg' + ]; + 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('src')).to.equal(hashes[i]); expect(image.attr('width')).to.equal('1200'); expect(image.attr('height')).to.equal('1800'); } @@ -64,7 +88,7 @@ describe('Image rotation', function () { it('built the optimized image', () => { for (let i = 0; i < 9; i++) { - verifyImage(`/_image/assets/Portrait_${i}_1200x1800.jpg`, { + verifyImage(hashes[i], { width: 1200, height: 1800, type: 'jpg', diff --git a/packages/integrations/image/test/sharp.test.js b/packages/integrations/image/test/sharp.test.js index 82e332e3d..ff1db8f17 100644 --- a/packages/integrations/image/test/sharp.test.js +++ b/packages/integrations/image/test/sharp.test.js @@ -26,7 +26,6 @@ describe('Sharp service', () => { } } - verifyProp(props.src, 'href'); verifyProp(props.quality, 'q'); verifyProp(props.format, 'f'); verifyProp(props.width, 'w'); diff --git a/packages/integrations/image/test/with-mdx.test.js b/packages/integrations/image/test/with-mdx.test.js new file mode 100644 index 000000000..714563e0f --- /dev/null +++ b/packages/integrations/image/test/with-mdx.test.js @@ -0,0 +1,101 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import sizeOf from 'image-size'; +import { fileURLToPath } from 'url'; +import { loadFixture } from './test-utils.js'; + +describe('Images in MDX - build', function () { + let fixture; + let $; + let html; + + before(async () => { + fixture = await loadFixture({ root: './fixtures/with-mdx/' }); + await fixture.build(); + + html = await fixture.readFile('/index.html'); + $ = cheerio.load(html); + }); + + function verifyImage(pathname, expected) { + const url = new URL('./fixtures/with-mdx/dist/' + pathname, import.meta.url); + const dist = fileURLToPath(url); + const result = sizeOf(dist); + expect(result).to.deep.equal(expected); + } + + describe('Local images', () => { + it('includes <img> attributes', () => { + const image = $('#social-jpg'); + + expect(image.attr('src')).to.equal('/social.cece8c77_1zwatP.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + }); + + it('built the optimized image', () => { + verifyImage('social.cece8c77_1zwatP.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); + + describe('Inline imports', () => { + it('includes <img> attributes', () => { + const image = $('#inline'); + + expect(image.attr('src')).to.equal('/social.cece8c77_Z2tF99.jpg'); + expect(image.attr('width')).to.equal('506'); + expect(image.attr('height')).to.equal('253'); + }); + + it('built the optimized image', () => { + verifyImage('social.cece8c77_Z2tF99.jpg', { width: 506, height: 253, type: 'jpg' }); + }); + }); + + describe('Remote images', () => { + // Hard-coding in the test! These should never change since the hash is based + // on the static `src` string + const HASH = 'Z1RBHqs'; + const HASH_WITH_QUERY = 'Z17oujH'; + + it('includes <img> attributes', () => { + const image = $('#google'); + + expect(image.attr('src')).to.equal( + `/googlelogo_color_272x92dp_${HASH}.webp` + ); + expect(image.attr('width')).to.equal('544'); + expect(image.attr('height')).to.equal('184'); + }); + + it('built the optimized image', () => { + verifyImage(`/googlelogo_color_272x92dp_${HASH}.webp`, { + width: 544, + height: 184, + type: 'webp', + }); + }); + + it('removes query strings', () => { + verifyImage(`/googlelogo_color_272x92dp_${HASH_WITH_QUERY}.webp`, { + width: 544, + height: 184, + type: 'webp', + }); + }); + }); + + describe('/public images', () => { + it('includes <img> attributes', () => { + const image = $('#hero'); + + expect(image.attr('src')).to.equal('/hero_Z2k1JGN.webp'); + expect(image.attr('width')).to.equal('768'); + expect(image.attr('height')).to.equal('414'); + }); + + it('built the optimized image', () => { + verifyImage('hero_Z2k1JGN.webp', { width: 768, height: 414, type: 'webp' }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eea2d1b43..4c1de0cd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2228,26 +2228,20 @@ importers: packages/integrations/image: specifiers: - '@types/etag': ^1.8.1 - '@types/sharp': ^0.30.4 + '@types/sharp': ^0.30.5 astro: workspace:* astro-scripts: workspace:* - etag: ^1.8.1 - image-size: ^1.0.1 + image-size: ^1.0.2 kleur: ^4.1.4 - mrmime: ^1.0.0 + magic-string: ^0.25.9 + mime: ^3.0.0 sharp: ^0.30.6 - slash: ^4.0.0 - tiny-glob: ^0.2.9 dependencies: - etag: 1.8.1 image-size: 1.0.2 - mrmime: 1.0.1 + magic-string: 0.25.9 + mime: 3.0.0 sharp: 0.30.7 - slash: 4.0.0 - tiny-glob: 0.2.9 devDependencies: - '@types/etag': 1.8.1 '@types/sharp': 0.30.5 astro: link:../../astro astro-scripts: link:../../../scripts @@ -2283,6 +2277,18 @@ importers: '@astrojs/node': link:../../../../node astro: link:../../../../../astro + packages/integrations/image/test/fixtures/with-mdx: + specifiers: + '@astrojs/image': workspace:* + '@astrojs/mdx': workspace:* + '@astrojs/node': workspace:* + astro: workspace:* + dependencies: + '@astrojs/image': link:../../.. + '@astrojs/mdx': link:../../../../mdx + '@astrojs/node': link:../../../../node + astro: link:../../../../../astro + packages/integrations/lit: specifiers: '@lit-labs/ssr': ^2.2.0 @@ -11464,11 +11470,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /etag/1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - dev: false - /event-target-shim/5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -11904,6 +11905,7 @@ packages: /globalyzer/0.1.0: resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + dev: true /globby/11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} @@ -11931,6 +11933,7 @@ packages: /globrex/0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true /graceful-fs/4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} @@ -16303,6 +16306,7 @@ packages: dependencies: globalyzer: 0.1.0 globrex: 0.1.2 + dev: true /tinypool/0.2.4: resolution: {integrity: sha512-Vs3rhkUH6Qq1t5bqtb816oT+HeJTXfwt2cbPH17sWHIYKTotQIFPk3tf2fgqRrVyMDVOc1EnPgzIxfIulXVzwQ==} |