diff --git a/demo/src/examples.json b/demo/src/examples.json index bffd3d8..c67bf57 100644 --- a/demo/src/examples.json +++ b/demo/src/examples.json @@ -99,5 +99,9 @@ "wsrv": [ "wsrv.nl", "https://wsrv.nl/?url=images.unsplash.com/photo-1560807707-8cc77767d783" + ], + "umbraco": [ + "Umbraco", + "https://umbraco.com/media/z2ef0fnx/umbraco_250314_1169.jpg" ] } diff --git a/src/extract.ts b/src/extract.ts index 2ec907a..b2c8b12 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -34,6 +34,7 @@ import { extract as uploadcare } from "./providers/uploadcare.ts"; import { extract as vercel } from "./providers/vercel.ts"; import { extract as wordpress } from "./providers/wordpress.ts"; import { extract as wsrv } from "./providers/wsrv.ts"; +import { extract as umbraco } from "./providers/umbraco.ts"; export const parsers: URLExtractorMap = { appwrite, @@ -60,6 +61,7 @@ export const parsers: URLExtractorMap = { shopify, storyblok, supabase, + umbraco, uploadcare, vercel, wordpress, diff --git a/src/providers/types.ts b/src/providers/types.ts index 3166818..534d881 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -38,6 +38,7 @@ import type { UploadcareOperations, UploadcareOptions } from "./uploadcare.ts"; import type { VercelOperations, VercelOptions } from "./vercel.ts"; import type { WordPressOperations } from "./wordpress.ts"; import type { WsrvOperations } from "./wsrv.ts"; +import type { UmbracoOperations, UmbracoOptions } from "./umbraco.ts"; export interface ProviderOperations { appwrite: AppwriteOperations; @@ -64,6 +65,7 @@ export interface ProviderOperations { shopify: ShopifyOperations; storyblok: StoryblokOperations; supabase: SupabaseOperations; + umbraco: UmbracoOperations; uploadcare: UploadcareOperations; vercel: VercelOperations; wordpress: WordPressOperations; @@ -95,6 +97,7 @@ export interface ProviderOptions { shopify: undefined; storyblok: undefined; supabase: undefined; + umbraco: UmbracoOptions; uploadcare: UploadcareOptions; vercel: VercelOptions; wordpress: undefined; diff --git a/src/providers/umbraco.test.ts b/src/providers/umbraco.test.ts new file mode 100644 index 0000000..aba7175 --- /dev/null +++ b/src/providers/umbraco.test.ts @@ -0,0 +1,249 @@ +import { assertEquals } from "jsr:@std/assert"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { extract, generate, transform } from "./umbraco.ts"; + +// Relative URL (typical Umbraco self-hosted usage) +const relImg = "/media/abc123/photo.jpg"; +// Absolute URL — the real Umbraco website example +const absImg = "https://umbraco.com/media/z2ef0fnx/umbraco_250314_1169.jpg"; + +Deno.test("umbraco extract", async (t) => { + await t.step("should extract width, height, rmode from URL", () => { + const result = extract(`${absImg}?width=1200&height=630&rmode=crop`); + assertEquals(result?.src, absImg); + assertEquals(result?.operations, { + width: 1200, + height: 630, + rmode: "crop", + }); + assertEquals(result?.options, { baseUrl: "https://umbraco.com" }); + }); + + await t.step("should extract all standard operations", () => { + const result = extract( + `${absImg}?width=784&height=897&quality=85&format=webp`, + ); + assertEquals(result?.src, absImg); + assertEquals(result?.operations, { + width: 784, + height: 897, + quality: 85, + format: "webp", + }); + assertEquals(result?.options, { baseUrl: "https://umbraco.com" }); + }); + + await t.step("should extract rxy focal point", () => { + const result = extract( + `${absImg}?width=800&height=800&rxy=0.5,0.2&rmode=crop`, + ); + assertEquals(result?.src, absImg); + assertEquals(result?.operations, { + width: 800, + height: 800, + rxy: "0.5,0.2", + rmode: "crop", + }); + }); + + await t.step("should extract bgcolor", () => { + const result = extract(`${absImg}?width=800&bgcolor=FFFFFF`); + assertEquals(result?.src, absImg); + assertEquals(result?.operations, { + width: 800, + bgcolor: "FFFFFF", + }); + }); + + await t.step("should strip query params from src", () => { + const result = extract(`${absImg}?width=400&format=webp&quality=75`); + assertEquals(result?.src, absImg); + }); + + await t.step("should handle relative URLs", () => { + const result = extract(`${relImg}?width=300&height=200`); + assertEquals(result?.src, relImg); + assertEquals(result?.operations, { + width: 300, + height: 200, + }); + assertEquals(result?.options, { baseUrl: undefined }); + }); + + await t.step( + "should resolve relative URL when baseUrl option supplied", + () => { + const result = extract(`${relImg}?width=300`, { + baseUrl: "https://mysite.com", + }); + assertEquals(result?.src, `https://mysite.com${relImg}`); + assertEquals(result?.options, { baseUrl: "https://mysite.com" }); + }, + ); + + await t.step("should return empty operations for URL with no params", () => { + const result = extract(absImg); + assertEquals(result?.src, absImg); + assertEquals(result?.operations, {}); + }); +}); + +Deno.test("umbraco generate", async (t) => { + await t.step("should generate URL with width only", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 1200 }), + `${absImg}?width=1200&rmode=crop`, + ); + }); + + await t.step("should generate URL with width and height", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 1200, height: 630 }), + `${absImg}?width=1200&height=630&rmode=crop`, + ); + }); + + await t.step("should generate URL with format conversion", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 800, format: "webp" }), + `${absImg}?width=800&format=webp&rmode=crop`, + ); + }); + + await t.step("should generate URL with quality", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 800, format: "webp", quality: 75 }), + `${absImg}?width=800&format=webp&quality=75&rmode=crop`, + ); + }); + + await t.step("should generate URL with bgcolor", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 800, bgcolor: "FFFFFF" }), + `${absImg}?width=800&bgcolor=FFFFFF&rmode=crop`, + ); + }); + + await t.step("should generate URL with rxy focal point", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 800, height: 800, rxy: "0.5,0.2" }), + `${absImg}?width=800&height=800&rxy=0.5%2C0.2&rmode=crop`, + ); + }); + + await t.step("should allow overriding the default rmode", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 800, rmode: "stretch" }), + `${absImg}?width=800&rmode=stretch`, + ); + }); + + await t.step("should allow rmode=pad with bgcolor", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { + width: 800, + height: 600, + rmode: "pad", + bgcolor: "000000", + }), + `${absImg}?width=800&height=600&rmode=pad&bgcolor=000000`, + ); + }); + + await t.step("should generate URL with rsampler", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 400, rsampler: "nearest" }), + `${absImg}?width=400&rsampler=nearest&rmode=crop`, + ); + }); + + await t.step("should generate URL with ranchor", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 400, height: 300, ranchor: "top" }), + `${absImg}?width=400&height=300&ranchor=top&rmode=crop`, + ); + }); + + await t.step("should handle relative URLs", () => { + assertEqualIgnoringQueryOrder( + generate(relImg, { width: 300, height: 200 }), + `${relImg}?width=300&height=200&rmode=crop`, + ); + }); + + await t.step("should resolve relative URL with baseUrl option", () => { + assertEqualIgnoringQueryOrder( + generate(relImg, { width: 300, height: 200 }, { + baseUrl: "https://mysite.com", + }), + "https://mysite.com/media/abc123/photo.jpg?width=300&height=200&rmode=crop", + ); + }); + + await t.step( + "should still produce absolute URL when src is already absolute", + () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 400 }, { baseUrl: "https://other.com" }), + `${absImg}?width=400&rmode=crop`, + ); + }, + ); + + await t.step("should round non-integer dimensions", () => { + assertEqualIgnoringQueryOrder( + generate(absImg, { width: 400.6, height: 300.2 }), + `${absImg}?width=401&height=300&rmode=crop`, + ); + }); +}); + +Deno.test("umbraco transform", async (t) => { + await t.step("should transform URL by merging new operations", () => { + const url = `${absImg}?width=400&height=300&format=jpg`; + assertEqualIgnoringQueryOrder( + transform(url, { width: 800 }), + `${absImg}?width=800&height=300&format=jpg&rmode=crop`, + ); + }); + + await t.step("should add rmode=crop by default when transforming", () => { + assertEqualIgnoringQueryOrder( + transform(absImg, { width: 600, height: 400 }), + `${absImg}?width=600&height=400&rmode=crop`, + ); + }); + + await t.step("should preserve existing rmode when transforming", () => { + const url = `${absImg}?width=400&rmode=pad&bgcolor=FFFFFF`; + assertEqualIgnoringQueryOrder( + transform(url, { width: 800 }), + `${absImg}?width=800&rmode=pad&bgcolor=FFFFFF`, + ); + }); + + await t.step("should transform relative URL", () => { + assertEqualIgnoringQueryOrder( + transform(relImg, { width: 300 }), + `${relImg}?width=300&rmode=crop`, + ); + }); + + await t.step( + "should resolve relative URL with baseUrl when transforming", + () => { + assertEqualIgnoringQueryOrder( + transform(relImg, { width: 300 }, { baseUrl: "https://mysite.com" }), + "https://mysite.com/media/abc123/photo.jpg?width=300&rmode=crop", + ); + }, + ); + + await t.step("should round-trip the real Umbraco example URL", () => { + const url = `${absImg}?format=webp&width=784&height=897&quality=85`; + assertEqualIgnoringQueryOrder( + transform(url, { width: 400 }), + `${absImg}?width=400&height=897&format=webp&quality=85&rmode=crop`, + ); + }); +}); diff --git a/src/providers/umbraco.ts b/src/providers/umbraco.ts new file mode 100644 index 0000000..267522e --- /dev/null +++ b/src/providers/umbraco.ts @@ -0,0 +1,169 @@ +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +/** + * Resize modes supported by ImageSharp.Web. + * @see https://docs.sixlabors.com/api/ImageSharp/SixLabors.ImageSharp.Processing.ResizeMode.html + */ +export type UmbracoResizeMode = + | "crop" + | "pad" + | "boxpad" + | "max" + | "min" + | "stretch"; + +/** + * Resampler algorithms supported by ImageSharp.Web. + * @see https://docs.sixlabors.com/articles/imagesharp.web/processingcommands.html + */ +export type UmbracoResampler = + | "bicubic" + | "nearest" + | "box" + | "mitchell" + | "catmull" + | "lanczos2" + | "lanczos3" + | "lanczos5" + | "lanczos8" + | "welch" + | "robidoux" + | "robidouxsharp" + | "spline" + | "triangle" + | "hermite"; + +/** + * Anchor position modes supported by ImageSharp.Web. + * @see https://docs.sixlabors.com/api/ImageSharp/SixLabors.ImageSharp.Processing.AnchorPositionMode.html + */ +export type UmbracoAnchor = + | "center" + | "top" + | "bottom" + | "left" + | "right" + | "topleft" + | "topright" + | "bottomleft" + | "bottomright"; + +/** + * Options for the Umbraco provider. + */ +export interface UmbracoOptions { + /** + * Base URL of the Umbraco site. Used to resolve the relative media paths + * returned by the Umbraco Content Delivery API (e.g. `/media/abc123/photo.jpg`) + * into absolute URLs. If omitted, relative URLs are kept relative. + * + * @example "https://my-umbraco-site.com" + */ + baseUrl?: string; +} + +/** + * Image transform options for Umbraco media URLs powered by ImageSharp.Web. + * + * Umbraco stores media files under `/media/` and uses ImageSharp.Web middleware + * for on-the-fly image processing. All parameters are passed as query strings. + * + * @see https://docs.sixlabors.com/articles/imagesharp.web/processingcommands.html + * @see https://docs.umbraco.com/umbraco-cms/fundamentals/backoffice/property-editors/built-in-umbraco-property-editors/image-cropper + */ +export interface UmbracoOperations extends Operations { + /** + * Controls how the image is resized to fit the requested dimensions. + * Defaults to `"crop"` (equivalent to CSS `object-fit: cover`). + * - `crop`: Crops the image to fill the dimensions exactly. + * - `pad`: Pads with background color to fill the dimensions. + * - `boxpad`: Pads with background color without upscaling. + * - `max`: Resizes to fit within dimensions without upscaling. + * - `min`: Resizes to fit within dimensions without downscaling. + * - `stretch`: Stretches the image to fill dimensions exactly (distorts). + */ + rmode?: UmbracoResizeMode; + + /** + * The resampling algorithm to use when resizing. + * Defaults to `bicubic` (ImageSharp.Web default). + */ + rsampler?: UmbracoResampler; + + /** + * The anchor position to use when cropping. + * Used when `rmode` is `crop` and no `rxy` focal point is set. + */ + ranchor?: UmbracoAnchor; + + /** + * Focal point for cropping, as comma-separated x,y values in the range 0–1. + * For example, `"0.5,0.2"` focuses on the horizontal center, 20% from the top. + * Set by the Umbraco Image Cropper's focal point UI. + */ + rxy?: string; + + /** + * Background color for transparent images or padding (used with `rmode=pad`). + * Accepts hex (without `#`), named colors, or RGB values. + * Examples: `"FFFFFF"`, `"red"`, `"128,64,32"` + */ + bgcolor?: string; + + /** + * Whether to apply EXIF orientation correction. + * Defaults to `true` in ImageSharp.Web. + */ + orient?: boolean; + + /** + * Whether to compress and expand pixel color values to/from a linear + * color space when processing. Defaults to `false`. + */ + compand?: boolean; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + UmbracoOperations +>({ + // ImageSharp.Web uses the same parameter names as unpic's standard Operations + // (width, height, format, quality), so no keyMap remapping is needed. + defaults: { + rmode: "crop", + }, +}); + +export const extract: URLExtractor<"umbraco"> = (url, options) => { + const parsedUrl = toUrl(url, options?.baseUrl); + const operations = operationsParser(parsedUrl); + parsedUrl.search = ""; + return { + src: toCanonicalUrlString(parsedUrl), + operations, + options: { + baseUrl: parsedUrl.hostname === "n" ? undefined : parsedUrl.origin, + }, + }; +}; + +export const generate: URLGenerator<"umbraco"> = (src, operations, options) => { + const url = toUrl(src, options?.baseUrl); + url.search = operationsGenerator(operations); + return toCanonicalUrlString(url); +}; + +export const transform: URLTransformer<"umbraco"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/transform.ts b/src/transform.ts index d9bc03b..fc2c3ee 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -27,6 +27,7 @@ import { transform as uploadcare } from "./providers/uploadcare.ts"; import { transform as vercel } from "./providers/vercel.ts"; import { transform as wordpress } from "./providers/wordpress.ts"; import { transform as wsrv } from "./providers/wsrv.ts"; +import { transform as umbraco } from "./providers/umbraco.ts"; import type { ImageCdn, URLTransformer, @@ -63,6 +64,7 @@ const transformerMap: URLTransformerMap = { shopify, storyblok, supabase, + umbraco, uploadcare, vercel, wordpress, diff --git a/src/types.ts b/src/types.ts index 3492606..3d8480a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,7 +60,8 @@ export type ImageCdn = | "supabase" | "hygraph" | "appwrite" - | "wsrv"; + | "wsrv" + | "umbraco"; export const SupportedProviders: Record = { appwrite: "Appwrite", @@ -87,6 +88,7 @@ export const SupportedProviders: Record = { shopify: "Shopify", storyblok: "Storyblok", supabase: "Supabase", + umbraco: "Umbraco", uploadcare: "Uploadcare", vercel: "Vercel", wordpress: "WordPress",