dawaltconley / responsive-images

Static site image processing. Currently a wrapper around eleventy-img.
https://dawaltconley.github.io/responsive-images/
ISC License
1 stars 0 forks source link

Thoughts on a cleaner API #7

Closed dawaltconley closed 7 months ago

dawaltconley commented 1 year ago

Currently, nearly all public methods follow a convention where the first argument takes a path to an image (string | URL | Buffer) and the second argument takes an object of various options. The motivation behind this was to have methods that could be easily called from markup, with static site templating languages in mind:

<!-- nunjucks -->
<div>
  {% picture "assets/example.jpg",
    alt="A responsive picture element",
    formats=['webp', null],
    sizes="(min-width: 800px) 400px, 100vw" %}
</div>
// jsx (component not currently implemented)
const example = () =>
  <Picture src="assets/example.jpg"
    alt="A responsive picture element"
    formats={['webp', null]}
    sizes="(min-width: 800px) 400px, 100vw"
  />

One downside of this approach is that it mixes different types of arguments into the same object: both items like alt that will end up in the markup, and processing commands like formats. Then you have sizes, which serves both purposes. This is a little hard to keep track of internally.

A further complication is that, unlike templating languages, components are not limited to the server. A jsx component likely needs to receive some information about the generated images from a prior build step. So its API would look more like:

import images, { Picture } from '../lib/configured-images.mjs'

// image generation is handled on the server
export function getStaticProps() {
  const metadata = images.metadataFromSizes('image/path.jpg', {
    sizes: '(min-width: 1280px) 50vw, 100vw'
  })
  return { metadata }
}

// markup is handled on both the server and client
export default function Component({ metadata }) {
  return (
    <Picture
      metadata={metadata}
      alt="Some alt text"
      className="classes-should-be-passed-here"
    />
  )
}

This is pretty close to the way eleventy-img works, where one function (Image) takes a set of options that resize the image and return a metadata object, and another function (generateHtml) takes the metadata object as its first argument its second argument is an object that defines html attributes. This nicely separates concerns, and it's be possible to do this internally with wrapper methods that flatten these parameters for use in templating shortcodes.

The problem with this separation of concerns is: where does sizes go? The whole point of this library is that sizes can define the images that must be generated with both better precision and abstraction than a custom data type. That means I want server-side methods to use it. But it also needs to go to the client, and it's important that it's the same sizes method going to the client.

The most straightforward approach is to pass sizes through metadataFromSizes as part of the returned metadata object. But that raises the question: what else should be passed through? In the above example, the alt attribute works fine, but if we're passing through an array of metadata for a set of images, we'll probably want any html property that depends on the image src (such as alt) to be part of that metadata.

I could go to the other extreme and just pass through fully rendered strings or React server components (where supported). This makes it more difficult, however, to style elements based on their context. It seems useful to at least be able to set classNames on the client since styles can depend on the surrounding elements. The easiest way to allow this is for both the server-side and client code to accept html attributes, so any attribute can be paired with a specific src and can also be overridden.

import images, { Picture } from '../lib/configured-images.mjs'

export function getStaticProps() {
  const metadata = images.metadataFromSizes('image/path.jpg', {
    alt: 'Alt text goes here',
    sizes: '(min-width: 1280px) 50vw, 100vw'
  })
  return { metadata }
}

export default function Component({ metadata }) {
  return (
    <Picture
      {...metadata}
      className="classes-should-be-passed-here"
    />
  )
}

All that said, it probably is worth separating these concerns internally and only mixing them in the api of public methods.

dawaltconley commented 11 months ago

Additionally, the internal functions would be better exposed as a series of method chains rather than a bunch of disconnected pure functions.

I'd like to be able to write something like:

const responsive = await image(src, opts)
  .fromSizes(sizes) // returns an object with the metadata and various methods for processing it
  .toHtml({ alt }) // could also call .toCss() or something

Handling async methods might be tricky. Ideally I'd still want to be able await .fromSizes and then pass its value to a synchronous toHtml method call on the client.

dawaltconley commented 7 months ago

New API in v0.5.0 should address this.