diff --git a/data/domains.json b/data/domains.json index 2ae07b9..f13205f 100644 --- a/data/domains.json +++ b/data/domains.json @@ -11,5 +11,6 @@ "assets.caisy.io": "bunny", "images.contentstack.io": "contentstack", "ucarecdn.com": "uploadcare", - "imagedelivery.net": "cloudflare_images" + "imagedelivery.net": "cloudflare_images", + "wsrv.nl": "wsrv" } diff --git a/demo/src/examples.json b/demo/src/examples.json index 25b43b0..bffd3d8 100644 --- a/demo/src/examples.json +++ b/demo/src/examples.json @@ -95,5 +95,9 @@ "appwrite": [ "Appwrite", "https://cloud.appwrite.io/v1/storage/buckets/unpic/files/679d127100131f67b6d8/view?project=unpic-test" + ], + "wsrv": [ + "wsrv.nl", + "https://wsrv.nl/?url=images.unsplash.com/photo-1560807707-8cc77767d783" ] } diff --git a/deno.jsonc b/deno.jsonc index 222fc93..cbd775d 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -29,7 +29,8 @@ "./providers/supabase": "./src/providers/supabase.ts", "./providers/uploadcare": "./src/providers/uploadcare.ts", "./providers/vercel": "./src/providers/vercel.ts", - "./providers/wordpress": "./src/providers/wordpress.ts" + "./providers/wordpress": "./src/providers/wordpress.ts", + "./providers/wsrv": "./src/providers/wsrv.ts" }, "tasks": { "build:npm": "deno run --allow-all scripts/build_npm.ts" diff --git a/src/async.ts b/src/async.ts index 0ce472b..bb77294 100644 --- a/src/async.ts +++ b/src/async.ts @@ -40,6 +40,7 @@ const asyncProviderMap: AsyncProviderMap = { uploadcare: () => import("./providers/uploadcare.ts"), vercel: () => import("./providers/vercel.ts"), wordpress: () => import("./providers/wordpress.ts"), + wsrv: () => import("./providers/wsrv.ts"), }; /** diff --git a/src/extract.ts b/src/extract.ts index 1e82549..2ec907a 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -33,6 +33,7 @@ import { extract as supabase } from "./providers/supabase.ts"; import { extract as uploadcare } from "./providers/uploadcare.ts"; import { extract as vercel } from "./providers/vercel.ts"; import { extract as wordpress } from "./providers/wordpress.ts"; +import { extract as wsrv } from "./providers/wsrv.ts"; export const parsers: URLExtractorMap = { appwrite, @@ -62,6 +63,7 @@ export const parsers: URLExtractorMap = { uploadcare, vercel, wordpress, + wsrv, } as const; /** diff --git a/src/providers/types.ts b/src/providers/types.ts index e5aa982..3166818 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -37,6 +37,7 @@ import type { SupabaseOperations } from "./supabase.ts"; import type { UploadcareOperations, UploadcareOptions } from "./uploadcare.ts"; import type { VercelOperations, VercelOptions } from "./vercel.ts"; import type { WordPressOperations } from "./wordpress.ts"; +import type { WsrvOperations } from "./wsrv.ts"; export interface ProviderOperations { appwrite: AppwriteOperations; @@ -66,6 +67,7 @@ export interface ProviderOperations { uploadcare: UploadcareOperations; vercel: VercelOperations; wordpress: WordPressOperations; + wsrv: WsrvOperations; } export interface ProviderOptions { @@ -96,6 +98,7 @@ export interface ProviderOptions { uploadcare: UploadcareOptions; vercel: VercelOptions; wordpress: undefined; + wsrv: undefined; } export type URLExtractorMap = { diff --git a/src/providers/wsrv.test.ts b/src/providers/wsrv.test.ts new file mode 100644 index 0000000..0f71871 --- /dev/null +++ b/src/providers/wsrv.test.ts @@ -0,0 +1,136 @@ +import { extract, generate, transform } from "./wsrv.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const img = "https://example.com/image.jpg"; + +Deno.test("wsrv extract", async (t) => { + await t.step("should parse a basic wsrv URL", () => { + const { operations, src } = extract( + "https://wsrv.nl/?url=example.com/image.jpg", + ) ?? {}; + assertEquals(src, "https://example.com/image.jpg"); + assertEquals(operations, {}); + }); + + await t.step("should parse operations from URL", () => { + const { operations, src } = extract( + "https://wsrv.nl/?url=example.com/image.jpg&w=300&h=200&q=85&output=webp&fit=cover", + ) ?? {}; + assertEquals(src, "https://example.com/image.jpg"); + assertEquals(operations?.width, 300); + assertEquals(operations?.height, 200); + assertEquals(operations?.quality, 85); + assertEquals(operations?.format, "webp"); + assertEquals(operations?.fit, "cover"); + }); + + await t.step("should return null for non-wsrv URLs", () => { + const result = extract("https://example.com/image.jpg"); + assertEquals(result, null); + }); +}); + +Deno.test("wsrv generate", async (t) => { + await t.step("should format a URL with width and height", () => { + const result = generate(img, { + width: 300, + height: 200, + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&h=200&fit=cover", + ); + }); + + await t.step("should not set height if not provided", () => { + const result = generate(img, { width: 300 }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=cover", + ); + }); + + await t.step("should format a URL with quality", () => { + const result = generate(img, { width: 600, quality: 80 }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=600&q=80&fit=cover", + ); + }); + + await t.step("should format a URL with format conversion", () => { + const result = generate(img, { width: 400, format: "webp" }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=400&output=webp&fit=cover", + ); + }); + + await t.step("should apply default fit=cover", () => { + const result = generate(img, { width: 300 }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=cover", + ); + }); + + await t.step("should allow overriding fit parameter", () => { + const result = generate(img, { width: 300, fit: "contain" }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=contain", + ); + }); + + await t.step("should format a URL with provider-specific operations", () => { + const result = generate(img, { width: 300, dpr: 2, blur: 5 }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&dpr=2&blur=5&fit=cover", + ); + }); +}); + +Deno.test("wsrv transform", async (t) => { + await t.step("should format a URL with width and height", () => { + const result = transform(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=200&h=100&fit=cover", + ); + }); + + await t.step("should not set height if not provided", () => { + const result = transform(img, { width: 200 }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=200&fit=cover", + ); + }); + + await t.step("should apply defaults to URL", () => { + const result = transform( + "https://wsrv.nl/?url=example.com/image.jpg&w=300", + {}, + ); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=cover", + ); + }); + + await t.step("should override existing parameters", () => { + const result = transform( + "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=inside", + { fit: "contain" }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=contain", + ); + }); +}); diff --git a/src/providers/wsrv.ts b/src/providers/wsrv.ts new file mode 100644 index 0000000..968ca73 --- /dev/null +++ b/src/providers/wsrv.ts @@ -0,0 +1,188 @@ +import type { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +export type WsrvFormats = + | ImageFormat + | "gif" + | "tiff" + | "json" + // deno-lint-ignore ban-types + | (string & {}); + +/** + * Image transform options for wsrv.nl image processing. + */ +export interface WsrvOperations extends Operations { + /** Sets the width of the image in pixels. Alias for width. */ + w?: number; + + /** Sets the height of the image in pixels. Alias for height. */ + h?: number; + + /** Output format. Alias for format. */ + output?: WsrvFormats; + + /** Quality level (0-100). Alias for quality. */ + q?: number; + + /** Sets the output density of the image (1-8). */ + dpr?: number; + + /** + * Sets how the image is fitted to its target dimensions. + * - `inside`: (default) Resize to be as large as possible while ensuring dimensions are <= specified + * - `outside`: Resize to be as small as possible while ensuring dimensions are >= specified + * - `cover`: Crop to cover both provided dimensions + * - `fill`: Ignore aspect ratio and stretch to both dimensions + * - `contain`: Embed within both dimensions (use with cbg for background) + */ + fit?: "inside" | "outside" | "cover" | "fill" | "contain"; + + /** Do not enlarge if width or height are already less than specified dimensions. */ + we?: boolean; + + /** Alignment position for the image. */ + a?: + | "center" + | "top" + | "right" + | "bottom" + | "left" + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "entropy" + | "attention"; + + /** Crops the image to specific dimensions before any other operations. */ + crop?: string; + + /** Trim 'boring' pixels from all edges. */ + trim?: number; + + /** Sets the mask type from a predefined list (circle, ellipse, triangle, etc.). */ + mask?: string; + + /** Mask background color. */ + mbg?: string; + + /** Mirrors the image vertically. */ + flip?: boolean; + + /** Mirrors the image horizontally. */ + flop?: boolean; + + /** Rotates the image by specified degrees. */ + ro?: number; + + /** Background color for transparent images. */ + bg?: string; + + /** Blur radius (0.3-1000). */ + blur?: number; + + /** Contrast adjustment (-100 to 100). */ + con?: number; + + /** Filter to apply (greyscale, sepia, negate, etc.). */ + filt?: "greyscale" | "sepia" | "duotone" | "negate"; + + /** Saturation adjustment (-100 to 100). */ + sat?: number; + + /** Hue rotation (0-360). */ + hue?: number; + + /** Sharpening amount. */ + sharp?: number; + + /** Tint the image. */ + tint?: string; + + /** Contain background color (use with fit=contain). */ + cbg?: string; + + /** PNG compression level (0-9, default 6). */ + l?: number; + + /** WebP lossless compression. */ + ll?: boolean; + + /** Add interlacing/progressive scan. */ + il?: boolean; + + /** Number of pages to render (-1 for all, useful for animated images). */ + n?: number; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + WsrvOperations +>({ + keyMap: { + width: "w", + height: "h", + format: "output", + quality: "q", + }, + defaults: { + fit: "cover", + }, +}); + +export const extract: URLExtractor<"wsrv"> = (url) => { + const urlObj = toUrl(url); + + const srcParam = urlObj.searchParams.get("url"); + if (!srcParam) { + return null; + } + + let src = srcParam; + if (!src.startsWith("http://") && !src.startsWith("https://")) { + src = "https://" + src; + } + + urlObj.searchParams.delete("url"); + + const operations = operationsParser(urlObj); + + return { + src, + operations, + }; +}; + +export const generate: URLGenerator<"wsrv"> = (src, operations) => { + const url = new URL("https://wsrv.nl/"); + + const srcUrl = typeof src === "string" ? src : src.toString(); + const cleanSrc = srcUrl.replace(/^https?:\/\//, ""); + url.searchParams.set("url", cleanSrc); + + const params = operationsGenerator(operations); + const searchParams = new URLSearchParams(params); + for (const [key, value] of searchParams) { + if (key !== "url") { + url.searchParams.set(key, value); + } + } + + return toCanonicalUrlString(url); +}; + +export const transform: URLTransformer<"wsrv"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/transform.ts b/src/transform.ts index 4554fb5..d9bc03b 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -26,6 +26,7 @@ import { transform as supabase } from "./providers/supabase.ts"; import { transform as uploadcare } from "./providers/uploadcare.ts"; import { transform as vercel } from "./providers/vercel.ts"; import { transform as wordpress } from "./providers/wordpress.ts"; +import { transform as wsrv } from "./providers/wsrv.ts"; import type { ImageCdn, URLTransformer, @@ -65,6 +66,7 @@ const transformerMap: URLTransformerMap = { uploadcare, vercel, wordpress, + wsrv, } as const; /** * Returns a transformer function if the given CDN is supported diff --git a/src/types.ts b/src/types.ts index 92e20ad..3492606 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,7 +59,8 @@ export type ImageCdn = | "uploadcare" | "supabase" | "hygraph" - | "appwrite"; + | "appwrite" + | "wsrv"; export const SupportedProviders: Record = { appwrite: "Appwrite", @@ -89,6 +90,7 @@ export const SupportedProviders: Record = { uploadcare: "Uploadcare", vercel: "Vercel", wordpress: "WordPress", + wsrv: "wsrv.nl", } as const; export type OperationFormatter = (