Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-imagor-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"unpic": minor
---

feat: add imagor provider
4 changes: 4 additions & 0 deletions demo/src/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
1 change: 1 addition & 0 deletions src/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -50,6 +51,7 @@ export const parsers: URLExtractorMap = {
hygraph,
imageengine,
imagekit,
imagor,
imgix,
ipx,
keycdn,
Expand Down
282 changes: 282 additions & 0 deletions src/providers/imagor.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
Loading
Loading