You work on something for long enough and it stops being fun.
I've maintained and built imgix SDKs for generating image URLs for years now. They're wonderful and if you use imgix you should use them!
But they're also... I don't know, safe? Where is the "sizzle"? Where is the unnecessary complexity?
I want to have some fun.
What we'll build
I want to end up with a composed image like the one below.
I'm going to make polymorphic function that builds URLs around the imgix image rendering API. It will append each method call as a query parameter to a URL. Then, imgix will transform our image based off of those paramters.
My goals:
- use the "builder pattern" so I can add parameters by chaining method calls
- write as little parameter-specific code as possible
- make it easy to "layer" imgix images on top of each other, creating composites
To keep things simple fun, and because my blog is hardly 'mission critical infrastructure', I'm not going to use TypeScript.
I want an API that's fun to use and looks a little like zod
's. A single function call with a bunch of chained method calls.
const image = buildURL({ domain })
.path("/blog-og-image.png")
.width(400)
.height(400)
.fm("auto")
.fit("crop")
.finalize();
console.log(image);
// https://luis-ball.imgix.net/blog-og-image.png?w=400&h=400&fm=auto&fit=crop
Writing buildURL
The first order of business is the "Director".
A Director is the thing that will take my URL through the assembly line of parameter construction. I want it to feed each imgix parameter to the final image path.
I'll call it buildURL
.
There are a lot of imgix parameters I need to account for. But I don't want to make a special handler for them all. Instead, I'll have the method arguments tell me how to construct the parameter I need.
This is where "polymorphism" comes into play.
I'm going to call buildURL
many times but with slightly different signatures. I need it to be smart enough to handle each signature differently.
To do this I can use "proxy handlers".
Proxy handlers allow you to modify the properties of an object by chaining method calls. I use them to define properties, in this case, imgix parameters, on the object returned by buildURL
.
So if I want my image to use the fm=auto
and fit=crop
parameters, I could chain calls like the example from earlier.
const image = buildURL({ domain })
.path("/blog-og-image.png")
.fm("auto")
.fit("crop")
.finalize();
Creating the Proxy Object
To chain method calls like this, I need to buildURL
methods to always return a proxy object.
I'm giving all the Proxies a get()
handler, which intercepts access to properties. It has two arguments, the Proxy target
, or original object, and the prop
, or string of the name property being accessed.
I can use the get()
handler to override whatever behavior would have taken place and always return a new Proxy instead.
export function buildURL({ protocol = "https://", domain }) {
let baseURL;
// ...
const proxyHandler = {
get(target, prop) {
return function (paramValue) {
// ...
return new Proxy(target, proxyHandler); // allow method chaining
};
}
},
// Expose the proxy to the parent scope object
const parentScope = new Proxy({}, proxyHandler);
return parentScope;
}
All this is doing right now is returning a proxy object.
I need it to append the query param to the URL using the target
and prop
parameters.
Appending parameters to the URL using chained method calls
Since all query parameter have a similar signature, they can share the same method, appendQueryParam()
.
export function buildURL({ protocol = "https://", domain }) {
// ...
const proxyHandler = {
get(target, prop) {
return function (paramValue) {
// ...
appendQueryParam(prop, paramValue);
return new Proxy(target, proxyHandler); // allow method chaining
};
}
},
// ...
}
This function uses the paramName
and paramValue
to append the right things to my URL query parameters, which imgix uses to build my image.
In order for some params to work correctly, I URL encode my param values. This might cause headaches in the future, so a nice improvement later on would be to conditionally disable this behavior.
/**
* Appends a query parameter to the URL object
*/
function appendQueryParam(paramName, paramValue) {
// Check if the baseURL is a relative path
const isRelativePath = !baseURL.href.startsWith("http");
// Validate that the protocol and domain are set for relative paths
if (isRelativePath && !domain) {
throw new Error("If the URL is a relative path, the domain must be set.");
}
const separator = baseURL.href.includes("?") ? "&" : "?";
const encodedValue =
typeof paramValue === "object"
? encodeURIComponent(JSON.stringify(paramValue))
: encodeURIComponent(paramValue);
baseURL.href += `${separator}${encodeURIComponent(
paramName
)}=${encodedValue}`;
return this; // To allow method chaining
}
My favorite part of this implementation is that I don't have to define the fm()
or any other parameter, they all share the same method!
Now I can use this in buildURL
to start, well, building the URL I need.
Finalizing the built URL
I need a way to return the final result string. In other words, a finalize()
method that stops the method chaining. Recall the example we had before.
const image = buildURL({ domain })
.path("/blog-og-image.png")
.fm("auto")
.fit("crop")
.finalize(); // tell our builder to return the URL
I can use the prop
argument from the Proxy's get()
method to decide to opt out of future method chaining and just return the final URL.
const proxyHandler = {
get(target, prop) {
// 👇 return the URL string
if (prop === "finalize") {
return function () {
return baseURL;
};
} else {
return function (paramValue) {
// ...
appendQueryParam(prop, paramValue);
return new Proxy(target, proxyHandler);
};
}
},
};
That's it!...
I lied.
There are of course some edge cases I need to account for.
Layering 4 images together 🤯
Ultimately what I want to use buildURL
for is layering images on top of each other. In other words, creating composites.
The mark64
, txt
, and blend
work wonderfully for this purpose, but I need to handle them differently from the reset.
Layer 1, the "base" image
Our first layer is the "base" layer, or the background of our image. I already made this layer above, but as a reminder:
const image = buildURL({ domain }).path("/blog-og-image.png");
// https://luis-ball.imgix.net/blog-og-image.png
Layer 2, the mark64
parameter
The mark64
needs extra special attention because I plan to use it in combination with the ~txt
parameter to allow me to overlay two different text "images" on my final image.
Chaining off our "base" layer with this parameter would look something like this.
.bri(-15) // lower the brightness so we can see the text
.mark64({
x: 180,
y: 180,
text: {
align: 'middle, left',
pad: 20,
color: 'fff',
font: 'avenir-black',
size: 28,
txt: 'Using the builder pattern to create imgix image URLs',
w: 400,
}
})
// https://luis-ball.imgix.net/blog-og-image.png?bri=-15&mark64=aHR0cHM6Ly9sdWlzLWJhbGwuaW1naXgubmV0L350ZXh0P3R4dC1hbGlnbj1taWRkbGUlMkMrbGVmdCZ0eHQtcGFkPTIwJnR4dC1jb2xvcj1mZmYmdHh0LWZvbnQ9YXZlbmlyLWJsYWNrJnR4dC1zaXplPTI4JnR4dD1Vc2luZyt0aGUrYnVpbGRlcitwYXR0ZXJuK3RvK2NyZWF0ZStpbWdpeCtpbWFnZStVUkxzJnc9NDAw&mark-x=180&mark-y=180
My mark64()
method needs to first create an image URL from the ~txt
endpoint and encode that URL into base-64 to use as the value for the mark64
parameter. Then it needs to add any mark-<some-param>
params as well that style the text.
That's a lot of code just for some text, I know. But doing so allows me to free up the txt
parameter to add even more text to our image.
Got it? Good. Let's look at some code.
/**
* Base64-encodes an imgix/~txt URL for use with the mark64 query parameter
* @see https://docs.imgix.com/apis/rendering/text/txt
*/
function mark64({ text, ...rest }) {
// start building the `~text` URL
const markURl = new URL(`${protocol + domain}/~text`);
// append params to the ~txt URL that we'll encode to base64
Object.entries(text).forEach(([key, value]) => {
if (!(key === "txt" || key === "w")) {
markURl.searchParams.set(`txt-${key}`, value);
} else {
markURl.searchParams.set(key, value);
}
});
const mark64 = markURl.toString();
// clear the search params
baseURL.searchParams.delete("mark64");
Object.entries(rest).forEach(([key, _value]) => {
baseURL.searchParams.delete(`mark-${key}`);
});
// set the new search params
baseURL.searchParams.set("mark64", Buffer.from(mark64).toString("base64"));
Object.entries(rest).forEach(([key, value]) => {
// console.log(`key = ${key}, value = ${value}`);
baseURL.searchParams.set(`mark-${key}`, value);
});
return this;
}
Why not do both texts in one layer? I want fine-tuned control over the styling and positioning of the text. I can't do that if they live in the same layer of the image.
Creating the third layer with txt
Building on top of the previous example, this is what I want using the txt()
method to look like.
.txt({
align: 'middle, left',
color: 'fff',
font64: 'avenir',
pad: 200,
size: 18,
text64: 'Nobody asked for this, but it was fun!',
y: 350
})
// https://luis-ball.imgix.net/blog-og-image.png?bri=-15&mark64=aHR0cHM6Ly9sdWlzLWJhbGwuaW1naXgubmV0L350ZXh0P3R4dC1hbGlnbj1taWRkbGUlMkMrbGVmdCZ0eHQtcGFkPTIwJnR4dC1jb2xvcj1mZmYmdHh0LWZvbnQ9YXZlbmlyLWJsYWNrJnR4dC1zaXplPTI4JnR4dD1Vc2luZyt0aGUrYnVpbGRlcitwYXR0ZXJuK3RvK2NyZWF0ZStpbWdpeCtpbWFnZStVUkxzJnc9NDAw&mark-x=180&mark-y=180&txt64=bm9ib2R5IGFza2VkIGZvciB0aGlzLCBidXQgaXQgd2FzIGZ1biE%3D&txt-font64=YXZlbmly&txt-align=middle%2C+left&txt-color=fff&txt-pad=200&txt-size=18&txt-y=350
I'm kind of lying agin here. I'm not really using the txt
parameter, but it's far more capable variant, txt64
.
That's why need some special sauce to handle txt()
.
It's not because I'm lazy and I don't want to write txt64
, but because only some of the parameters we use with txt
need to be base64 encoded: The text itself and the font I want to use for it. The rest of the txt-<some-param>
parameters don't need to be encoded.
/**
* Base64-encodes a text string for use with the imgix txt64 query parameter
* @see https://docs.imgix.com/apis/rendering/text/txt
*/
function txt({ text64, font64, ...rest }) {
const encodedTxt = Buffer.from(text64).toString("base64");
const encodedFont = Buffer.from(font64).toString("base64");
// clear the search params
baseURL.searchParams.delete("txt64");
baseURL.searchParams.delete("txt-font64");
Object.entries(rest).forEach(([key, _value]) => {
baseURL.searchParams.delete(`txt-${key}`);
});
// set the new search params
baseURL.searchParams.set("txt64", encodedTxt);
baseURL.searchParams.set("txt-font64", encodedFont);
Object.entries(rest).forEach(([key, value]) => {
baseURL.searchParams.set(`txt-${key}`, value);
});
return this;
}
Is there a pattern emerging here around using base64 parameters? Yes. Are we going to abstract this sensibly right now? No. Fuck it ship it.
Creating the fourth layer with blend
I saved the best, sexiest param for last. It's magical and you can do some truly insanely cool things with it, like swapping car colors depending on user input.
I'm going to use it to add a preview image to our background.
.blend({
mode: 'multiply',
url: `blog-placeholder-border-14.webp?w=960&h=480&fit=crop&auto=format`,
w: 600,
x: 180,
y: 120,
})
// https://luis-ball.imgix.net/blog-og-image.png?bri=-15&mark64=aHR0cHM6Ly9sdWlzLWJhbGwuaW1naXgubmV0L350ZXh0P3R4dC1hbGlnbj1taWRkbGUlMkMrbGVmdCZ0eHQtcGFkPTIwJnR4dC1jb2xvcj1mZmYmdHh0LWZvbnQ9YXZlbmlyLWJsYWNrJnR4dC1zaXplPTI4JnR4dD1Vc2luZyt0aGUrYnVpbGRlcitwYXR0ZXJuK3RvK2NyZWF0ZStpbWdpeCtpbWFnZStVUkxzJnc9NDAw&mark-x=180&mark-y=180&txt64=bm9ib2R5IGFza2VkIGZvciB0aGlzLCBidXQgaXQgd2FzIGZ1biE%3D&txt-font64=YXZlbmly&txt-align=middle%2C+left&txt-color=fff&txt-pad=200&txt-size=18&txt-y=350&blend=blog-placeholder-border-14.webp?w=960&h=480&fit=crop&auto=format&blend-mode=multiply&blend-w=600&blend-x=180&blend-y=120&fm=auto
Blissfully, I don't need to do much here other than prepend the parameter options with blend-<my-option>
.
/**
* Constructs URL params for use with the imgix blend query parameter
* @see https://docs.imgix.com/apis/rendering/blending
*/
function blend({ url, ...rest }) {
// clear the search params
baseURL.searchParams.delete("blend");
Object.entries(rest).forEach(([key, _value]) => {
baseURL.searchParams.delete(`blend-${key}`);
});
// set the new search params
baseURL.searchParams.set("blend", url);
Object.entries(rest).forEach(([key, value]) => {
baseURL.searchParams.set(`blend-${key}`, value);
});
return this;
}
That's it! I now have four images layered on top of each other.
I hope you enjoyed the post! I'd love your feedback if you've got it. Find me @luqven on Twitter and say hey 👋🏼.