diff --git a/docs/docs/client-loader.md b/docs/docs/client-loader.md new file mode 100644 index 0000000..52fdfeb --- /dev/null +++ b/docs/docs/client-loader.md @@ -0,0 +1,84 @@ +--- +sidebar_position: 8 +--- + +# ClientLoader + +Client Loader Functions are used to construct the URL for the `Image` and `BaseImage` components and the `useResponsiveImage` hook. + +Using `remixImageLoader` enables Remix-Image’s advanced transformation options, which includes transformation caching and image operations such as `resize`, `crop`, `rotate`, `blur`, and `flip`. + +However, using alternative (and likely paid) services and their client loaders may result in faster response times, as the Remix server will not be slowed down by requests for new image transformations. + +We suggest trying the default `remixImageLoader` function to see if it works for your apps, and then upgrade to a paid alternative if needed. Only websites with high traffic and/or many dynamic image assets will need an alternative client loader. + +## Supported Loaders + +### Remix Image Loader + +`remixImageLoader` is the default loader used by Remix-Image. In most cases you will want to use this loader. + +### Cloudflare Images Loader + +`cloudflareImagesLoader` is a loader used to transform images using the paid [Cloudflare Images](https://developers.cloudflare.com/images/) service. + +### Cloudinary Loader + +`cloudinaryLoader` is a loader used to transform images using the paid [Cloudinary](https://cloudinary.com/) service. + +**Note**: Remember to set `loaderUrl` to your API url! This should be a string similar to `https://res.cloudinary.com//` + +### Imgix Loader + +`imgixLoader` is a loader used to transform images using the paid [Imgix](https://imgix.com/) service. + +**Note**: Remember to set `loaderUrl` to your API url! This should be a string similar to `https://.imgix.net/` + +## Usage + +### `BaseImage` and `Image` Components + +```typescript jsx +import { Image, remixImageLoader, cloudflareImagesLoader, cloudinaryLoader, imgixLoader } from "remix-image"; + +... +``` + +### `useResponsiveImage` Hook + +```typescript jsx +import { useResponsiveImage, remixImageLoader, cloudflareImagesLoader, cloudinaryLoader, imgixLoader } from "remix-image"; + +const Image: React.FC = ({ + className, + loaderUrl = "/api/image", // Required when using cloudinaryLoader or imgixLoader + responsive = [], + ...imgProps +}) => { + const responsiveProps = useResponsiveImage(imgProps, responsive, [1], loaderUrl, remixImageLoader or cloudflareImagesLoader or cloudinaryLoader or imgixLoader); + + return ( + + ); +}; +``` \ No newline at end of file diff --git a/docs/docs/component.md b/docs/docs/component.md index 4f8f750..61da5db 100644 --- a/docs/docs/component.md +++ b/docs/docs/component.md @@ -19,10 +19,11 @@ Use `Image` element if you would like to use the performance optimizations built Import the `Image` component and specify the url to the resource route used by the `imageLoader` function. ```typescript jsx -import { Image } from "remix-image"; +import { Image, remixImageLoader } from "remix-image"; = ({ className, @@ -16,7 +16,7 @@ const Image: React.FC = ({ responsive = [], ...imgProps }) => { - const responsiveProps = useResponsiveImage(imgProps, loaderUrl, responsive); + const responsiveProps = useResponsiveImage(imgProps, responsive, [1], loaderUrl, remixImageLoader); return ( = ({ ``` ## Parameters -| Name | Type | Required | Default | Description | -|:-----------:|:------------------------------------------------------------------:|:--------:|:-------:|:--------------------------------------------------------------------------------------------------------------:| -| imgProps | { src: string } | X | | The props to be passed to the base img element. | -| loaderUrl | string | X | [] | The path of the image loader resource route. | -| responsive | { size: { width: number; height: number; }; maxWidth?: number; }[] | | [] | An array of responsive sizes. | -| options | TransformOptions | | | TransformOptions that can be used to override the defaults provided to the loader. | -| dprVariants | number or number[] | | [1] | Different DPR variants to generate images for. This value will always be merged into an array with value [1]. | +| Name | Type | Required | Default | Description | +|:-----------:|:------------------------------------------------------------------:|:-------------------------------------------------------------------------:|:-------------------:|:-------------------------------------------------------------------------------------------------------------:| +| imgProps | { src: string } | Yes | | The props to be passed to the base img element. | +| responsive | { size: { width: number; height: number; }; maxWidth?: number; }[] | | `[]` | An array of responsive sizes. | +| options | TransformOptions | | | TransformOptions that can be used to override the defaults provided to the loader. | +| dprVariants | number or number[] | | `[1]` | Different DPR variants to generate images for. This value will always be merged into an array with value [1]. | +| loaderUrl | string | Yes when using `cloudinaryLoader` or `imgixLoader` for `loader` parameter | `"/api/image"` | The path of the image loader resource route. | +| loader | ClientLoader | | `remixImageLoader` | The ClientLoader to use for generating the transformed image. | + +### ClientLoader Options +By default, `remixImageLoader` is used. If you would like to use an external ClientLoader, please refer to the [ClientLoader documentation](./client-loader.md). ### TransformOptions ```typescript diff --git a/docs/docs/intro.md b/docs/docs/intro.md index b51922b..204c164 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -10,7 +10,8 @@ A React component for responsive images in Remix. This library lets you: * Resize images to the minimum size needed for faster page loading -* Transform images to more efficient file types for faster speed +* Convert images to more efficient file types for faster page loader +* Apply transformations to images such as `resize`, `crop`, `rotate`, `blur`, and `flip` * Cache commonly requested assets for the best performance Turning: diff --git a/docs/docs/loader.md b/docs/docs/loader.md index fb4d9df..1efb957 100644 --- a/docs/docs/loader.md +++ b/docs/docs/loader.md @@ -23,7 +23,7 @@ export const loader: LoaderFunction = ({ request }) => { ## Options | Name | Type | Required | Default | Description | |:----------------------:|:------------------------------:|:--------:|:-------------------:|:----------------------------------------------------------------------------------------------------------------:| -| selfUrl | string | X | | The URL of the local server. | +| selfUrl | string | Yes | | The URL of the local server. | | resolver | Resolver | | fetchResolver | The image resolver to use. | | transformer | Transformer or null | | pureTransformer | A transformer function that handles mutations of images. If this option is null, transformation will be skipped. | | useFallbackFormat | boolean | | true | If RemixImage should fallback to the fallback mime type if the output type is not supported. | diff --git a/docs/docs/tutorial-basics/use-component.md b/docs/docs/tutorial-basics/use-component.md index e71a7d6..05842fc 100644 --- a/docs/docs/tutorial-basics/use-component.md +++ b/docs/docs/tutorial-basics/use-component.md @@ -27,13 +27,16 @@ import Image from "remix-image"; ``` ## PropTypes -| Name | Type | Required | Default | Description | -|:-----------:|:------------------------------------------------------------------:|:--------:|:------------:|:------------------------------------------------------------------------------------------------------------------------------------------------:| -| loaderUrl | string | | "/api/image" | The path of the image loader resource route. The `loaderUrl` prop is optional if the resource route has been created at the path `"/api/image"`. | -| responsive | { size: { width: number; height: number; }; maxWidth?: number; }[] | | [] | An array of responsive sizes. The resource route is not called if this prop is not provided. | -| options | TransformOptions | | {} | TransformOptions that can be used to override the defaults provided to the loader. | -| dprVariants | number or number[] | | [1] | Different DPR variants to generate images for. This value will always be merged into an array with value [1]. | +| Name | Type | Required | Default | Description | +|:-----------:|:------------------------------------------------------------------:|:--------------------------------------------------------------------:|:-------------------:|:------------------------------------------------------------------------------------------------------------------------------------------------:| +| loaderUrl | string | Yes when using `cloudinaryLoader` or `imgixLoader` for `loader` prop | `"/api/image"` | The path of the image loader resource route. The `loaderUrl` prop is optional if the resource route has been created at the path `"/api/image"`. | +| loader | ClientLoader | | `remixImageLoader` | The ClientLoader to use for generating the transformed image. | +| responsive | { size: { width: number; height: number; }; maxWidth?: number; }[] | | `[]` | An array of responsive sizes. The resource route is not called if this prop is not provided. | +| options | TransformOptions | | `{}` | TransformOptions that can be used to override the defaults provided to the loader. | +| dprVariants | number or number[] | | `[1]` | Different DPR variants to generate images for. This value will always be merged into an array with value [1]. | +### ClientLoader Options +By default, `remixImageLoader` is used. If you would like to use an external ClientLoader, please refer to the [ClientLoader documentation](../client-loader.md). **Note**: The `Image` component extends the native `img` element, so any props used with `img` can be provided to the `Image` component. diff --git a/examples/basic/app/routes/index.tsx b/examples/basic/app/routes/index.tsx index a775f48..a7ca2f2 100644 --- a/examples/basic/app/routes/index.tsx +++ b/examples/basic/app/routes/index.tsx @@ -1,5 +1,5 @@ import React from "react"; -import Image, { MimeType } from "remix-image"; +import Image, { MimeType, cloudflareLoader } from "remix-image"; const images = [ { @@ -75,6 +75,7 @@ const IndexPage: React.FC = () => ( diff --git a/package-lock.json b/package-lock.json index ddf49dd..75d74e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22902,7 +22902,7 @@ } }, "packages/remix-image": { - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "clsx": "^1.1.1", diff --git a/packages/remix-image/CHANGELOG.md b/packages/remix-image/CHANGELOG.md index d9a81d4..ca3fd77 100644 --- a/packages/remix-image/CHANGELOG.md +++ b/packages/remix-image/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Added Client Loader functions to enable the use of external image transformation services + +### Changed + +- Added optional `loader` prop to `Image` and `BaseImage` components +- **BREAKING**: Moved `loaderUrl` argument of `useResponsiveImage` hook to the end +- Added optional `loader` argument to the end of `useResponsiveImage` hook + ## [1.2.0] - 2022-06-30 ### Changed diff --git a/packages/remix-image/src/components/Image/BaseImage.tsx b/packages/remix-image/src/components/Image/BaseImage.tsx index 826ab67..6f58a1f 100644 --- a/packages/remix-image/src/components/Image/BaseImage.tsx +++ b/packages/remix-image/src/components/Image/BaseImage.tsx @@ -1,11 +1,13 @@ import * as React from "react"; import { useResponsiveImage } from "../../hooks"; +import { remixImageLoader } from "../../loaders"; import { BaseImageProps } from "./types"; export const BaseImage = React.forwardRef( ( { loaderUrl = "/api/image", + loader = remixImageLoader, responsive = [], options = {}, dprVariants = 1, @@ -17,10 +19,11 @@ export const BaseImage = React.forwardRef( ) => { const responsiveProps = useResponsiveImage( imgProps, - loaderUrl, responsive, options, - dprVariants + dprVariants, + loaderUrl, + loader ); return ( diff --git a/packages/remix-image/src/components/Image/Image.tsx b/packages/remix-image/src/components/Image/Image.tsx index 7c9d02b..6ab6e77 100644 --- a/packages/remix-image/src/components/Image/Image.tsx +++ b/packages/remix-image/src/components/Image/Image.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; import * as React from "react"; import { useResponsiveImage } from "../../hooks"; +import { remixImageLoader } from "../../loaders"; import { encodeQuery } from "../../utils/url"; import { ImgElementWithDataProp, @@ -15,6 +16,7 @@ export const Image = React.memo( { src, loaderUrl = "/api/image", + loader = remixImageLoader, responsive = [], options = {}, dprVariants = 1, @@ -43,10 +45,11 @@ export const Image = React.memo( ) => { const responsiveProps = useResponsiveImage( { src }, - loaderUrl, responsive, options, - dprVariants + dprVariants, + loaderUrl, + loader ); const imageStyle = React.useMemo(() => { diff --git a/packages/remix-image/src/components/Image/types.ts b/packages/remix-image/src/components/Image/types.ts index 0a07915..96b2e17 100644 --- a/packages/remix-image/src/components/Image/types.ts +++ b/packages/remix-image/src/components/Image/types.ts @@ -1,5 +1,5 @@ import * as React from "react"; -import { ResponsiveSize, SizelessOptions } from "../../types"; +import { ClientLoader, ResponsiveSize, SizelessOptions } from "../../types"; export type OnLoadingComplete = (result: { naturalWidth: number; @@ -8,6 +8,7 @@ export type OnLoadingComplete = (result: { export interface BaseImageProps extends React.ComponentPropsWithRef<"img"> { loaderUrl?: string; + loader?: ClientLoader; responsive?: ResponsiveSize[]; options?: SizelessOptions; dprVariants?: number | number[]; diff --git a/packages/remix-image/src/hooks/responsiveImage.ts b/packages/remix-image/src/hooks/responsiveImage.ts index 960ee81..54824f1 100644 --- a/packages/remix-image/src/hooks/responsiveImage.ts +++ b/packages/remix-image/src/hooks/responsiveImage.ts @@ -1,7 +1,8 @@ import * as React from "react"; +import { remixImageLoader } from "../loaders"; +import { ClientLoader } from "../types/client"; import type { ResponsiveSize } from "../types/image"; import type { SizelessOptions } from "../types/transformer"; -import { encodeQuery } from "../utils/url"; export type ImageSource = { src?: string; @@ -23,10 +24,11 @@ const sizeConverter = (resp: ResponsiveSize): string => export function useResponsiveImage( image: ImageSource, - loaderUrl: string, responsive: ResponsiveSize[], options: SizelessOptions = {}, - dprVariants: number | number[] = [1] + dprVariants: number | number[] = [1], + loaderUrl = "/api/image", + loaderFunction: ClientLoader = remixImageLoader ): ResponsiveHookResult { return React.useMemo(() => { let largestSrc = image.src || ""; @@ -41,8 +43,7 @@ export function useResponsiveImage( for (const multiplier of multipliers) { for (const { size } of responsive) { - const srcSetUrl = encodeQuery(loaderUrl, { - src: encodeURI(image.src || ""), + const srcSetUrl = loaderFunction(image.src || "", loaderUrl, { width: typeof size.width === "number" ? size.width * multiplier @@ -82,5 +83,5 @@ export function useResponsiveImage( // This bug cannot be reproduced in Chrome or Firefox. src: largestSrc, }; - }, [image.src, loaderUrl, responsive, options, dprVariants]); + }, [image.src, loaderUrl, loaderFunction, responsive, options, dprVariants]); } diff --git a/packages/remix-image/src/index.tsx b/packages/remix-image/src/index.tsx index 12f165c..8be38c3 100644 --- a/packages/remix-image/src/index.tsx +++ b/packages/remix-image/src/index.tsx @@ -1,4 +1,5 @@ export { Image as default } from "./components"; export * from "./components"; export * from "./hooks"; +export * from "./loaders"; export * from "./types"; diff --git a/packages/remix-image/src/loaders/cloudflareImagesLoader.ts b/packages/remix-image/src/loaders/cloudflareImagesLoader.ts new file mode 100644 index 0000000..1b0affd --- /dev/null +++ b/packages/remix-image/src/loaders/cloudflareImagesLoader.ts @@ -0,0 +1,107 @@ +import { ClientLoader } from "../types/client"; +import { ImagePosition } from "../types/transformer"; + +const normalizeSrc = (src: string) => { + return src.startsWith("/") ? src.slice(1) : src; +}; + +const positionMap: Record = { + "center bottom": "0.5x1", + "center center": "0.5x0.5", + "center top": "0.5x0", + "left bottom": "0x1", + "left center": "0x0.5", + "left top": "0x0", + "right bottom": "1x1", + "right center": "1x0.5", + "right top": "1x0", + bottom: "bottom", + center: "0.5x0.5", + left: "left", + right: "right", + top: "top", +}; + +export const cloudflareImagesLoader: ClientLoader = ( + src, + _loaderUrl, + loaderOptions +) => { + const params = []; + + if (loaderOptions.background) { + params.push( + `background=rgba(${loaderOptions.background[0]},${ + loaderOptions.background[1] + },${loaderOptions.background[2]},${Number( + loaderOptions.background[3] / 255 + ).toFixed(2)})` + ); + } + + if (loaderOptions.crop) { + params.push( + `trim=${loaderOptions.crop.y};${ + loaderOptions.crop.x + loaderOptions.crop.width + };${loaderOptions.crop.height};${loaderOptions.crop.x}` + ); + } + + if (loaderOptions.rotate) { + params.push(`rotate=${loaderOptions.rotate}`); + } + + if (loaderOptions.blurRadius) { + params.push(`blur=${loaderOptions.blurRadius}`); + } + + if (loaderOptions.fit === "outside") { + params.push(`fit=contain`); + + if (loaderOptions.width && loaderOptions.height) { + params.push( + `width=${Math.max(loaderOptions.width, loaderOptions.height)}` + ); + params.push( + `height=${Math.max(loaderOptions.width, loaderOptions.height)}` + ); + } else if (loaderOptions.width) { + params.push(`width=${loaderOptions.width}`); + } else if (loaderOptions.height) { + params.push(`height=${loaderOptions.height}`); + } + } else { + if (loaderOptions.fit === "contain") { + params.push(`fit=pad`); + } else if (loaderOptions.fit === "cover") { + params.push(`fit=cover`); + } else if (loaderOptions.fit === "fill") { + params.push(`fit=fill`); + } else if (loaderOptions.fit === "inside") { + params.push(`fit=contain`); + } + + if (loaderOptions.width) { + params.push(`width=${loaderOptions.width}`); + } + + if (loaderOptions.height) { + params.push(`height=${loaderOptions.height}`); + } + } + + if (loaderOptions.position) { + params.push( + `gravity=${ + positionMap[loaderOptions.position as ImagePosition] || "0.5x0.5" + }` + ); + } + + if (loaderOptions.quality) { + params.push(`quality=${loaderOptions.quality}`); + } + + const paramsString = params.join(","); + return `/cdn-cgi/image/${paramsString}/${normalizeSrc(src)}`; +}; diff --git a/packages/remix-image/src/loaders/cloudinaryLoader.ts b/packages/remix-image/src/loaders/cloudinaryLoader.ts new file mode 100644 index 0000000..bddb0df --- /dev/null +++ b/packages/remix-image/src/loaders/cloudinaryLoader.ts @@ -0,0 +1,113 @@ +import { ClientLoader } from "../types/client"; +import { ImagePosition } from "../types/transformer"; + +const normalizeSrc = (src: string) => { + return src.startsWith("/") ? src.slice(1) : src; +}; + +const numberToHex = (num: number): string => + ("0" + Number(num).toString(16)).slice(-2).toUpperCase(); + +const positionMap: Record = { + "center bottom": "south", + "center center": "center", + "center top": "north", + "left bottom": "south_west", + "left center": "west", + "left top": "north_west", + "right bottom": "south_east", + "right center": "east", + "right top": "north_east", + bottom: "south", + center: "center", + left: "west", + right: "east", + top: "north", +}; + +export const cloudinaryLoader: ClientLoader = ( + src, + loaderUrl, + loaderOptions +) => { + const params = []; + + if (loaderOptions.background) { + params.push( + `b_${ + numberToHex(loaderOptions.background[0]) + + numberToHex(loaderOptions.background[1]) + + numberToHex(loaderOptions.background[2]) + + numberToHex(loaderOptions.background[3]) + }` + ); + } + + if (loaderOptions.crop) { + params.push(`c_crop`); + params.push(`g_north_west`); + params.push(`h_${loaderOptions.crop.height}`); + params.push(`w_${loaderOptions.crop.width}`); + params.push(`x_${loaderOptions.crop.x}`); + params.push(`y_${loaderOptions.crop.y}`); + } + + if (loaderOptions.rotate) { + params.push(`a_${loaderOptions.rotate}`); + } + + if (loaderOptions.blurRadius) { + params.push(`e_blur:${loaderOptions.blurRadius}`); + } + + if (loaderOptions.fit === "outside") { + params.push("c_fit"); + + if (loaderOptions.width && loaderOptions.height) { + params.push(`w_${Math.max(loaderOptions.width, loaderOptions.height)}`); + params.push(`h_${Math.max(loaderOptions.width, loaderOptions.height)}`); + } else if (loaderOptions.width) { + params.push(`w_${loaderOptions.width}`); + } else if (loaderOptions.height) { + params.push(`h_${loaderOptions.height}`); + } + } else { + if (loaderOptions.fit === "contain") { + params.push("c_pad"); + } else if (loaderOptions.fit === "cover") { + params.push("c_fill"); + } else if (loaderOptions.fit === "fill") { + params.push("c_scale"); + } else if (loaderOptions.fit === "inside") { + params.push("c_fit"); + } + + if (loaderOptions.width) { + params.push(`w_${loaderOptions.width}`); + } + + if (loaderOptions.height) { + params.push(`h_${loaderOptions.height}`); + } + } + + if (loaderOptions.position) { + params.push( + `g_${positionMap[loaderOptions.position as ImagePosition] || "center"}` + ); + } + + params.push(`q_${loaderOptions.quality || "auto"}`); + + if (loaderOptions.contentType) { + params.push( + "f_", + loaderOptions.contentType.replace("image/", "").replace("jpeg", "jpg") + ); + } else { + params.push("f_auto"); + } + + const paramsString = params.join(",") + "/"; + return `${loaderUrl}${paramsString}${normalizeSrc(src)}`; +}; diff --git a/packages/remix-image/src/loaders/imgixLoader.ts b/packages/remix-image/src/loaders/imgixLoader.ts new file mode 100644 index 0000000..320ff5c --- /dev/null +++ b/packages/remix-image/src/loaders/imgixLoader.ts @@ -0,0 +1,81 @@ +import { ClientLoader } from "../types/client"; + +const normalizeSrc = (src: string) => { + return src.startsWith("/") ? src.slice(1) : src; +}; + +const numberToHex = (num: number): string => + ("0" + Number(num).toString(16)).slice(-2).toUpperCase(); + +export const imgixLoader: ClientLoader = (src, loaderUrl, loaderOptions) => { + const url = new URL(`${loaderUrl}${normalizeSrc(src)}`); + const params = url.searchParams; + + if (loaderOptions.width) { + params.set("w", loaderOptions.width.toString()); + } + + if (loaderOptions.height) { + params.set("h", loaderOptions.height.toString()); + } + + if (loaderOptions.background) { + params.set( + "bg", + numberToHex(loaderOptions.background[3]) + + numberToHex(loaderOptions.background[0]) + + numberToHex(loaderOptions.background[1]) + + numberToHex(loaderOptions.background[2]) + ); + } + + if (loaderOptions.crop) { + params.set( + "rect", + `${loaderOptions.crop.x},${loaderOptions.crop.y},${loaderOptions.crop.width},${loaderOptions.crop.height}` + ); + } + + if (loaderOptions.flip === "horizontal") { + params.set("flip", "h"); + } else if (loaderOptions.flip === "vertical") { + params.set("flip", "v"); + } else if (loaderOptions.flip === "both") { + params.set("flip", "hv"); + } + + if (loaderOptions.rotate) { + params.set("rot", loaderOptions.rotate.toString()); + } + + if (loaderOptions.blurRadius) { + params.set("blur", loaderOptions.blurRadius.toString()); + } + + if (loaderOptions.fit === "contain") { + params.set("fit", "fill"); + } else if (loaderOptions.fit === "cover") { + params.set("fit", "crop"); + } else if (loaderOptions.fit === "fill") { + params.set("fit", "scale"); + } else if (loaderOptions.fit === "inside") { + params.set("fit", "fillmax"); + } else if (loaderOptions.fit === "outside") { + params.set("fit", "max"); + } + + if (loaderOptions.quality) { + params.set("q", loaderOptions.quality.toString()); + } + + if (loaderOptions.contentType) { + params.set( + "format", + loaderOptions.contentType.replace("image/", "").replace("jpeg", "jpg") + ); + } else { + params.set("auto", "format"); + } + + return url.href; +}; diff --git a/packages/remix-image/src/loaders/index.ts b/packages/remix-image/src/loaders/index.ts new file mode 100644 index 0000000..c9feb18 --- /dev/null +++ b/packages/remix-image/src/loaders/index.ts @@ -0,0 +1,4 @@ +export * from "./cloudflareImagesLoader"; +export * from "./cloudinaryLoader"; +export * from "./imgixLoader"; +export * from "./remixImageLoader"; diff --git a/packages/remix-image/src/loaders/remixImageLoader.ts b/packages/remix-image/src/loaders/remixImageLoader.ts new file mode 100644 index 0000000..18d8207 --- /dev/null +++ b/packages/remix-image/src/loaders/remixImageLoader.ts @@ -0,0 +1,13 @@ +import { ClientLoader } from "../types/client"; +import { encodeQuery } from "../utils/url"; + +export const remixImageLoader: ClientLoader = ( + src, + loaderUrl, + loaderOptions +) => { + return encodeQuery(loaderUrl, { + src: encodeURI(src), + ...loaderOptions, + }); +}; diff --git a/packages/remix-image/src/types/client.ts b/packages/remix-image/src/types/client.ts new file mode 100644 index 0000000..26a09dd --- /dev/null +++ b/packages/remix-image/src/types/client.ts @@ -0,0 +1,7 @@ +import { TransformOptions } from "./transformer"; + +export type ClientLoader = ( + src: string, + loaderUrl: string, + loaderOptions: TransformOptions +) => string; diff --git a/packages/remix-image/src/types/index.ts b/packages/remix-image/src/types/index.ts index 9cadf37..039bebc 100644 --- a/packages/remix-image/src/types/index.ts +++ b/packages/remix-image/src/types/index.ts @@ -1,4 +1,5 @@ export * from "./cache"; +export * from "./client"; export * from "./error"; export * from "./file"; export * from "./image";