diff --git a/.changeset/bright-images-transform.md b/.changeset/bright-images-transform.md new file mode 100644 index 0000000..986624b --- /dev/null +++ b/.changeset/bright-images-transform.md @@ -0,0 +1,5 @@ +--- +"unpic": minor +--- + +feat(autorender): add Autorender image CDN provider diff --git a/README.md b/README.md index c90f44a..db61e32 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ const url = transform( - Adobe Dynamic Media (Scene7) `scene7` - Astro image service `astro` +- Autorender `autorender` - Builder.io `builder.io` - Bunny.net, including caisy `bunny` - Cloudflare `cloudflare` and `cloudflare_images` diff --git a/data/domains.json b/data/domains.json index f13205f..8108910 100644 --- a/data/domains.json +++ b/data/domains.json @@ -1,4 +1,5 @@ { + "assets.autorender.io": "autorender", "images.ctfassets.net": "contentful", "cdn.builder.io": "builder.io", "images.prismic.io": "imgix", diff --git a/demo/src/examples.json b/demo/src/examples.json index bffd3d8..9577445 100644 --- a/demo/src/examples.json +++ b/demo/src/examples.json @@ -1,4 +1,8 @@ { + "autorender": [ + "Autorender", + "https://assets.autorender.io/cQQ9C4UxSb/w_720/ar-juice.jpg" + ], "shopify": [ "Shopify", "https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg" diff --git a/deno.jsonc b/deno.jsonc index 88c341a..273fd3c 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -7,6 +7,7 @@ "./utils": "./src/utils.ts", "./providers/appwrite": "./src/providers/appwrite.ts", "./providers/astro": "./src/providers/astro.ts", + "./providers/autorender": "./src/providers/autorender.ts", "./providers/builder.io": "./src/providers/builder.io.ts", "./providers/bunny": "./src/providers/bunny.ts", "./providers/cloudflare_images": "./src/providers/cloudflare_images.ts", @@ -60,4 +61,4 @@ ] }, "license": "MIT" -} \ No newline at end of file +} diff --git a/src/async.ts b/src/async.ts index bb77294..8f50250 100644 --- a/src/async.ts +++ b/src/async.ts @@ -15,6 +15,7 @@ type AsyncProviderMap = { const asyncProviderMap: AsyncProviderMap = { appwrite: () => import("./providers/appwrite.ts"), astro: () => import("./providers/astro.ts"), + autorender: () => import("./providers/autorender.ts"), "builder.io": () => import("./providers/builder.io.ts"), bunny: () => import("./providers/bunny.ts"), cloudflare: () => import("./providers/cloudflare.ts"), diff --git a/src/extract.ts b/src/extract.ts index 2ec907a..53f70e5 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -8,6 +8,7 @@ import type { ImageCdn, ParseURLResult, URLExtractor } from "./types.ts"; import { extract as appwrite } from "./providers/appwrite.ts"; import { extract as astro } from "./providers/astro.ts"; +import { extract as autorender } from "./providers/autorender.ts"; import { extract as builder } from "./providers/builder.io.ts"; import { extract as bunny } from "./providers/bunny.ts"; import { extract as cloudflare } from "./providers/cloudflare.ts"; @@ -38,6 +39,7 @@ import { extract as wsrv } from "./providers/wsrv.ts"; export const parsers: URLExtractorMap = { appwrite, astro, + autorender, "builder.io": builder, bunny, cloudflare, diff --git a/src/providers/autorender.test.ts b/src/providers/autorender.test.ts new file mode 100644 index 0000000..b845b40 --- /dev/null +++ b/src/providers/autorender.test.ts @@ -0,0 +1,176 @@ +import { assertEquals } from "jsr:@std/assert"; +import { extract, generate, transform } from "./autorender.ts"; + +const base = "https://assets.autorender.io/cQQ9C4UxSb/ar-juice.jpg"; + +Deno.test("Autorender extract", async (t) => { + await t.step("parses an untransformed URL", () => { + assertEquals(extract(base), { + src: base, + operations: {}, + options: { + workspace: "cQQ9C4UxSb", + }, + }); + }); + + await t.step("parses path-based transformations", () => { + assertEquals( + extract( + "https://assets.autorender.io/cQQ9C4UxSb/w_720,f_webp,q_80/ar-juice.jpg", + ), + { + src: base, + operations: { width: 720, format: "webp", quality: 80 }, + options: { + workspace: "cQQ9C4UxSb", + }, + }, + ); + }); + + await t.step("parses provider-specific crop operations", () => { + const parsed = extract( + "https://assets.autorender.io/cQQ9C4UxSb/c_fit,w_720,h_480/ar-juice.jpg", + ); + assertEquals(parsed?.operations, { + c: "fit", + width: 720, + height: 480, + }); + }); + + await t.step("recognizes nonstandard transformation tokens", () => { + const parsed = extract( + "https://assets.autorender.io/cQQ9C4UxSb/fo_smart,w_720/ar-juice.jpg", + ); + assertEquals(parsed?.src, base); + assertEquals(parsed?.operations as Record, { + fo: "smart", + width: 720, + }); + }); + + await t.step("preserves nested asset paths", () => { + const nested = + "https://assets.autorender.io/cQQ9C4UxSb/folder/sub/ar-juice.jpg"; + assertEquals(extract(nested)?.src, nested); + }); + + await t.step("rejects non-Autorender URLs", () => { + assertEquals(extract("https://example.com/photo.jpg"), null); + }); + + await t.step("rejects custom domains", () => { + assertEquals( + extract("https://images.example.com/cQQ9C4UxSb/photo.jpg"), + null, + ); + }); + + await t.step("rejects malformed URLs", () => { + assertEquals(extract("not a URL"), null); + }); +}); + +Deno.test("Autorender generate and transform", async (t) => { + await t.step("generates a transformed asset URL", () => { + assertEquals( + generate(base, { width: 720, format: "webp" }), + "https://assets.autorender.io/cQQ9C4UxSb/w_720,f_webp/ar-juice.jpg", + ); + }); + + await t.step("uses auto format by default", () => { + assertEquals( + generate(base, { width: 720 }), + "https://assets.autorender.io/cQQ9C4UxSb/w_720,f_auto/ar-juice.jpg", + ); + }); + + await t.step("uses cover behavior for width and height", () => { + assertEquals( + generate(base, { width: 720, height: 480 }), + "https://assets.autorender.io/cQQ9C4UxSb/c_fill,w_720,h_480,f_auto/ar-juice.jpg", + ); + }); + + await t.step("allows explicit crop behavior and quality", () => { + assertEquals( + generate(base, { + width: 720, + height: 480, + c: "fit", + quality: 80, + }), + "https://assets.autorender.io/cQQ9C4UxSb/c_fit,w_720,h_480,q_80,f_auto/ar-juice.jpg", + ); + }); + + await t.step("replaces existing transformations", () => { + assertEquals( + transform( + "https://assets.autorender.io/cQQ9C4UxSb/w_320/ar-juice.jpg", + { width: 720 }, + ), + "https://assets.autorender.io/cQQ9C4UxSb/w_720,f_auto/ar-juice.jpg", + ); + }); + + await t.step( + "preserves existing provider operations while adding quality", + () => { + assertEquals( + transform( + "https://assets.autorender.io/cQQ9C4UxSb/c_fill,w_320,h_240,f_webp,br_16/ar-juice.jpg", + { quality: 80 }, + ), + "https://assets.autorender.io/cQQ9C4UxSb/c_fill,br_16,w_320,h_240,f_webp,q_80/ar-juice.jpg", + ); + }, + ); + + await t.step("requires a workspace for relative asset paths", () => { + try { + generate("/images/photo.jpg", { width: 720 }); + throw new Error("expected generate to throw"); + } catch (error) { + assertEquals( + (error as Error).message, + "Autorender workspace is required to generate a URL", + ); + } + }); + + await t.step("rejects unsupported remote source URLs", () => { + try { + generate("https://example.com/photo.jpg", { + width: 720, + }, { workspace: "cQQ9C4UxSb" }); + throw new Error("expected generate to throw"); + } catch (error) { + assertEquals( + (error as Error).message, + "Autorender provider only supports Autorender URLs or relative asset paths", + ); + } + }); + + await t.step("supports relative asset paths with a workspace", () => { + assertEquals( + generate("/images/photo.jpg", { width: 720 }, { + workspace: "cQQ9C4UxSb", + }), + "https://assets.autorender.io/cQQ9C4UxSb/w_720,f_auto/images/photo.jpg", + ); + }); + + await t.step("supports nested relative asset paths", () => { + assertEquals( + generate("/images/products/photo.jpg", { width: 720 }, { + workspace: "cQQ9C4UxSb", + }), + "https://assets.autorender.io/cQQ9C4UxSb/w_720,f_auto/images/products/photo.jpg", + ); + }); +}); diff --git a/src/providers/autorender.ts b/src/providers/autorender.ts new file mode 100644 index 0000000..d0372c2 --- /dev/null +++ b/src/providers/autorender.ts @@ -0,0 +1,135 @@ +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsGenerator, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +const DEFAULT_BASE_URL = "https://assets.autorender.io"; + +export interface AutorenderOperations extends Operations { + /** Crop to fill the requested dimensions, or fit inside them. */ + c?: "fill" | "fit" | "crop"; +} + +type AutorenderUrlOperations = AutorenderOperations & { + w?: number | string; + h?: number | string; + f?: string; + q?: number | string; +}; + +export interface AutorenderOptions { + /** Autorender workspace ID used when generating from a relative asset path. */ + workspace?: string; +} + +const operationsConfig = { + keyMap: { + width: "w", + height: "h", + format: "f", + quality: "q", + }, + defaults: { format: "auto" }, + kvSeparator: "_", + paramSeparator: ",", +} as const; + +const operationsGeneratorBase = createOperationsGenerator< + AutorenderUrlOperations +>( + operationsConfig, +); +const operationsGenerator = (operations: AutorenderOperations) => { + const normalized = { ...operations }; + if (normalized.width !== undefined && normalized.height !== undefined) { + normalized.c ??= "fill"; + } + return operationsGeneratorBase(normalized); +}; +const { operationsParser } = createOperationsHandlers( + operationsConfig, +); + +const TOKEN = /^[a-z]{1,5}_[^\/,]*$/; +const isTransformationSegment = (segment: string): boolean => + segment.split(",").every((part) => TOKEN.test(part)); + +const getPathParts = (src: string | URL) => { + const url = toUrl(src); + const parts = url.pathname.split("/").filter(Boolean); + return { url, parts }; +}; + +export const extract: URLExtractor<"autorender"> = (src) => { + const { url, parts } = getPathParts(src); + const base = toUrl(DEFAULT_BASE_URL); + + if (url.hostname !== base.hostname || parts.length < 2) { + return null; + } + + const workspace = parts[0]; + const hasTransformations = isTransformationSegment(parts[1]); + const transformation = hasTransformations ? parts[1] : ""; + const assetPath = parts.slice(hasTransformations ? 2 : 1).join("/"); + const source = new URL(url.toString()); + source.pathname = `/${workspace}/${assetPath}`; + source.search = ""; + + return { + src: toCanonicalUrlString(source), + operations: transformation ? operationsParser(transformation) : {}, + options: { workspace }, + }; +}; + +export const generate: URLGenerator<"autorender"> = ( + src, + operations, + options = {}, +) => { + const parsed = extract(src, options); + const workspace = options.workspace ?? parsed?.options?.workspace; + + if (!workspace) { + throw new Error("Autorender workspace is required to generate a URL"); + } + + const url = toUrl(DEFAULT_BASE_URL); + const modifiers = operationsGenerator(operations); + const source = parsed?.src ?? src.toString(); + const sourceUrl = toUrl(source); + const sourceParts = sourceUrl.pathname.split("/").filter(Boolean); + + if (parsed) { + const assetPath = sourceParts.slice(1).join("/"); + url.pathname = `/${workspace}/${ + modifiers ? `${modifiers}/` : "" + }${assetPath}`; + } else if (sourceUrl.hostname === "n") { + const assetPath = sourceUrl.pathname.replace(/^\/+/, ""); + url.pathname = `/${workspace}/${ + modifiers ? `${modifiers}/` : "" + }${assetPath}`; + } else { + throw new Error( + "Autorender provider only supports Autorender URLs or relative asset paths", + ); + } + + return toCanonicalUrlString(url); +}; + +export const transform: URLTransformer<"autorender"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/types.ts b/src/providers/types.ts index 3166818..f5d077c 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -6,6 +6,7 @@ import type { } from "../types.ts"; import type { AppwriteOperations } from "./appwrite.ts"; import type { AstroOperations, AstroOptions } from "./astro.ts"; +import type { AutorenderOperations, AutorenderOptions } from "./autorender.ts"; import type { BuilderOperations } from "./builder.io.ts"; import type { BunnyOperations } from "./bunny.ts"; import type { CloudflareOperations, CloudflareOptions } from "./cloudflare.ts"; @@ -42,6 +43,7 @@ import type { WsrvOperations } from "./wsrv.ts"; export interface ProviderOperations { appwrite: AppwriteOperations; astro: AstroOperations; + autorender: AutorenderOperations; "builder.io": BuilderOperations; bunny: BunnyOperations; cloudflare: CloudflareOperations; @@ -73,6 +75,7 @@ export interface ProviderOperations { export interface ProviderOptions { appwrite: undefined; astro: AstroOptions; + autorender: AutorenderOptions; "builder.io": undefined; bunny: undefined; cloudflare: CloudflareOptions; diff --git a/src/transform.ts b/src/transform.ts index d9bc03b..b329077 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,6 +1,7 @@ import { getProviderForUrl } from "./detect.ts"; import { transform as appwrite } from "./providers/appwrite.ts"; import { transform as astro } from "./providers/astro.ts"; +import { transform as autorender } from "./providers/autorender.ts"; import { transform as builderio } from "./providers/builder.io.ts"; import { transform as bunny } from "./providers/bunny.ts"; import { transform as cloudflare } from "./providers/cloudflare.ts"; @@ -41,6 +42,7 @@ import type { const transformerMap: URLTransformerMap = { appwrite, astro, + autorender, "builder.io": builderio, bunny, cloudflare, diff --git a/src/types.ts b/src/types.ts index 3492606..76e6902 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,7 @@ export interface UrlGeneratorOptions> { } export type ImageCdn = + | "autorender" | "contentful" | "builder.io" | "cloudimage" @@ -65,6 +66,7 @@ export type ImageCdn = export const SupportedProviders: Record = { appwrite: "Appwrite", astro: "Astro image service", + autorender: "Autorender", "builder.io": "Builder.io", bunny: "Bunny.net", cloudflare: "Cloudflare",