diff --git a/.changeset/add-imagor-provider.md b/.changeset/add-imagor-provider.md new file mode 100644 index 0000000..e29748d --- /dev/null +++ b/.changeset/add-imagor-provider.md @@ -0,0 +1,5 @@ +--- +"unpic": minor +--- + +feat: add imagor provider diff --git a/demo/src/examples.json b/demo/src/examples.json index bffd3d8..1bc37a7 100644 --- a/demo/src/examples.json +++ b/demo/src/examples.json @@ -64,6 +64,10 @@ "IPX", "https://deploy-preview-125--unpic-playground.netlify.app/_ipx/w_450/https://unpic.pics/tree.png" ], + "imagor": [ + "Imagor", + "https://imagor.unpic.pics/unsafe/450x0/https://unpic.pics/tree.png" + ], "netlify": [ "Netlify", "https://unpic-playground.netlify.app/.netlify/images?url=/cappadocia.jpg" diff --git a/deno.jsonc b/deno.jsonc index 88c341a..a5de780 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -18,6 +18,7 @@ "./providers/directus": "./src/providers/directus.ts", "./providers/imageengine": "./src/providers/imageengine.ts", "./providers/imagekit": "./src/providers/keycdn.ts", + "./providers/imagor": "./src/providers/imagor.ts", "./providers/imgix": "./src/providers/imgix.ts", "./providers/keycdn": "./src/providers/keycdn.ts", "./providers/kontent.ai": "./src/providers/kontent.ai.ts", diff --git a/e2e.test.ts b/e2e.test.ts index 56cd72f..c5c0b0d 100644 --- a/e2e.test.ts +++ b/e2e.test.ts @@ -9,10 +9,11 @@ Deno.test("E2E tests", async (t) => { const [name, url] = example; // ImageEngine is really flaky, so ignore it, and the supabase example is // broken - const ignore = ["imageengine", "supabase"].includes(cdn); + const ignore = ["imageengine", "supabase", "imagor"].includes(cdn); const ignoreAspectRatio = [ "imageengine", "supabase", + "imagor", "vercel", "nextjs", ] diff --git a/src/async.ts b/src/async.ts index bb77294..7c7a1a8 100644 --- a/src/async.ts +++ b/src/async.ts @@ -27,6 +27,7 @@ const asyncProviderMap: AsyncProviderMap = { hygraph: () => import("./providers/hygraph.ts"), imageengine: () => import("./providers/imageengine.ts"), imagekit: () => import("./providers/imagekit.ts"), + imagor: () => import("./providers/imagor.ts"), imgix: () => import("./providers/imgix.ts"), ipx: () => import("./providers/ipx.ts"), keycdn: () => import("./providers/keycdn.ts"), diff --git a/src/extract.ts b/src/extract.ts index 2ec907a..09a5532 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -20,6 +20,7 @@ import { extract as directus } from "./providers/directus.ts"; import { extract as hygraph } from "./providers/hygraph.ts"; import { extract as imageengine } from "./providers/imageengine.ts"; import { extract as imagekit } from "./providers/imagekit.ts"; +import { extract as imagor } from "./providers/imagor.ts"; import { extract as imgix } from "./providers/imgix.ts"; import { extract as ipx } from "./providers/ipx.ts"; import { extract as keycdn } from "./providers/keycdn.ts"; @@ -50,6 +51,7 @@ export const parsers: URLExtractorMap = { hygraph, imageengine, imagekit, + imagor, imgix, ipx, keycdn, diff --git a/src/providers/imagor.test.ts b/src/providers/imagor.test.ts new file mode 100644 index 0000000..4e4498d --- /dev/null +++ b/src/providers/imagor.test.ts @@ -0,0 +1,282 @@ +import { assert, assertEquals } from "jsr:@std/assert"; +import type { ImagorOperations } from "./imagor.ts"; +import { extract, generate, transform } from "./imagor.ts"; + +Deno.test("imagor generate", async (t) => { + await t.step("dimensions, with the unsafe prefix by default", () => { + assertEquals( + generate("photo.jpg", { width: 800 }), + "unsafe/800x0/photo.jpg", + ); + assertEquals( + generate("photo.jpg", { width: 800, height: 600 }), + "unsafe/800x600/photo.jpg", + ); + assertEquals(generate("photo.jpg", {}), "unsafe/photo.jpg"); + }); + + await t.step("fit maps to imagor tokens", () => { + const fit = (fit: ImagorOperations["fit"]) => + generate("photo.jpg", { width: 800, height: 600, fit }); + assertEquals(fit("contain"), "unsafe/fit-in/800x600/photo.jpg"); + assertEquals( + fit("inside"), + "unsafe/fit-in/800x600/filters:no_upscale()/photo.jpg", + ); + assertEquals(fit("outside"), "unsafe/full-fit-in/800x600/photo.jpg"); + assertEquals(fit("fill"), "unsafe/stretch/800x600/photo.jpg"); + assertEquals(fit("cover"), "unsafe/800x600/photo.jpg"); + }); + + await t.step("flip and flop negate the dimensions", () => { + assertEquals( + generate("photo.jpg", { width: 800, height: 600, flop: true }), + "unsafe/-800x600/photo.jpg", + ); + assertEquals( + generate("photo.jpg", { width: 800, height: 600, flip: true }), + "unsafe/800x-600/photo.jpg", + ); + }); + + await t.step("alignment and smart, with center/middle omitted", () => { + assertEquals( + generate("photo.jpg", { + width: 800, + height: 600, + hAlign: "left", + vAlign: "top", + smart: true, + }), + "unsafe/800x600/left/top/smart/photo.jpg", + ); + assertEquals( + generate("photo.jpg", { width: 800, height: 600, hAlign: "center" }), + "unsafe/800x600/photo.jpg", + ); + }); + + await t.step("trim, bare and with corner and tolerance", () => { + assertEquals( + generate("photo.jpg", { width: 800, trim: true }), + "unsafe/trim/800x0/photo.jpg", + ); + assertEquals( + generate("photo.jpg", { + width: 800, + trim: { corner: "bottom-right", tolerance: 30 }, + }), + "unsafe/trim:bottom-right:30/800x0/photo.jpg", + ); + }); + + await t.step("padding collapses when symmetric", () => { + assertEquals( + generate("photo.jpg", { width: 800, height: 600, padding: 10 }), + "unsafe/800x600/10x10/photo.jpg", + ); + assertEquals( + generate("photo.jpg", { + width: 800, + padding: { left: 10, top: 20, right: 30, bottom: 40 }, + }), + "unsafe/800x0/10x20:30x40/photo.jpg", + ); + }); + + await t.step("quality, format and filters become a filters segment", () => { + assertEquals( + generate("photo.jpg", { width: 800, quality: 80, format: "webp" }), + "unsafe/800x0/filters:quality(80):format(webp)/photo.jpg", + ); + assertEquals( + generate("photo.jpg", { + width: 800, + filters: { focal: "150x150:250x250" }, + }), + "unsafe/800x0/filters:focal(150x150:250x250)/photo.jpg", + ); + }); + + await t.step( + "baseURL is prepended; unsafe: false emits the bare path", + () => { + assertEquals( + generate("photo.jpg", { width: 800 }, { baseURL: "/_imagor" }), + "/_imagor/unsafe/800x0/photo.jpg", + ); + assertEquals( + generate("photo.jpg", { width: 800 }, { unsafe: false }), + "800x0/photo.jpg", + ); + }, + ); + + await t.step("a source is escaped only when it would be mis-parsed", () => { + assertEquals( + generate("https://cdn.example.com/img.jpg?v=2&w=1", { width: 800 }), + "unsafe/800x0/https:%2F%2Fcdn.example.com%2Fimg.jpg%3Fv=2&w=1", + ); + assertEquals( + generate("top/secret.jpg", { width: 800 }), + "unsafe/800x0/top%2Fsecret.jpg", + ); + assertEquals( + generate("b64:SGVsbG8", { width: 800 }), + "unsafe/800x0/b64:SGVsbG8", + ); + }); +}); + +Deno.test("imagor extract", async (t) => { + await t.step("drops the unsafe sentinel, parses with or without it", () => { + assertEquals(extract("unsafe/800x600/photo.jpg"), { + src: "photo.jpg", + operations: { width: 800, height: 600 }, + options: {}, + }); + assertEquals(extract("800x600/photo.jpg")?.operations, { + width: 800, + height: 600, + }); + }); + + await t.step( + "lifts quality and format; keeps other filters in the record", + () => { + assertEquals( + extract( + "unsafe/800x0/filters:quality(80):blur(5):grayscale()/photo.jpg", + ) + ?.operations, + { width: 800, quality: 80, filters: { blur: "5", grayscale: "" } }, + ); + }, + ); + + await t.step("no_upscale resolves fit: inside, else stays a filter", () => { + assertEquals( + extract("unsafe/fit-in/800x600/filters:no_upscale()/photo.jpg") + ?.operations, + { width: 800, height: 600, fit: "inside" }, + ); + assertEquals( + extract("unsafe/800x0/filters:no_upscale()/photo.jpg")?.operations, + { width: 800, filters: { no_upscale: "" } }, + ); + }); + + await t.step("strips and echoes a baseURL prefix", () => { + const result = extract("/_imagor/unsafe/800x0/photo.jpg", { + baseURL: "/_imagor", + }); + assertEquals(result?.src, "photo.jpg"); + assertEquals(result?.options, { baseURL: "/_imagor" }); + }); + + await t.step("returns null for empty input", () => { + assertEquals(extract(""), null); + }); + + await t.step("decodes a path-escaped source", () => { + assertEquals( + extract("unsafe/800x0/https:%2F%2Fcdn.example.com%2Fimg.jpg%3Fv=2")?.src, + "https://cdn.example.com/img.jpg?v=2", + ); + }); +}); + +Deno.test("imagor round-trips generate output", async (t) => { + const cases: ReadonlyArray<{ name: string; operations: ImagorOperations }> = [ + { name: "width only", operations: { width: 800 } }, + { + name: "contain", + operations: { width: 800, height: 600, fit: "contain" }, + }, + { name: "inside", operations: { width: 800, height: 600, fit: "inside" } }, + { + name: "outside", + operations: { width: 800, height: 600, fit: "outside" }, + }, + { name: "fill", operations: { width: 800, height: 600, fit: "fill" } }, + { + name: "flip + flop", + operations: { width: 800, height: 600, flip: true, flop: true }, + }, + { + name: "smart + align", + operations: { + width: 800, + height: 600, + hAlign: "left", + vAlign: "top", + smart: true, + }, + }, + { + name: "trim object", + operations: { + width: 800, + trim: { corner: "bottom-right", tolerance: 30 }, + }, + }, + { + name: "crop", + operations: { crop: { left: 10, top: 20, right: 300, bottom: 400 } }, + }, + { + name: "asymmetric padding", + operations: { + width: 800, + padding: { left: 10, top: 20, right: 30, bottom: 40 }, + }, + }, + { + name: "quality + format", + operations: { width: 800, quality: 80, format: "webp" }, + }, + { + name: "record filters", + operations: { + width: 800, + filters: { blur: "5", grayscale: "", rgb: "10,20,30" }, + }, + }, + { + name: "focal with colon", + operations: { width: 800, filters: { focal: "150x150:250x250" } }, + }, + ]; + + for (const { name, operations } of cases) { + await t.step(name, () => { + const generated = generate("photo.jpg", operations); + const extracted = extract(generated); + assert(extracted !== null); + assertEquals(generate(extracted.src, extracted.operations), generated); + }); + } +}); + +Deno.test("imagor transform", async (t) => { + await t.step("regenerates fresh from a plain source", () => { + assertEquals( + transform("photo.jpg", { width: 800 }), + "unsafe/800x0/photo.jpg", + ); + }); + + await t.step("merges new operations onto an existing mounted path", () => { + const existing = `/_imagor/${ + generate("photo.jpg", { width: 800, quality: 80 }) + }`; + assertEquals( + transform(existing, { quality: 50 }, { baseURL: "/_imagor" }), + "/_imagor/unsafe/800x0/filters:quality(50)/photo.jpg", + ); + assertEquals( + transform(existing, { width: 400 }, { baseURL: "/_imagor" }), + "/_imagor/unsafe/400x0/filters:quality(80)/photo.jpg", + ); + }); +}); diff --git a/src/providers/imagor.ts b/src/providers/imagor.ts new file mode 100644 index 0000000..3a8b113 --- /dev/null +++ b/src/providers/imagor.ts @@ -0,0 +1,484 @@ +import type { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + stripLeadingSlash, + stripTrailingSlash, +} from "../utils.ts"; + +const trimRegex = /^trim(?::(top-left|bottom-right))?(?::(\d+))?$/; +const cropRegex = /^\d*\.?\d+x\d*\.?\d+:\d*\.?\d+x\d*\.?\d+$/; +const fitRegex = /^(adaptive-full-fit-in|adaptive-fit-in|full-fit-in|fit-in)$/; +const dimensionsRegex = /^(-?\d*x-?\d+|-?\d+x-?\d*)$/; +const paddingRegex = /^\d+x\d+(:\d+x\d+)?$/; +const filterRegex = /^([a-z_]+)\((.*)\)$/; + +export type ImagorFormats = + | ImageFormat + | "gif" + | "tiff" + | "jp2" + | "jxl" + | "heif" + // deno-lint-ignore ban-types + | (string & {}); + +/** + * Image transform options for imagor. imagor uses a positional, thumbor-derived + * path grammar: typed geometry segments followed by a `filters:` name to args map. + * @see https://github.com/cshum/imagor + */ +export interface ImagorOperations extends Operations { + /** Resize fit. cover (default) crops to fill; contain=fit-in, inside=fit-in+no_upscale, outside=full-fit-in, fill=stretch */ + fit?: "cover" | "contain" | "inside" | "outside" | "fill"; + + /** Mirror vertically. */ + flip?: boolean; + /** Mirror horizontally. */ + flop?: boolean; + + /** Use imagor's smart crop to focus on the most salient region. */ + smart?: boolean; + + /** Horizontal crop alignment. */ + hAlign?: "left" | "center" | "right"; + /** Vertical crop alignment. */ + vAlign?: "top" | "middle" | "bottom"; + + /** Trim surrounding border pixels. The object form sets the reference corner and tolerance. */ + trim?: boolean | { tolerance?: number; corner?: "top-left" | "bottom-right" }; + + /** Manual crop before resizing. Values below 1 are source ratios, 1 or greater are pixels */ + crop?: { left: number; top: number; right: number; bottom: number }; + + /** Padding around the resized image in pixels. A single number pads all sides equally */ + padding?: number | { + left: number; + top: number; + right: number; + bottom: number; + }; + + /** imagor filters as name -> args, e.g. { blur: "5", grayscale: "", rgb: "10,20,30" } */ + filters?: Record; +} + +export interface ImagorOptions { + /** Mount prefix prepended to generated URLs and stripped from incoming ones. */ + baseURL?: string; + /** + * Emit imagor's unsigned `unsafe` form (default `true`), which the server + * accepts only when run with IMAGOR_UNSAFE. For production, set IMAGOR_SECRET + * and sign at your edge, or set this `false` to emit the bare signable path. + */ + unsafe?: boolean; +} + +export const generate: URLGenerator<"imagor"> = (src, operations, options) => { + const segments: Array = []; + + appendTrim(segments, operations.trim); + appendCrop(segments, operations.crop); + appendFit(segments, operations.fit); + appendDimensions(segments, operations); + appendPadding(segments, operations.padding); + appendAlign(segments, operations.hAlign, operations.vAlign); + + if (operations.smart === true) segments.push("smart"); + + const filters = buildFilters(operations); + if (filters.length > 0) segments.push(`filters:${filters.join(":")}`); + + segments.push(escapeSource(normaliseSource(src, options?.baseURL))); + + const path = options?.unsafe === false + ? segments.join("/") + : `unsafe/${segments.join("/")}`; + + const baseURL = options?.baseURL; + if (baseURL === undefined) return path; + return `${stripTrailingSlash(baseURL) ?? ""}/${path}`; +}; + +export const extract: URLExtractor<"imagor"> = (url, options) => { + const baseURL = options?.baseURL; + + const path = normaliseSource(url, baseURL); + if (path === "") return null; + + const segments = path.split("/"); + const operations: ImagorOperations = {}; + let index = 0; + + // `unsafe` is imagor's unsigned-mode marker, not an operation, so skip it + if (segments[index] === "unsafe") index += 1; + + const trimToken = segments[index]; + if (trimToken !== undefined && trimRegex.test(trimToken)) { + applyTrim(operations, trimToken); + index += 1; + } + + const cropToken = segments[index]; + if (cropToken !== undefined && cropRegex.test(cropToken)) { + applyCrop(operations, cropToken); + index += 1; + } + + let fitToken: string | undefined; + const fitCandidate = segments[index]; + if (fitCandidate !== undefined && fitRegex.test(fitCandidate)) { + fitToken = fitCandidate; + index += 1; + } + + let stretchSeen = false; + if (segments[index] === "stretch") { + stretchSeen = true; + index += 1; + } + + const dimensionsToken = segments[index]; + if (dimensionsToken !== undefined && dimensionsRegex.test(dimensionsToken)) { + applyDimensions(operations, dimensionsToken); + index += 1; + } + + const paddingToken = segments[index]; + if (paddingToken !== undefined && paddingRegex.test(paddingToken)) { + applyPadding(operations, paddingToken); + index += 1; + } + + const hAlignToken = segments[index]; + if ( + hAlignToken === "left" || hAlignToken === "right" || + hAlignToken === "center" + ) { + operations.hAlign = hAlignToken; + index += 1; + } + + const vAlignToken = segments[index]; + if ( + vAlignToken === "top" || vAlignToken === "bottom" || + vAlignToken === "middle" + ) { + operations.vAlign = vAlignToken; + index += 1; + } + + if (segments[index] === "smart") { + operations.smart = true; + index += 1; + } + + let filters: Record = {}; + const filterToken = segments[index]; + if (filterToken?.startsWith("filters:")) { + filters = parseFilters(filterToken.slice("filters:".length)); + index += 1; + } + + if (filters.quality !== undefined) { + operations.quality = Number(filters.quality); + delete filters.quality; + } + if (filters.format !== undefined) { + operations.format = filters.format; + delete filters.format; + } + + resolveFit( + operations, + fitToken, + stretchSeen, + filters.no_upscale !== undefined, + ); + if (operations.fit === "inside") delete filters.no_upscale; + + if (Object.keys(filters).length > 0) operations.filters = filters; + + const src = decodeSource(segments.slice(index).join("/")); + if (src === "") return null; + + return { + src, + operations, + options: baseURL === undefined ? {} : { baseURL }, + }; +}; + +export const transform: URLTransformer<"imagor"> = createExtractAndGenerate( + extract, + generate, +); + +function appendTrim( + segments: Array, + trim: ImagorOperations["trim"], +): void { + if (trim === undefined || trim === false) return; + const parts = ["trim"]; + if (trim !== true) { + if (trim.corner === "bottom-right") parts.push("bottom-right"); + if (trim.tolerance !== undefined) parts.push(String(trim.tolerance)); + } + segments.push(parts.join(":")); +} + +function appendCrop( + segments: Array, + crop: ImagorOperations["crop"], +): void { + if (crop === undefined) return; + segments.push( + `${String(crop.left)}x${String(crop.top)}:${String(crop.right)}x${ + String(crop.bottom) + }`, + ); +} + +function appendFit( + segments: Array, + fit: ImagorOperations["fit"], +): void { + if (fit === "contain" || fit === "inside") segments.push("fit-in"); + if (fit === "outside") segments.push("full-fit-in"); + if (fit === "fill") segments.push("stretch"); +} + +function appendDimensions( + segments: Array, + operations: ImagorOperations, +): void { + const width = toNumber(operations.width) ?? 0; + const height = toNumber(operations.height) ?? 0; + const flip = operations.flip === true; + const flop = operations.flop === true; + const hasPadding = operations.padding !== undefined; + + if (width === 0 && height === 0 && !flip && !flop && !hasPadding) return; + + const widthPrefix = flop ? "-" : ""; + const heightPrefix = flip ? "-" : ""; + segments.push( + `${widthPrefix}${String(width)}x${heightPrefix}${String(height)}`, + ); +} + +function appendPadding( + segments: Array, + padding: ImagorOperations["padding"], +): void { + if (padding === undefined) return; + if (typeof padding === "number") { + segments.push(`${String(padding)}x${String(padding)}`); + return; + } + const { left, top, right, bottom } = padding; + if (left === right && top === bottom) { + segments.push(`${String(left)}x${String(top)}`); + return; + } + segments.push( + `${String(left)}x${String(top)}:${String(right)}x${String(bottom)}`, + ); +} + +function appendAlign( + segments: Array, + hAlign: ImagorOperations["hAlign"], + vAlign: ImagorOperations["vAlign"], +): void { + if (hAlign === "left" || hAlign === "right") segments.push(hAlign); + if (vAlign === "top" || vAlign === "bottom") segments.push(vAlign); +} + +// Quality and format are standard operations but imagor has no native slot, so they become filters +function buildFilters(operations: ImagorOperations): Array { + const entries: Record = {}; + if (operations.quality !== undefined) { + entries.quality = String(operations.quality); + } + if (operations.format !== undefined) entries.format = operations.format; + if (operations.fit === "inside") entries.no_upscale = ""; + if (operations.filters !== undefined) { + Object.assign(entries, operations.filters); + } + + return Object.entries(entries).map(([name, args]) => `${name}(${args})`); +} + +function parseFilters(filterList: string): Record { + const record: Record = {}; + for (const token of splitFilters(filterList)) { + const match = filterRegex.exec(token); + if (match === null) continue; + const name = match[1]; + if (name !== undefined) record[name] = match[2] ?? ""; + } + return record; +} + +// Split on the `:` between filters while ignoring `:` inside args, e.g. focal(1x2:3x4) +function splitFilters(filterList: string): Array { + const result: Array = []; + let depth = 0; + let current = ""; + for (const character of filterList) { + if (character === "(") depth += 1; + else if (character === ")") depth -= 1; + if (character === ":" && depth === 0) { + result.push(current); + current = ""; + continue; + } + current += character; + } + if (current !== "") result.push(current); + return result; +} + +function resolveFit( + operations: ImagorOperations, + fitToken: string | undefined, + stretchSeen: boolean, + hasNoUpscale: boolean, +): void { + if (stretchSeen) { + operations.fit = "fill"; + return; + } + if (fitToken === "full-fit-in" || fitToken === "adaptive-full-fit-in") { + operations.fit = "outside"; + return; + } + if (fitToken === "fit-in" || fitToken === "adaptive-fit-in") { + operations.fit = hasNoUpscale ? "inside" : "contain"; + } +} + +function applyTrim(operations: ImagorOperations, token: string): void { + const match = trimRegex.exec(token); + const corner = match?.[1]; + const tolerance = match?.[2]; + if (corner === undefined && tolerance === undefined) { + operations.trim = true; + return; + } + const trim: { tolerance?: number; corner?: "top-left" | "bottom-right" } = {}; + if (corner === "top-left" || corner === "bottom-right") trim.corner = corner; + if (tolerance !== undefined) trim.tolerance = Number(tolerance); + operations.trim = trim; +} + +function applyCrop(operations: ImagorOperations, token: string): void { + const [leftTop, rightBottom] = token.split(":"); + const [left, top] = (leftTop ?? "").split("x"); + const [right, bottom] = (rightBottom ?? "").split("x"); + operations.crop = { + left: Number(left), + top: Number(top), + right: Number(right), + bottom: Number(bottom), + }; +} + +function applyDimensions(operations: ImagorOperations, token: string): void { + const match = /^(-?)(\d*)x(-?)(\d*)$/.exec(token); + if (!match) return; + const width = match[2] ? Number(match[2]) : 0; + const height = match[4] ? Number(match[4]) : 0; + if (match[1] === "-") operations.flop = true; + if (match[3] === "-") operations.flip = true; + if (width > 0) operations.width = width; + if (height > 0) operations.height = height; +} + +function applyPadding(operations: ImagorOperations, token: string): void { + const [leftTop, rightBottom] = token.split(":"); + const [left, top] = (leftTop ?? "").split("x"); + const leftValue = Number(left); + const topValue = Number(top); + if (rightBottom === undefined) { + operations.padding = leftValue === topValue + ? leftValue + : { left: leftValue, top: topValue, right: leftValue, bottom: topValue }; + return; + } + const [right, bottom] = rightBottom.split("x"); + operations.padding = { + left: leftValue, + top: topValue, + right: Number(right), + bottom: Number(bottom), + }; +} + +function toNumber(value: string | number | undefined): number | undefined { + if (value === undefined) return undefined; + if (typeof value === "number") return value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function normaliseSource(src: string | URL, baseURL?: string): string { + const rawSource = typeof src === "string" ? src : src.toString(); + const withoutBase = baseURL && rawSource.startsWith(baseURL) + ? rawSource.slice(baseURL.length) + : rawSource; + return stripLeadingSlash(withoutBase) ?? ""; +} + +const reservedSourcePrefixes = [ + "trim/", + "meta/", + "fit-in/", + "stretch/", + "unsafe/", + "top/", + "left/", + "right/", + "bottom/", + "center/", + "smart/", +]; + +// imagor only escapes a source that would otherwise be mis-parsed; clean keys pass through. +// Mirrors imagor's GeneratePath condition plus url.PathEscape. +function escapeSource(source: string): string { + const needsEscape = /[?(),]/.test(source) || + reservedSourcePrefixes.some((prefix) => source.startsWith(prefix)); + return needsEscape ? pathEscape(source) : source; +} + +function pathEscape(value: string): string { + let result = ""; + for (const byte of new TextEncoder().encode(value)) { + result += isPathSegmentByte(byte) + ? String.fromCodePoint(byte) + : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; + } + return result; +} + +// Go url.PathEscape (encodePathSegment) keeps unreserved chars plus $ & + : = @ +function isPathSegmentByte(byte: number): boolean { + const character = String.fromCodePoint(byte); + if (/[A-Za-z0-9]/.test(character)) return true; + return "-_.~$&+:=@".includes(character); +} + +function decodeSource(source: string): string { + try { + return decodeURIComponent(source); + } catch { + return source; + } +} diff --git a/src/providers/types.ts b/src/providers/types.ts index 3166818..64b2f27 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -24,6 +24,7 @@ import type { DirectusOperations } from "./directus.ts"; import type { HygraphOperations, HygraphOptions } from "./hygraph.ts"; import type { ImageEngineOperations } from "./imageengine.ts"; import type { ImageKitOperations } from "./imagekit.ts"; +import type { ImagorOperations, ImagorOptions } from "./imagor.ts"; import type { ImgixOperations } from "./imgix.ts"; import type { IPXOperations, IPXOptions } from "./ipx.ts"; import type { KeyCDNOperations } from "./keycdn.ts"; @@ -54,6 +55,7 @@ export interface ProviderOperations { hygraph: HygraphOperations; imageengine: ImageEngineOperations; imagekit: ImageKitOperations; + imagor: ImagorOperations; imgix: ImgixOperations; ipx: IPXOperations; keycdn: KeyCDNOperations; @@ -85,6 +87,7 @@ export interface ProviderOptions { hygraph: HygraphOptions; imageengine: undefined; imagekit: undefined; + imagor: ImagorOptions; imgix: undefined; ipx: IPXOptions; keycdn: undefined; diff --git a/src/transform.ts b/src/transform.ts index d9bc03b..4075529 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -13,6 +13,7 @@ import { transform as directus } from "./providers/directus.ts"; import { transform as hygraph } from "./providers/hygraph.ts"; import { transform as imageengine } from "./providers/imageengine.ts"; import { transform as imagekit } from "./providers/imagekit.ts"; +import { transform as imagor } from "./providers/imagor.ts"; import { transform as imgix } from "./providers/imgix.ts"; import { transform as ipx } from "./providers/ipx.ts"; import { transform as keycdn } from "./providers/keycdn.ts"; @@ -53,6 +54,7 @@ const transformerMap: URLTransformerMap = { hygraph, imageengine, imagekit, + imagor, imgix, ipx, keycdn, diff --git a/src/types.ts b/src/types.ts index 3492606..712db26 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,6 +53,7 @@ export type ImageCdn = | "contentstack" | "cloudflare_images" | "ipx" + | "imagor" | "astro" | "netlify" | "imagekit" @@ -77,6 +78,7 @@ export const SupportedProviders: Record = { hygraph: "Hygraph", imageengine: "ImageEngine", imagekit: "ImageKit", + imagor: "Imagor", imgix: "Imgix", ipx: "IPX", keycdn: "KeyCDN",