diff --git a/.changeset/afraid-pandas-shake.md b/.changeset/afraid-pandas-shake.md new file mode 100644 index 0000000..1e8e3d0 --- /dev/null +++ b/.changeset/afraid-pandas-shake.md @@ -0,0 +1,5 @@ +--- +"unpic": minor +--- + +feat(truocloud): add TruoCloud provider diff --git a/data/domains.json b/data/domains.json index f13205f..119ae15 100644 --- a/data/domains.json +++ b/data/domains.json @@ -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", diff --git a/demo/src/examples.json b/demo/src/examples.json index bffd3d8..63fdf98 100644 --- a/demo/src/examples.json +++ b/demo/src/examples.json @@ -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" diff --git a/deno.jsonc b/deno.jsonc index 88c341a..5e4e371 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -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", diff --git a/src/async.ts b/src/async.ts index bb77294..80869a9 100644 --- a/src/async.ts +++ b/src/async.ts @@ -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"), diff --git a/src/extract.ts b/src/extract.ts index 2ec907a..7741148 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -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"; @@ -60,6 +61,7 @@ export const parsers: URLExtractorMap = { shopify, storyblok, supabase, + truocloud, uploadcare, vercel, wordpress, diff --git a/src/providers/truocloud.test.ts b/src/providers/truocloud.test.ts new file mode 100644 index 0000000..8cef623 --- /dev/null +++ b/src/providers/truocloud.test.ts @@ -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//…`; 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/` 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`); + }); +}); diff --git a/src/providers/truocloud.ts b/src/providers/truocloud.ts new file mode 100644 index 0000000..5e6c9c2 --- /dev/null +++ b/src/providers/truocloud.ts @@ -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 { + /** 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//…` 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, +); diff --git a/src/providers/types.ts b/src/providers/types.ts index 3166818..3fcc78c 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -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"; @@ -64,6 +65,7 @@ export interface ProviderOperations { shopify: ShopifyOperations; storyblok: StoryblokOperations; supabase: SupabaseOperations; + truocloud: TruoCloudOperations; uploadcare: UploadcareOperations; vercel: VercelOperations; wordpress: WordPressOperations; @@ -95,6 +97,7 @@ export interface ProviderOptions { shopify: undefined; storyblok: undefined; supabase: undefined; + truocloud: undefined; uploadcare: UploadcareOptions; vercel: VercelOptions; wordpress: undefined; diff --git a/src/transform.ts b/src/transform.ts index d9bc03b..70ad708 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -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"; @@ -63,6 +64,7 @@ const transformerMap: URLTransformerMap = { shopify, storyblok, supabase, + truocloud, uploadcare, vercel, wordpress, diff --git a/src/types.ts b/src/types.ts index 3492606..c4306a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,7 @@ export type ImageCdn = | "imagekit" | "uploadcare" | "supabase" + | "truocloud" | "hygraph" | "appwrite" | "wsrv"; @@ -87,6 +88,7 @@ export const SupportedProviders: Record = { shopify: "Shopify", storyblok: "Storyblok", supabase: "Supabase", + truocloud: "TruoCloud", uploadcare: "Uploadcare", vercel: "Vercel", wordpress: "WordPress",