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/afraid-pandas-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"unpic": minor
---

feat(truocloud): add TruoCloud provider
1 change: 1 addition & 0 deletions data/domains.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"images.ctfassets.net": "contentful",
"cdn.builder.io": "builder.io",
"images.prismic.io": "imgix",
"img.truo.cloud": "truocloud",
"www.datocms-assets.com": "imgix",
"cdn.sanity.io": "imgix",
"images.unsplash.com": "imgix",
Expand Down
4 changes: 4 additions & 0 deletions demo/src/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@
"Uploadcare",
"https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/"
],
"truocloud": [
"TruoCloud",
"https://img.truo.cloud/i/demo/wikipedia/commons/3/3f/Fronalpstock_big.jpg"
],
"supabase": [
"Supabase",
"https://enlyjtqaeutqbhqgkadn.supabase.co/storage/v1/object/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg"
Expand Down
1 change: 1 addition & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"./providers/shopify": "./src/providers/shopify.ts",
"./providers/storyblok": "./src/providers/storyblok.ts",
"./providers/supabase": "./src/providers/supabase.ts",
"./providers/truocloud": "./src/providers/truocloud.ts",
"./providers/uploadcare": "./src/providers/uploadcare.ts",
"./providers/vercel": "./src/providers/vercel.ts",
"./providers/wordpress": "./src/providers/wordpress.ts",
Expand Down
1 change: 1 addition & 0 deletions src/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const asyncProviderMap: AsyncProviderMap = {
shopify: () => import("./providers/shopify.ts"),
storyblok: () => import("./providers/storyblok.ts"),
supabase: () => import("./providers/supabase.ts"),
truocloud: () => import("./providers/truocloud.ts"),
uploadcare: () => import("./providers/uploadcare.ts"),
vercel: () => import("./providers/vercel.ts"),
wordpress: () => import("./providers/wordpress.ts"),
Expand Down
2 changes: 2 additions & 0 deletions src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { extract as scene7 } from "./providers/scene7.ts";
import { extract as shopify } from "./providers/shopify.ts";
import { extract as storyblok } from "./providers/storyblok.ts";
import { extract as supabase } from "./providers/supabase.ts";
import { extract as truocloud } from "./providers/truocloud.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";
Expand Down Expand Up @@ -60,6 +61,7 @@ export const parsers: URLExtractorMap = {
shopify,
storyblok,
supabase,
truocloud,
uploadcare,
vercel,
wordpress,
Expand Down
77 changes: 77 additions & 0 deletions src/providers/truocloud.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { assertEquals } from "jsr:@std/assert";
import { extract, generate, transform } from "./truocloud.ts";
import { assertEqualIgnoringQueryOrder } from "../test-utils.ts";

const img = "https://img.truo.cloud/i/demo/uploads/photo.jpg";

Deno.test("truocloud extract", async (t) => {
await t.step("should parse a delivery URL", () => {
const result = extract(`${img}?f=auto&w=800`);
assertEquals(result?.src, img);
assertEquals(result?.operations.width, 800);
assertEquals(result?.operations.format, "auto");
});

await t.step("should return null for a URL it does not own", () => {
// The delivery path is `/i/<pid>/…`; anything else on the host is not an
// image, and claiming it would rewrite somebody else's URL.
assertEquals(extract("https://img.truo.cloud/health"), null);
assertEquals(extract("https://images.example.com/a.jpg?w=100"), null);
});

await t.step("should not return the signature as an operation", () => {
// `s` is an HMAC over this exact path and query. Returning it would let a
// caller regenerate a URL carrying a signature that no longer covers it.
const result = extract(`${img}?w=800&s=abc123`);
assertEquals(result?.operations.s, undefined);
});
});

Deno.test("truocloud generate", async (t) => {
await t.step("should format a URL with width and height", () => {
const result = generate(img, { width: 300, height: 200 });
assertEqualIgnoringQueryOrder(result, `${img}?h=200&w=300`);
});

await t.step("should map jpeg to jpg", () => {
// The service answers `jpg` and ignores a format it does not know, so an
// unmapped `jpeg` would silently return the source format.
const result = generate(img, { format: "jpeg" });
assertEqualIgnoringQueryOrder(result, `${img}?f=jpg`);
});

await t.step("should emit parameters in sorted order", () => {
// Every builder of this contract sorts them; two orderings of one request
// are two CDN cache entries for the same image.
const result = generate(img, { quality: 70, width: 800, format: "auto" });
assertEquals(result, `${img}?f=auto&q=70&w=800`);
});

await t.step("should keep commas literal", () => {
// The transformation engine does not decode `%2C`: with the comma escaped
// the crop is ignored and the image comes back uncropped, with a 200.
const result = generate(img, { crop: "60,30,0,0" });
assertEquals(result, `${img}?crop=60,30,0,0`);
});
});

Deno.test("truocloud transform", async (t) => {
await t.step("should change one operation and leave the rest", () => {
const result = transform(`${img}?f=auto&q=70&w=800`, { width: 400 });
assertEqualIgnoringQueryOrder(result, `${img}?f=auto&q=70&w=400`);
});

await t.step("should round non-integer params", () => {
const result = transform(img, { width: 200.6, height: 100.2 });
assertEqualIgnoringQueryOrder(result, `${img}?h=100&w=201`);
});

await t.step("should survive a proxied source intact", () => {
// `/fetch/<encoded url>` proxies a third-party origin. Its slashes are
// percent-encoded so the whole URL stays a single path segment.
const proxied =
"https://img.truo.cloud/i/demo/fetch/https%3A%2F%2Fexample.com%2Fa.jpg";
const result = transform(`${proxied}?w=400`, { width: 800 });
assertEqualIgnoringQueryOrder(result, `${proxied}?w=800`);
});
});
128 changes: 128 additions & 0 deletions src/providers/truocloud.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type {
ImageFormat,
Operations,
URLExtractor,
URLGenerator,
URLTransformer,
} from "../types.ts";
import {
createExtractAndGenerate,
createOperationsHandlers,
toCanonicalUrlString,
toUrl,
} from "../utils.ts";

/**
* `auto` is a first-class value, not a convenience: the service picks avif or
* webp from the browser's `Accept` header and answers `Vary: Accept`.
*/
export type TruoCloudFormats =
| ImageFormat
| "gif"
| "tiff"
| "jxl"
| "json"
| "auto";

export interface TruoCloudOperations extends Operations<TruoCloudFormats> {
/** Width in pixels. */
w?: number;
/** Height in pixels. */
h?: number;
/** Quality, 1-100. Defaults to 82 server-side when a transform is present. */
q?: number;
/** Output format. */
f?: TruoCloudFormats;
/**
* Resize behaviour. The service also accepts the imgix and ImageKit
* vocabularies (`crop`, `clip`, `pad`, `scale`…) and maps them itself; those
* reach it through the index signature below.
*/
fit?: "contain" | "cover" | "fill" | "inside" | "outside";
/** Device pixel ratio, up to 3. */
dpr?: number;
/** Blur radius, up to 100. */
blur?: number;
/** Gravity for `fit=cover`. */
a?: string;
/** Crop rectangle, as `width,height,x,y`. */
crop?: `${number},${number},${number},${number}`;
/** Rotation in degrees. */
ro?: number;
/** Background colour, as hex without `#` or a CSS name. */
bg?: string;
[key: string]: string | number | boolean | undefined;
}

const { operationsGenerator, operationsParser } = createOperationsHandlers<
TruoCloudOperations
>({
keyMap: {
width: "w",
height: "h",
format: "f",
quality: "q",
},
// The service answers `jpg`, not `jpeg`, and silently ignores a format it
// does not know — so an unmapped `jpeg` would return the source format with
// a 200 and no way to tell why.
formatMap: { jpeg: "jpg" },
});

/**
* `/i/<pid>/…` is the only shape this provider handles. The pid pattern is the
* service's own (3-64 lowercase alphanumerics and dashes); matching it rather
* than just `/i/` keeps the provider from claiming an unrelated URL on a custom
* domain that happens to have an `/i/` directory.
*/
const DELIVERY_PATH = /^\/i\/[a-z0-9][a-z0-9-]{2,63}\/.+/;

/**
* Parameters come out sorted by name.
*
* The service accepts any order, but every other builder of this contract emits
* them sorted, and two orderings of one request are two CDN cache entries for
* the same image.
*/
function sortSearch(url: URL): void {
const sorted = [...url.searchParams.entries()].sort(
([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
);
url.search = "";
for (const [key, value] of sorted) {
url.searchParams.append(key, value);
}
// The transformation engine does not decode `%2C`, so `crop=60,30,0,0` has
// to keep its commas literal or the crop is ignored and the image comes
// back uncropped, with a 200.
url.search = url.search.replace(/%2C/g, ",");
}

export const extract: URLExtractor<"truocloud"> = (url) => {
const src = toUrl(url);
if (!DELIVERY_PATH.test(src.pathname)) {
return null;
}

const operations = operationsParser(url);
// `s` and `exp` are an HMAC over this exact path and query. Returning them
// as operations would let a caller regenerate a URL carrying a signature
// that no longer covers it.
delete operations.s;
delete operations.exp;

src.search = "";
return { src: toCanonicalUrlString(src), operations };
};

export const generate: URLGenerator<"truocloud"> = (src, operations) => {
const url = toUrl(src);
url.search = operationsGenerator(operations);
sortSearch(url);
return toCanonicalUrlString(url);
};

export const transform: URLTransformer<"truocloud"> = createExtractAndGenerate(
extract,
generate,
);
3 changes: 3 additions & 0 deletions src/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type { Scene7Operations } from "./scene7.ts";
import type { ShopifyOperations } from "./shopify.ts";
import type { StoryblokOperations } from "./storyblok.ts";
import type { SupabaseOperations } from "./supabase.ts";
import type { TruoCloudOperations } from "./truocloud.ts";
import type { UploadcareOperations, UploadcareOptions } from "./uploadcare.ts";
import type { VercelOperations, VercelOptions } from "./vercel.ts";
import type { WordPressOperations } from "./wordpress.ts";
Expand Down Expand Up @@ -64,6 +65,7 @@ export interface ProviderOperations {
shopify: ShopifyOperations;
storyblok: StoryblokOperations;
supabase: SupabaseOperations;
truocloud: TruoCloudOperations;
uploadcare: UploadcareOperations;
vercel: VercelOperations;
wordpress: WordPressOperations;
Expand Down Expand Up @@ -95,6 +97,7 @@ export interface ProviderOptions {
shopify: undefined;
storyblok: undefined;
supabase: undefined;
truocloud: undefined;
uploadcare: UploadcareOptions;
vercel: VercelOptions;
wordpress: undefined;
Expand Down
2 changes: 2 additions & 0 deletions src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { transform as scene7 } from "./providers/scene7.ts";
import { transform as shopify } from "./providers/shopify.ts";
import { transform as storyblok } from "./providers/storyblok.ts";
import { transform as supabase } from "./providers/supabase.ts";
import { transform as truocloud } from "./providers/truocloud.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";
Expand Down Expand Up @@ -63,6 +64,7 @@ const transformerMap: URLTransformerMap = {
shopify,
storyblok,
supabase,
truocloud,
uploadcare,
vercel,
wordpress,
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type ImageCdn =
| "imagekit"
| "uploadcare"
| "supabase"
| "truocloud"
| "hygraph"
| "appwrite"
| "wsrv";
Expand Down Expand Up @@ -87,6 +88,7 @@ export const SupportedProviders: Record<ImageCdn, string> = {
shopify: "Shopify",
storyblok: "Storyblok",
supabase: "Supabase",
truocloud: "TruoCloud",
uploadcare: "Uploadcare",
vercel: "Vercel",
wordpress: "WordPress",
Expand Down
Loading