Skip to content

Commit 0ed821f

Browse files
committed
feat(umbraco): add Umbraco provider
with extraction, generation, and transformation capabilities
1 parent d60ab8f commit 0ed821f

File tree

7 files changed

+432
-1
lines changed

7 files changed

+432
-1
lines changed

demo/src/examples.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,5 +99,9 @@
9999
"wsrv": [
100100
"wsrv.nl",
101101
"https://wsrv.nl/?url=images.unsplash.com/photo-1560807707-8cc77767d783"
102+
],
103+
"umbraco": [
104+
"Umbraco",
105+
"https://umbraco.com/media/z2ef0fnx/umbraco_250314_1169.jpg"
102106
]
103107
}

src/extract.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { extract as uploadcare } from "./providers/uploadcare.ts";
3434
import { extract as vercel } from "./providers/vercel.ts";
3535
import { extract as wordpress } from "./providers/wordpress.ts";
3636
import { extract as wsrv } from "./providers/wsrv.ts";
37+
import { extract as umbraco } from "./providers/umbraco.ts";
3738

3839
export const parsers: URLExtractorMap = {
3940
appwrite,
@@ -60,6 +61,7 @@ export const parsers: URLExtractorMap = {
6061
shopify,
6162
storyblok,
6263
supabase,
64+
umbraco,
6365
uploadcare,
6466
vercel,
6567
wordpress,

src/providers/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type { UploadcareOperations, UploadcareOptions } from "./uploadcare.ts";
3838
import type { VercelOperations, VercelOptions } from "./vercel.ts";
3939
import type { WordPressOperations } from "./wordpress.ts";
4040
import type { WsrvOperations } from "./wsrv.ts";
41+
import type { UmbracoOperations, UmbracoOptions } from "./umbraco.ts";
4142

4243
export interface ProviderOperations {
4344
appwrite: AppwriteOperations;
@@ -64,6 +65,7 @@ export interface ProviderOperations {
6465
shopify: ShopifyOperations;
6566
storyblok: StoryblokOperations;
6667
supabase: SupabaseOperations;
68+
umbraco: UmbracoOperations;
6769
uploadcare: UploadcareOperations;
6870
vercel: VercelOperations;
6971
wordpress: WordPressOperations;
@@ -95,6 +97,7 @@ export interface ProviderOptions {
9597
shopify: undefined;
9698
storyblok: undefined;
9799
supabase: undefined;
100+
umbraco: UmbracoOptions;
98101
uploadcare: UploadcareOptions;
99102
vercel: VercelOptions;
100103
wordpress: undefined;

src/providers/umbraco.test.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { assertEquals } from "jsr:@std/assert";
2+
import { assertEqualIgnoringQueryOrder } from "../test-utils.ts";
3+
import { extract, generate, transform } from "./umbraco.ts";
4+
5+
// Relative URL (typical Umbraco self-hosted usage)
6+
const relImg = "/media/abc123/photo.jpg";
7+
// Absolute URL — the real Umbraco website example
8+
const absImg = "https://umbraco.com/media/z2ef0fnx/umbraco_250314_1169.jpg";
9+
10+
Deno.test("umbraco extract", async (t) => {
11+
await t.step("should extract width, height, rmode from URL", () => {
12+
const result = extract(`${absImg}?width=1200&height=630&rmode=crop`);
13+
assertEquals(result?.src, absImg);
14+
assertEquals(result?.operations, {
15+
width: 1200,
16+
height: 630,
17+
rmode: "crop",
18+
});
19+
assertEquals(result?.options, { baseUrl: "https://umbraco.com" });
20+
});
21+
22+
await t.step("should extract all standard operations", () => {
23+
const result = extract(
24+
`${absImg}?width=784&height=897&quality=85&format=webp`,
25+
);
26+
assertEquals(result?.src, absImg);
27+
assertEquals(result?.operations, {
28+
width: 784,
29+
height: 897,
30+
quality: 85,
31+
format: "webp",
32+
});
33+
assertEquals(result?.options, { baseUrl: "https://umbraco.com" });
34+
});
35+
36+
await t.step("should extract rxy focal point", () => {
37+
const result = extract(
38+
`${absImg}?width=800&height=800&rxy=0.5,0.2&rmode=crop`,
39+
);
40+
assertEquals(result?.src, absImg);
41+
assertEquals(result?.operations, {
42+
width: 800,
43+
height: 800,
44+
rxy: "0.5,0.2",
45+
rmode: "crop",
46+
});
47+
});
48+
49+
await t.step("should extract bgcolor", () => {
50+
const result = extract(`${absImg}?width=800&bgcolor=FFFFFF`);
51+
assertEquals(result?.src, absImg);
52+
assertEquals(result?.operations, {
53+
width: 800,
54+
bgcolor: "FFFFFF",
55+
});
56+
});
57+
58+
await t.step("should strip query params from src", () => {
59+
const result = extract(`${absImg}?width=400&format=webp&quality=75`);
60+
assertEquals(result?.src, absImg);
61+
});
62+
63+
await t.step("should handle relative URLs", () => {
64+
const result = extract(`${relImg}?width=300&height=200`);
65+
assertEquals(result?.src, relImg);
66+
assertEquals(result?.operations, {
67+
width: 300,
68+
height: 200,
69+
});
70+
assertEquals(result?.options, { baseUrl: undefined });
71+
});
72+
73+
await t.step(
74+
"should resolve relative URL when baseUrl option supplied",
75+
() => {
76+
const result = extract(`${relImg}?width=300`, {
77+
baseUrl: "https://mysite.com",
78+
});
79+
assertEquals(result?.src, `https://mysite.com${relImg}`);
80+
assertEquals(result?.options, { baseUrl: "https://mysite.com" });
81+
},
82+
);
83+
84+
await t.step("should return empty operations for URL with no params", () => {
85+
const result = extract(absImg);
86+
assertEquals(result?.src, absImg);
87+
assertEquals(result?.operations, {});
88+
});
89+
});
90+
91+
Deno.test("umbraco generate", async (t) => {
92+
await t.step("should generate URL with width only", () => {
93+
assertEqualIgnoringQueryOrder(
94+
generate(absImg, { width: 1200 }),
95+
`${absImg}?width=1200&rmode=crop`,
96+
);
97+
});
98+
99+
await t.step("should generate URL with width and height", () => {
100+
assertEqualIgnoringQueryOrder(
101+
generate(absImg, { width: 1200, height: 630 }),
102+
`${absImg}?width=1200&height=630&rmode=crop`,
103+
);
104+
});
105+
106+
await t.step("should generate URL with format conversion", () => {
107+
assertEqualIgnoringQueryOrder(
108+
generate(absImg, { width: 800, format: "webp" }),
109+
`${absImg}?width=800&format=webp&rmode=crop`,
110+
);
111+
});
112+
113+
await t.step("should generate URL with quality", () => {
114+
assertEqualIgnoringQueryOrder(
115+
generate(absImg, { width: 800, format: "webp", quality: 75 }),
116+
`${absImg}?width=800&format=webp&quality=75&rmode=crop`,
117+
);
118+
});
119+
120+
await t.step("should generate URL with bgcolor", () => {
121+
assertEqualIgnoringQueryOrder(
122+
generate(absImg, { width: 800, bgcolor: "FFFFFF" }),
123+
`${absImg}?width=800&bgcolor=FFFFFF&rmode=crop`,
124+
);
125+
});
126+
127+
await t.step("should generate URL with rxy focal point", () => {
128+
assertEqualIgnoringQueryOrder(
129+
generate(absImg, { width: 800, height: 800, rxy: "0.5,0.2" }),
130+
`${absImg}?width=800&height=800&rxy=0.5%2C0.2&rmode=crop`,
131+
);
132+
});
133+
134+
await t.step("should allow overriding the default rmode", () => {
135+
assertEqualIgnoringQueryOrder(
136+
generate(absImg, { width: 800, rmode: "stretch" }),
137+
`${absImg}?width=800&rmode=stretch`,
138+
);
139+
});
140+
141+
await t.step("should allow rmode=pad with bgcolor", () => {
142+
assertEqualIgnoringQueryOrder(
143+
generate(absImg, {
144+
width: 800,
145+
height: 600,
146+
rmode: "pad",
147+
bgcolor: "000000",
148+
}),
149+
`${absImg}?width=800&height=600&rmode=pad&bgcolor=000000`,
150+
);
151+
});
152+
153+
await t.step("should generate URL with rsampler", () => {
154+
assertEqualIgnoringQueryOrder(
155+
generate(absImg, { width: 400, rsampler: "nearest" }),
156+
`${absImg}?width=400&rsampler=nearest&rmode=crop`,
157+
);
158+
});
159+
160+
await t.step("should generate URL with ranchor", () => {
161+
assertEqualIgnoringQueryOrder(
162+
generate(absImg, { width: 400, height: 300, ranchor: "top" }),
163+
`${absImg}?width=400&height=300&ranchor=top&rmode=crop`,
164+
);
165+
});
166+
167+
await t.step("should handle relative URLs", () => {
168+
assertEqualIgnoringQueryOrder(
169+
generate(relImg, { width: 300, height: 200 }),
170+
`${relImg}?width=300&height=200&rmode=crop`,
171+
);
172+
});
173+
174+
await t.step("should resolve relative URL with baseUrl option", () => {
175+
assertEqualIgnoringQueryOrder(
176+
generate(relImg, { width: 300, height: 200 }, {
177+
baseUrl: "https://mysite.com",
178+
}),
179+
"https://mysite.com/media/abc123/photo.jpg?width=300&height=200&rmode=crop",
180+
);
181+
});
182+
183+
await t.step(
184+
"should still produce absolute URL when src is already absolute",
185+
() => {
186+
assertEqualIgnoringQueryOrder(
187+
generate(absImg, { width: 400 }, { baseUrl: "https://other.com" }),
188+
`${absImg}?width=400&rmode=crop`,
189+
);
190+
},
191+
);
192+
193+
await t.step("should round non-integer dimensions", () => {
194+
assertEqualIgnoringQueryOrder(
195+
generate(absImg, { width: 400.6, height: 300.2 }),
196+
`${absImg}?width=401&height=300&rmode=crop`,
197+
);
198+
});
199+
});
200+
201+
Deno.test("umbraco transform", async (t) => {
202+
await t.step("should transform URL by merging new operations", () => {
203+
const url = `${absImg}?width=400&height=300&format=jpg`;
204+
assertEqualIgnoringQueryOrder(
205+
transform(url, { width: 800 }),
206+
`${absImg}?width=800&height=300&format=jpg&rmode=crop`,
207+
);
208+
});
209+
210+
await t.step("should add rmode=crop by default when transforming", () => {
211+
assertEqualIgnoringQueryOrder(
212+
transform(absImg, { width: 600, height: 400 }),
213+
`${absImg}?width=600&height=400&rmode=crop`,
214+
);
215+
});
216+
217+
await t.step("should preserve existing rmode when transforming", () => {
218+
const url = `${absImg}?width=400&rmode=pad&bgcolor=FFFFFF`;
219+
assertEqualIgnoringQueryOrder(
220+
transform(url, { width: 800 }),
221+
`${absImg}?width=800&rmode=pad&bgcolor=FFFFFF`,
222+
);
223+
});
224+
225+
await t.step("should transform relative URL", () => {
226+
assertEqualIgnoringQueryOrder(
227+
transform(relImg, { width: 300 }),
228+
`${relImg}?width=300&rmode=crop`,
229+
);
230+
});
231+
232+
await t.step(
233+
"should resolve relative URL with baseUrl when transforming",
234+
() => {
235+
assertEqualIgnoringQueryOrder(
236+
transform(relImg, { width: 300 }, { baseUrl: "https://mysite.com" }),
237+
"https://mysite.com/media/abc123/photo.jpg?width=300&rmode=crop",
238+
);
239+
},
240+
);
241+
242+
await t.step("should round-trip the real Umbraco example URL", () => {
243+
const url = `${absImg}?format=webp&width=784&height=897&quality=85`;
244+
assertEqualIgnoringQueryOrder(
245+
transform(url, { width: 400 }),
246+
`${absImg}?width=400&height=897&format=webp&quality=85&rmode=crop`,
247+
);
248+
});
249+
});

0 commit comments

Comments
 (0)