From a4953151d5d0d25de708897d4233a3c5f74c11a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20=C3=85berg=20Kultalahti?= Date: Tue, 4 Nov 2025 21:23:11 +0100 Subject: [PATCH 1/3] Add wsrv.nl provider --- data/domains.json | 3 +- demo/src/examples.json | 4 + deno.jsonc | 3 +- src/extract.ts | 2 + src/providers/types.ts | 3 + src/providers/wsrv.test.ts | 241 +++++++++++++++++++++++++ src/providers/wsrv.ts | 358 +++++++++++++++++++++++++++++++++++++ src/transform.ts | 2 + src/types.ts | 4 +- 9 files changed, 617 insertions(+), 3 deletions(-) create mode 100644 src/providers/wsrv.test.ts create mode 100644 src/providers/wsrv.ts 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/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..c59831c --- /dev/null +++ b/src/providers/wsrv.test.ts @@ -0,0 +1,241 @@ +import { extract, generate, transform } from "./wsrv.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const testImage = "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 a wsrv URL with width and height", () => { + const { operations, src } = extract( + "https://wsrv.nl/?url=example.com/image.jpg&w=300&h=200", + ) ?? {}; + assertEquals(src, "https://example.com/image.jpg"); + assertEquals(operations?.width, 300); + assertEquals(operations?.height, 200); + assertEquals(operations?.w, 300); + assertEquals(operations?.h, 200); + }); + + await t.step("should parse a wsrv URL with quality and format", () => { + const { operations, src } = extract( + "https://wsrv.nl/?url=example.com/image.jpg&q=85&output=webp", + ) ?? {}; + assertEquals(src, "https://example.com/image.jpg"); + assertEquals(operations?.quality, 85); + assertEquals(operations?.format, "webp"); + assertEquals(operations?.q, 85); + assertEquals(operations?.output, "webp"); + }); + + await t.step("should parse a wsrv URL with fit parameter", () => { + const { operations } = extract( + "https://wsrv.nl/?url=example.com/image.jpg&fit=cover", + ) ?? {}; + assertEquals(operations?.fit, "cover"); + }); + + await t.step("should parse a wsrv URL with dpr", () => { + const { operations } = extract( + "https://wsrv.nl/?url=example.com/image.jpg&dpr=2", + ) ?? {}; + assertEquals(operations?.dpr, 2); + }); + + await t.step("should parse a wsrv URL with boolean parameters", () => { + const { operations } = extract( + "https://wsrv.nl/?url=example.com/image.jpg&flip=1&we=1", + ) ?? {}; + assertEquals(operations?.flip, true); + assertEquals(operations?.we, true); + }); + + await t.step("should parse a wsrv URL with adjustment parameters", () => { + const { operations } = extract( + "https://wsrv.nl/?url=example.com/image.jpg&blur=5&con=20&sat=-10", + ) ?? {}; + assertEquals(operations?.blur, 5); + assertEquals(operations?.con, 20); + assertEquals(operations?.sat, -10); + }); + + 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 generate a basic wsrv URL with width", () => { + const result = generate(testImage, { + width: 300, + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=cover", + ); + }); + + await t.step("should generate a wsrv URL with width and height", () => { + const result = generate(testImage, { + width: 300, + height: 200, + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&h=200&fit=cover", + ); + }); + + await t.step("should generate a wsrv URL with format", () => { + const result = generate(testImage, { + width: 300, + format: "webp", + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&output=webp&fit=cover", + ); + }); + + await t.step("should generate a wsrv URL with quality", () => { + const result = generate(testImage, { + width: 300, + quality: 85, + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&q=85&fit=cover", + ); + }); + + await t.step("should apply default fit=cover", () => { + const result = generate(testImage, { + 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(testImage, { + width: 300, + fit: "contain", + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=contain", + ); + }); + + await t.step("should generate URL with dpr", () => { + const result = generate(testImage, { + width: 300, + dpr: 2, + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&dpr=2&fit=cover", + ); + }); + + await t.step("should generate URL with boolean parameters", () => { + const result = generate(testImage, { + width: 300, + flip: true, + we: true, + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&flip=1&we=1&fit=cover", + ); + }); + + await t.step("should generate URL with adjustment parameters", () => { + const result = generate(testImage, { + width: 300, + blur: 5, + con: 20, + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&blur=5&con=20&fit=cover", + ); + }); + + await t.step("should strip protocol from source URL", () => { + const result = generate("https://example.com/image.jpg", { + width: 300, + }); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=cover", + ); + }); +}); + +Deno.test("wsrv transform", async (t) => { + await t.step("should transform an existing wsrv URL", () => { + const result = transform( + "https://wsrv.nl/?url=example.com/image.jpg&w=300", + { width: 500 }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=500&fit=cover", + ); + }); + + await t.step("should add format to existing wsrv URL", () => { + const result = transform( + "https://wsrv.nl/?url=example.com/image.jpg&w=300", + { format: "webp" }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&output=webp&fit=cover", + ); + }); + + await t.step("should preserve existing operations when transforming", () => { + const result = transform( + "https://wsrv.nl/?url=example.com/image.jpg&w=300&dpr=2", + { height: 200 }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://wsrv.nl/?url=example.com/image.jpg&w=300&dpr=2&h=200&fit=cover", + ); + }); + + await t.step("should apply defaults when transforming with empty operations", () => { + 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 fit parameter", () => { + 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..932a84c --- /dev/null +++ b/src/providers/wsrv.ts @@ -0,0 +1,358 @@ +import type { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + 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. */ + w?: number; + + /** Sets the height of the image in pixels. */ + h?: 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; + + /** Pre-resize crop behavior. */ + precrop?: boolean; + + /** Trim 'boring' pixels from all edges. */ + trim?: number; + + /** Sets the mask type from a predefined list (circle, ellipse, triangle, etc.). */ + mask?: string; + + /** Removes whitespace from mask. */ + mtrim?: boolean; + + /** 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; + + /** Rotation background color. */ + rbg?: string; + + /** 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"; + + /** Gamma adjustment (1.0-3.0). */ + gam?: number; + + /** Modulate brightness, saturation, hue. */ + mod?: string; + + /** 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; + + /** Output format (jpg, png, gif, tiff, webp, json). */ + output?: WsrvFormats; + + /** Quality level (0-100, default 80). */ + q?: number; + + /** 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; + + /** Page number for multi-page images. */ + page?: number; + + /** Default image URL to use on error. */ + default?: string; + + /** Custom filename for download. */ + filename?: string; + + /** Cache duration in seconds. */ + maxage?: string; + + /** Base64 encoding format. */ + encoding?: "base64"; +} + +/** + * Converts standard operations to wsrv parameters + */ +function operationsToParams(operations: WsrvOperations): URLSearchParams { + const params = new URLSearchParams(); + + // Map standard operations + if (operations.width !== undefined) { + params.set("w", String(operations.width)); + } + if (operations.height !== undefined) { + params.set("h", String(operations.height)); + } + if (operations.format !== undefined) { + params.set("output", String(operations.format)); + } + if (operations.quality !== undefined) { + params.set("q", String(operations.quality)); + } + + // Add all other wsrv-specific operations + const wsrvKeys: (keyof WsrvOperations)[] = [ + "w", + "h", + "dpr", + "fit", + "we", + "a", + "crop", + "precrop", + "trim", + "mask", + "mtrim", + "mbg", + "flip", + "flop", + "ro", + "rbg", + "bg", + "blur", + "con", + "filt", + "gam", + "mod", + "sat", + "hue", + "sharp", + "tint", + "cbg", + "output", + "q", + "l", + "ll", + "il", + "n", + "page", + "default", + "filename", + "maxage", + "encoding", + ]; + + for (const key of wsrvKeys) { + const value = operations[key]; + if (value !== undefined && !params.has(key)) { + if (typeof value === "boolean") { + params.set(key, "1"); + } else { + params.set(key, String(value)); + } + } + } + + return params; +} + +/** + * Extracts operations from wsrv URL parameters + */ +function paramsToOperations(params: URLSearchParams): WsrvOperations { + const operations: WsrvOperations = {}; + + // Standard operations + if (params.has("w")) { + operations.width = Number(params.get("w")); + operations.w = Number(params.get("w")); + } + if (params.has("h")) { + operations.height = Number(params.get("h")); + operations.h = Number(params.get("h")); + } + if (params.has("output")) { + operations.format = params.get("output") as WsrvFormats; + operations.output = params.get("output") as WsrvFormats; + } + if (params.has("q")) { + operations.quality = Number(params.get("q")); + operations.q = Number(params.get("q")); + } + + // Other operations + if (params.has("dpr")) operations.dpr = Number(params.get("dpr")); + if (params.has("fit")) { + operations.fit = params.get("fit") as WsrvOperations["fit"]; + } + if (params.has("we")) operations.we = params.get("we") === "1"; + if (params.has("a")) { + operations.a = params.get("a") as WsrvOperations["a"]; + } + if (params.has("crop")) operations.crop = params.get("crop")!; + if (params.has("precrop")) operations.precrop = params.get("precrop") === "1"; + if (params.has("trim")) operations.trim = Number(params.get("trim")); + if (params.has("mask")) operations.mask = params.get("mask")!; + if (params.has("mtrim")) operations.mtrim = params.get("mtrim") === "1"; + if (params.has("mbg")) operations.mbg = params.get("mbg")!; + if (params.has("flip")) operations.flip = params.get("flip") === "1"; + if (params.has("flop")) operations.flop = params.get("flop") === "1"; + if (params.has("ro")) operations.ro = Number(params.get("ro")); + if (params.has("rbg")) operations.rbg = params.get("rbg")!; + if (params.has("bg")) operations.bg = params.get("bg")!; + if (params.has("blur")) operations.blur = Number(params.get("blur")); + if (params.has("con")) operations.con = Number(params.get("con")); + if (params.has("filt")) { + operations.filt = params.get("filt") as WsrvOperations["filt"]; + } + if (params.has("gam")) operations.gam = Number(params.get("gam")); + if (params.has("mod")) operations.mod = params.get("mod")!; + if (params.has("sat")) operations.sat = Number(params.get("sat")); + if (params.has("hue")) operations.hue = Number(params.get("hue")); + if (params.has("sharp")) operations.sharp = Number(params.get("sharp")); + if (params.has("tint")) operations.tint = params.get("tint")!; + if (params.has("cbg")) operations.cbg = params.get("cbg")!; + if (params.has("l")) operations.l = Number(params.get("l")); + if (params.has("ll")) operations.ll = params.get("ll") === "1"; + if (params.has("il")) operations.il = params.get("il") === "1"; + if (params.has("n")) operations.n = Number(params.get("n")); + if (params.has("page")) operations.page = Number(params.get("page")); + if (params.has("default")) operations.default = params.get("default")!; + if (params.has("filename")) operations.filename = params.get("filename")!; + if (params.has("maxage")) operations.maxage = params.get("maxage")!; + if (params.has("encoding")) { + operations.encoding = params.get("encoding") as "base64"; + } + + return operations; +} + +export const extract: URLExtractor<"wsrv"> = (url) => { + const urlObj = toUrl(url); + + // wsrv.nl URLs have the source image in the 'url' parameter + const srcParam = urlObj.searchParams.get("url"); + if (!srcParam) { + return null; + } + + // The source URL might need protocol added + let src = srcParam; + if (!src.startsWith("http://") && !src.startsWith("https://")) { + src = "https://" + src; + } + + // Extract all operations except the url parameter + const params = new URLSearchParams(urlObj.search); + params.delete("url"); + + const operations = paramsToOperations(params); + + return { + src, + operations, + }; +}; + +export const generate: URLGenerator<"wsrv"> = (src, operations) => { + const url = new URL("https://wsrv.nl/"); + + // Apply default operations + const allOperations: WsrvOperations = { + fit: "cover", + ...operations, + }; + + // Convert operations to URL parameters + const params = operationsToParams(allOperations); + + // Add the source URL + const srcUrl = typeof src === "string" ? src : src.toString(); + // Remove protocol for cleaner URLs + const cleanSrc = srcUrl.replace(/^https?:\/\//, ""); + params.set("url", cleanSrc); + + url.search = params.toString(); + 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 = ( From 744cd1eaa9f0ab9a3eaf738db4d8aa7a2e32cf15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20=C3=85berg=20Kultalahti?= Date: Tue, 4 Nov 2025 21:32:39 +0100 Subject: [PATCH 2/3] Re-work --- src/providers/wsrv.test.ts | 168 +++++--------------------- src/providers/wsrv.ts | 234 +++++-------------------------------- 2 files changed, 60 insertions(+), 342 deletions(-) diff --git a/src/providers/wsrv.test.ts b/src/providers/wsrv.test.ts index c59831c..9b612e4 100644 --- a/src/providers/wsrv.test.ts +++ b/src/providers/wsrv.test.ts @@ -2,7 +2,7 @@ import { extract, generate, transform } from "./wsrv.ts"; import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; import { assertEquals } from "jsr:@std/assert"; -const testImage = "https://example.com/image.jpg"; +const img = "https://example.com/image.jpg"; Deno.test("wsrv extract", async (t) => { await t.step("should parse a basic wsrv URL", () => { @@ -13,59 +13,19 @@ Deno.test("wsrv extract", async (t) => { assertEquals(operations, {}); }); - await t.step("should parse a wsrv URL with width and height", () => { + await t.step("should parse operations from URL", () => { const { operations, src } = extract( - "https://wsrv.nl/?url=example.com/image.jpg&w=300&h=200", + "https://wsrv.nl/?url=example.com/image.jpg&w=300&h=200&q=85&output=webp&fit=cover&dpr=2", ) ?? {}; assertEquals(src, "https://example.com/image.jpg"); assertEquals(operations?.width, 300); assertEquals(operations?.height, 200); - assertEquals(operations?.w, 300); - assertEquals(operations?.h, 200); - }); - - await t.step("should parse a wsrv URL with quality and format", () => { - const { operations, src } = extract( - "https://wsrv.nl/?url=example.com/image.jpg&q=85&output=webp", - ) ?? {}; - assertEquals(src, "https://example.com/image.jpg"); assertEquals(operations?.quality, 85); assertEquals(operations?.format, "webp"); - assertEquals(operations?.q, 85); - assertEquals(operations?.output, "webp"); - }); - - await t.step("should parse a wsrv URL with fit parameter", () => { - const { operations } = extract( - "https://wsrv.nl/?url=example.com/image.jpg&fit=cover", - ) ?? {}; assertEquals(operations?.fit, "cover"); - }); - - await t.step("should parse a wsrv URL with dpr", () => { - const { operations } = extract( - "https://wsrv.nl/?url=example.com/image.jpg&dpr=2", - ) ?? {}; assertEquals(operations?.dpr, 2); }); - await t.step("should parse a wsrv URL with boolean parameters", () => { - const { operations } = extract( - "https://wsrv.nl/?url=example.com/image.jpg&flip=1&we=1", - ) ?? {}; - assertEquals(operations?.flip, true); - assertEquals(operations?.we, true); - }); - - await t.step("should parse a wsrv URL with adjustment parameters", () => { - const { operations } = extract( - "https://wsrv.nl/?url=example.com/image.jpg&blur=5&con=20&sat=-10", - ) ?? {}; - assertEquals(operations?.blur, 5); - assertEquals(operations?.con, 20); - assertEquals(operations?.sat, -10); - }); - await t.step("should return null for non-wsrv URLs", () => { const result = extract("https://example.com/image.jpg"); assertEquals(result, null); @@ -73,53 +33,43 @@ Deno.test("wsrv extract", async (t) => { }); Deno.test("wsrv generate", async (t) => { - await t.step("should generate a basic wsrv URL with width", () => { - const result = generate(testImage, { + 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&fit=cover", + "https://wsrv.nl/?url=example.com/image.jpg&w=300&h=200&fit=cover", ); }); - await t.step("should generate a wsrv URL with width and height", () => { - const result = generate(testImage, { - width: 300, - height: 200, - }); + 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&h=200&fit=cover", + "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=cover", ); }); - await t.step("should generate a wsrv URL with format", () => { - const result = generate(testImage, { - width: 300, - format: "webp", - }); + 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=300&output=webp&fit=cover", + "https://wsrv.nl/?url=example.com/image.jpg&w=600&q=80&fit=cover", ); }); - await t.step("should generate a wsrv URL with quality", () => { - const result = generate(testImage, { - width: 300, - quality: 85, - }); + 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=300&q=85&fit=cover", + "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(testImage, { - width: 300, - }); + const result = generate(img, { width: 300 }); assertEqualIgnoringQueryOrder( result, "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=cover", @@ -127,97 +77,43 @@ Deno.test("wsrv generate", async (t) => { }); await t.step("should allow overriding fit parameter", () => { - const result = generate(testImage, { - width: 300, - fit: "contain", - }); + 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 generate URL with dpr", () => { - const result = generate(testImage, { - width: 300, - dpr: 2, - }); + 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&fit=cover", - ); - }); - - await t.step("should generate URL with boolean parameters", () => { - const result = generate(testImage, { - width: 300, - flip: true, - we: true, - }); - assertEqualIgnoringQueryOrder( - result, - "https://wsrv.nl/?url=example.com/image.jpg&w=300&flip=1&we=1&fit=cover", - ); - }); - - await t.step("should generate URL with adjustment parameters", () => { - const result = generate(testImage, { - width: 300, - blur: 5, - con: 20, - }); - assertEqualIgnoringQueryOrder( - result, - "https://wsrv.nl/?url=example.com/image.jpg&w=300&blur=5&con=20&fit=cover", - ); - }); - - await t.step("should strip protocol from source URL", () => { - const result = generate("https://example.com/image.jpg", { - width: 300, - }); - assertEqualIgnoringQueryOrder( - result, - "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=cover", + "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 transform an existing wsrv URL", () => { - const result = transform( - "https://wsrv.nl/?url=example.com/image.jpg&w=300", - { width: 500 }, - ); - assertEqualIgnoringQueryOrder( - result, - "https://wsrv.nl/?url=example.com/image.jpg&w=500&fit=cover", - ); - }); - - await t.step("should add format to existing wsrv URL", () => { - const result = transform( - "https://wsrv.nl/?url=example.com/image.jpg&w=300", - { format: "webp" }, - ); + 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=300&output=webp&fit=cover", + "https://wsrv.nl/?url=example.com/image.jpg&w=200&h=100&fit=cover", ); }); - await t.step("should preserve existing operations when transforming", () => { - const result = transform( - "https://wsrv.nl/?url=example.com/image.jpg&w=300&dpr=2", - { height: 200 }, - ); + 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=300&dpr=2&h=200&fit=cover", + "https://wsrv.nl/?url=example.com/image.jpg&w=200&fit=cover", ); }); - await t.step("should apply defaults when transforming with empty operations", () => { + await t.step("should apply defaults to URL", () => { const result = transform( "https://wsrv.nl/?url=example.com/image.jpg&w=300", {}, @@ -228,7 +124,7 @@ Deno.test("wsrv transform", async (t) => { ); }); - await t.step("should override existing fit parameter", () => { + await t.step("should override existing parameters", () => { const result = transform( "https://wsrv.nl/?url=example.com/image.jpg&w=300&fit=inside", { fit: "contain" }, diff --git a/src/providers/wsrv.ts b/src/providers/wsrv.ts index 932a84c..4dca328 100644 --- a/src/providers/wsrv.ts +++ b/src/providers/wsrv.ts @@ -7,6 +7,7 @@ import type { } from "../types.ts"; import { createExtractAndGenerate, + createOperationsHandlers, toCanonicalUrlString, toUrl, } from "../utils.ts"; @@ -21,14 +22,9 @@ export type WsrvFormats = /** * Image transform options for wsrv.nl image processing. + * Note: width, height, format, and quality are inherited from Operations. */ export interface WsrvOperations extends Operations { - /** Sets the width of the image in pixels. */ - w?: number; - - /** Sets the height of the image in pixels. */ - h?: number; - /** Sets the output density of the image (1-8). */ dpr?: number; @@ -62,18 +58,12 @@ export interface WsrvOperations extends Operations { /** Crops the image to specific dimensions before any other operations. */ crop?: string; - /** Pre-resize crop behavior. */ - precrop?: boolean; - /** Trim 'boring' pixels from all edges. */ trim?: number; /** Sets the mask type from a predefined list (circle, ellipse, triangle, etc.). */ mask?: string; - /** Removes whitespace from mask. */ - mtrim?: boolean; - /** Mask background color. */ mbg?: string; @@ -86,9 +76,6 @@ export interface WsrvOperations extends Operations { /** Rotates the image by specified degrees. */ ro?: number; - /** Rotation background color. */ - rbg?: string; - /** Background color for transparent images. */ bg?: string; @@ -101,12 +88,6 @@ export interface WsrvOperations extends Operations { /** Filter to apply (greyscale, sepia, negate, etc.). */ filt?: "greyscale" | "sepia" | "duotone" | "negate"; - /** Gamma adjustment (1.0-3.0). */ - gam?: number; - - /** Modulate brightness, saturation, hue. */ - mod?: string; - /** Saturation adjustment (-100 to 100). */ sat?: number; @@ -122,12 +103,6 @@ export interface WsrvOperations extends Operations { /** Contain background color (use with fit=contain). */ cbg?: string; - /** Output format (jpg, png, gif, tiff, webp, json). */ - output?: WsrvFormats; - - /** Quality level (0-100, default 80). */ - q?: number; - /** PNG compression level (0-9, default 6). */ l?: number; @@ -139,169 +114,22 @@ export interface WsrvOperations extends Operations { /** Number of pages to render (-1 for all, useful for animated images). */ n?: number; - - /** Page number for multi-page images. */ - page?: number; - - /** Default image URL to use on error. */ - default?: string; - - /** Custom filename for download. */ - filename?: string; - - /** Cache duration in seconds. */ - maxage?: string; - - /** Base64 encoding format. */ - encoding?: "base64"; } -/** - * Converts standard operations to wsrv parameters - */ -function operationsToParams(operations: WsrvOperations): URLSearchParams { - const params = new URLSearchParams(); - - // Map standard operations - if (operations.width !== undefined) { - params.set("w", String(operations.width)); - } - if (operations.height !== undefined) { - params.set("h", String(operations.height)); - } - if (operations.format !== undefined) { - params.set("output", String(operations.format)); - } - if (operations.quality !== undefined) { - params.set("q", String(operations.quality)); - } - - // Add all other wsrv-specific operations - const wsrvKeys: (keyof WsrvOperations)[] = [ - "w", - "h", - "dpr", - "fit", - "we", - "a", - "crop", - "precrop", - "trim", - "mask", - "mtrim", - "mbg", - "flip", - "flop", - "ro", - "rbg", - "bg", - "blur", - "con", - "filt", - "gam", - "mod", - "sat", - "hue", - "sharp", - "tint", - "cbg", - "output", - "q", - "l", - "ll", - "il", - "n", - "page", - "default", - "filename", - "maxage", - "encoding", - ]; - - for (const key of wsrvKeys) { - const value = operations[key]; - if (value !== undefined && !params.has(key)) { - if (typeof value === "boolean") { - params.set(key, "1"); - } else { - params.set(key, String(value)); - } - } - } - - return params; -} - -/** - * Extracts operations from wsrv URL parameters - */ -function paramsToOperations(params: URLSearchParams): WsrvOperations { - const operations: WsrvOperations = {}; - - // Standard operations - if (params.has("w")) { - operations.width = Number(params.get("w")); - operations.w = Number(params.get("w")); - } - if (params.has("h")) { - operations.height = Number(params.get("h")); - operations.h = Number(params.get("h")); - } - if (params.has("output")) { - operations.format = params.get("output") as WsrvFormats; - operations.output = params.get("output") as WsrvFormats; - } - if (params.has("q")) { - operations.quality = Number(params.get("q")); - operations.q = Number(params.get("q")); - } - - // Other operations - if (params.has("dpr")) operations.dpr = Number(params.get("dpr")); - if (params.has("fit")) { - operations.fit = params.get("fit") as WsrvOperations["fit"]; - } - if (params.has("we")) operations.we = params.get("we") === "1"; - if (params.has("a")) { - operations.a = params.get("a") as WsrvOperations["a"]; - } - if (params.has("crop")) operations.crop = params.get("crop")!; - if (params.has("precrop")) operations.precrop = params.get("precrop") === "1"; - if (params.has("trim")) operations.trim = Number(params.get("trim")); - if (params.has("mask")) operations.mask = params.get("mask")!; - if (params.has("mtrim")) operations.mtrim = params.get("mtrim") === "1"; - if (params.has("mbg")) operations.mbg = params.get("mbg")!; - if (params.has("flip")) operations.flip = params.get("flip") === "1"; - if (params.has("flop")) operations.flop = params.get("flop") === "1"; - if (params.has("ro")) operations.ro = Number(params.get("ro")); - if (params.has("rbg")) operations.rbg = params.get("rbg")!; - if (params.has("bg")) operations.bg = params.get("bg")!; - if (params.has("blur")) operations.blur = Number(params.get("blur")); - if (params.has("con")) operations.con = Number(params.get("con")); - if (params.has("filt")) { - operations.filt = params.get("filt") as WsrvOperations["filt"]; - } - if (params.has("gam")) operations.gam = Number(params.get("gam")); - if (params.has("mod")) operations.mod = params.get("mod")!; - if (params.has("sat")) operations.sat = Number(params.get("sat")); - if (params.has("hue")) operations.hue = Number(params.get("hue")); - if (params.has("sharp")) operations.sharp = Number(params.get("sharp")); - if (params.has("tint")) operations.tint = params.get("tint")!; - if (params.has("cbg")) operations.cbg = params.get("cbg")!; - if (params.has("l")) operations.l = Number(params.get("l")); - if (params.has("ll")) operations.ll = params.get("ll") === "1"; - if (params.has("il")) operations.il = params.get("il") === "1"; - if (params.has("n")) operations.n = Number(params.get("n")); - if (params.has("page")) operations.page = Number(params.get("page")); - if (params.has("default")) operations.default = params.get("default")!; - if (params.has("filename")) operations.filename = params.get("filename")!; - if (params.has("maxage")) operations.maxage = params.get("maxage")!; - if (params.has("encoding")) { - operations.encoding = params.get("encoding") as "base64"; - } - - return operations; -} +const { operationsGenerator, operationsParser } = createOperationsHandlers< + WsrvOperations +>({ + keyMap: { + width: "w", + height: "h", + format: "output", + quality: "q", + }, + defaults: { + fit: "cover", + }, + srcParam: "url", +}); export const extract: URLExtractor<"wsrv"> = (url) => { const urlObj = toUrl(url); @@ -318,11 +146,7 @@ export const extract: URLExtractor<"wsrv"> = (url) => { src = "https://" + src; } - // Extract all operations except the url parameter - const params = new URLSearchParams(urlObj.search); - params.delete("url"); - - const operations = paramsToOperations(params); + const operations = operationsParser(urlObj); return { src, @@ -333,22 +157,20 @@ export const extract: URLExtractor<"wsrv"> = (url) => { export const generate: URLGenerator<"wsrv"> = (src, operations) => { const url = new URL("https://wsrv.nl/"); - // Apply default operations - const allOperations: WsrvOperations = { - fit: "cover", - ...operations, - }; - - // Convert operations to URL parameters - const params = operationsToParams(allOperations); - - // Add the source URL + // Add the source URL (remove protocol for cleaner URLs) const srcUrl = typeof src === "string" ? src : src.toString(); - // Remove protocol for cleaner URLs const cleanSrc = srcUrl.replace(/^https?:\/\//, ""); - params.set("url", cleanSrc); + url.searchParams.set("url", cleanSrc); + + // Add operations as query parameters + const params = operationsGenerator(operations); + const searchParams = new URLSearchParams(params); + for (const [key, value] of searchParams) { + if (key !== "url") { + url.searchParams.set(key, value); + } + } - url.search = params.toString(); return toCanonicalUrlString(url); }; From deb740f3e1893e0d18e03ca76991a5603fc4301f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20=C3=85berg=20Kultalahti?= Date: Tue, 4 Nov 2025 22:00:25 +0100 Subject: [PATCH 3/3] Fix keyMap and test issue, add to async provider map --- src/async.ts | 1 + src/providers/wsrv.test.ts | 3 +-- src/providers/wsrv.ts | 20 ++++++++++++++------ 3 files changed, 16 insertions(+), 8 deletions(-) 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/providers/wsrv.test.ts b/src/providers/wsrv.test.ts index 9b612e4..0f71871 100644 --- a/src/providers/wsrv.test.ts +++ b/src/providers/wsrv.test.ts @@ -15,7 +15,7 @@ Deno.test("wsrv extract", async (t) => { 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&dpr=2", + "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); @@ -23,7 +23,6 @@ Deno.test("wsrv extract", async (t) => { assertEquals(operations?.quality, 85); assertEquals(operations?.format, "webp"); assertEquals(operations?.fit, "cover"); - assertEquals(operations?.dpr, 2); }); await t.step("should return null for non-wsrv URLs", () => { diff --git a/src/providers/wsrv.ts b/src/providers/wsrv.ts index 4dca328..968ca73 100644 --- a/src/providers/wsrv.ts +++ b/src/providers/wsrv.ts @@ -22,9 +22,20 @@ export type WsrvFormats = /** * Image transform options for wsrv.nl image processing. - * Note: width, height, format, and quality are inherited from Operations. */ 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; @@ -128,24 +139,23 @@ const { operationsGenerator, operationsParser } = createOperationsHandlers< defaults: { fit: "cover", }, - srcParam: "url", }); export const extract: URLExtractor<"wsrv"> = (url) => { const urlObj = toUrl(url); - // wsrv.nl URLs have the source image in the 'url' parameter const srcParam = urlObj.searchParams.get("url"); if (!srcParam) { return null; } - // The source URL might need protocol added let src = srcParam; if (!src.startsWith("http://") && !src.startsWith("https://")) { src = "https://" + src; } + urlObj.searchParams.delete("url"); + const operations = operationsParser(urlObj); return { @@ -157,12 +167,10 @@ export const extract: URLExtractor<"wsrv"> = (url) => { export const generate: URLGenerator<"wsrv"> = (src, operations) => { const url = new URL("https://wsrv.nl/"); - // Add the source URL (remove protocol for cleaner URLs) const srcUrl = typeof src === "string" ? src : src.toString(); const cleanSrc = srcUrl.replace(/^https?:\/\//, ""); url.searchParams.set("url", cleanSrc); - // Add operations as query parameters const params = operationsGenerator(operations); const searchParams = new URLSearchParams(params); for (const [key, value] of searchParams) {