Repository: nuxt/image
Pull request #823 (2023-05-13 15:04:09)

feat: add `prepr` cms image provider integration
# Description

This PR adds an integration with [Prepr Headless CMS](https://prepr.io/), docs can be found at [Prepr Docs](https://docs.prepr.io/).

## Motivation and Context

While the image transformation options provided by this CMS are still few, their Support team informs me, that they are due to receive updates very soon. I also happen to need to use this provider on my personal project and I much prefer to share the provider with the community then have it all for myself. :heart:

## Changes

- Sorts `BuiltInProviders` array alphabetically, no removals were made, only `prepr` key was added.
- Add `src/runtime/prepr` folder containing the necessary code, to `getImage`.
- Add prepr documentation to `docs/content`
- Add necessary configurations for `playground`


nuxt-studio[bot]: 
### <span aria-hidden="true">✅</span> Live Preview ready!

| Name | Edit | Preview | Latest Commit |
| :--- | :----- | :------ | :------ |
| **Image** | [Edit on Studio ↗︎](https://nuxt.studio/@nuxt/image/studio/content?refId=%23823) | [View Live Preview](https://v1.image.nuxtjs.org?preview=2fc5dcae1b80fea98cb8b4e6f3306b2f) | 9dc8a6908fa23295902c456a8c2691c449bcf11a |

<!-- This comment is generated by [Nuxt Studio](https://nuxt.studio) -->
<!-- Do not remove or edit this comment. -->

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy request for *nuxt-image-v1* pending review.
[Visit the deploys page to approve it](https://app.netlify.com/sites/nuxt-image-v1/deploys)

|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 9dc8a6908fa23295902c456a8c2691c449bcf11a |
danielroe: Would you be able to add snapshots/expected values for prepr? 🙏
FranciscoKloganB: @danielroe Hey, I think anything is possible. Can you just clarity what you are looking for? Thanks 
danielroe: Merge in the latest changes in the main branch, and get the tests passing. (This requires adding `prepr` entries to the unit tests, just like the other providers.)
FranciscoKloganB: Makes sense. Will do. :)
FranciscoKloganB: @danielroe I think that's it :)

Patch:
diff --git a/docs/content/5.providers/prepr.md b/docs/content/5.providers/prepr.md
new file mode 100644
index 0000000..3884cc5
--- /dev/null
+++ b/docs/content/5.providers/prepr.md
@@ -0,0 +1,59 @@
+# Prepr
+
+Nuxt Image integration with Prepr CMS
+
+Integration between [Prepr](https://prepr.io/) and Nuxt Image.
+
+To use this provider you just need to specify the `projectName` of your project in Prepr.
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+  image: {
+    prepr: {
+      // E.g.: https://YourProjectName.prepr.io/
+      projectName: 'YourProjectName',
+    }
+  }
+})
+```
+
+## Modifiers
+
+The Prepr provider supports a number of additional modifiers. For a full list,
+check out [the Prepr documentation](https://docs.prepr.io/reference/rest/v1/assets-resizing/).
+All current transformations currently mentioned in Prepr docs are supported.
+
+For the time being you might find the following links useful:
+
+- [Assets Resizing via REST API](https://docs.prepr.io/reference/rest/v1/assets-resizing/)
+- [Understanding your marketing and design team workflows](https://docs.prepr.io/managing-content/images)
+
+::alert{type="info"}
+prepr.io does not provide a way to restrict what domains can
+request assets to your project's CDN, nor limit the maximum size in `pixels` or
+`bytes` of images that are served from the CDN.
+::
+
+### Convenience key modifiers
+
+The following more readable modifiers are supported, in addition to Prepr's
+native modifiers:
+
+- `crop` is equivalent to `c`
+- `format` is equivalent to `format`
+- `height` is equivalent to `h`
+- `quality` is equivalent to `q`
+- `width` is equivalent to `w`
+
+### `fit`
+
+In addition to the values specified in the Prepr docs, which are respected, the
+following options from the [default fit behavior](/components/nuxt-img#fit)
+are supported:
+
+- `cover` - this will behave like the Prepr modifier `crop`, when passed without
+a value (defaults to `centre`)
+
+::alert{type="warning"}
+For the time being, other `fit` options are not supported by this provider.
+::
diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts
index d2e9c7c..6d4b1ef 100644
--- a/playground/nuxt.config.ts
+++ b/playground/nuxt.config.ts
@@ -65,6 +65,9 @@ export default defineNuxtConfig({
       baseURL: 'https://opt.moovweb.net'
     },
     prismic: {},
+    prepr: {
+      projectName: 'nuxt-prepr-demo'
+    },
     sanity: {
       projectId: 'zp7mbokg'
     },
diff --git a/playground/providers.ts b/playground/providers.ts
index 77e67af..e51be90 100644
--- a/playground/providers.ts
+++ b/playground/providers.ts
@@ -290,6 +290,47 @@ export const providers: Provider[] = [
       { src: 'https://i.imgur.com/LFtQeX2.jpeg', quality: 10 }
     ]
   },
+  // Prepr
+  {
+    name: 'prepr',
+    samples: [
+      { src: '4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg', quality: 5 },
+      { src: '4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg', height: 200 },
+      { src: '4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg', width: 200 },
+      { src: '4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg', width: 200, height: 200 },
+      {
+        src: '4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg',
+        modifiers: {
+          w: 250,
+          h: 250
+        }
+      },
+      {
+        src: '4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg',
+        width: 250,
+        height: 200,
+        modifiers: {
+          crop: 'southeast'
+        }
+      },
+      {
+        src: '4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg',
+        width: 250,
+        height: 200,
+        modifiers: {
+          crop: 'centre'
+        }
+      },
+      {
+        src: '4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg',
+        width: 250,
+        height: 200,
+        fit: {
+          cover: true
+        }
+      }
+    ]
+  },
   // Prismic
   {
     name: 'prismic',
diff --git a/src/module.ts b/src/module.ts
index ab0ee4e..8664a6f 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -107,6 +107,7 @@ export default defineNuxtModule<ModuleOptions>({
       name: 'NuxtImg',
       filePath: resolver.resolve('./runtime/components/nuxt-img')
     })
+
     addComponent({
       name: 'NuxtPicture',
       filePath: resolver.resolve('./runtime/components/nuxt-picture')
diff --git a/src/provider.ts b/src/provider.ts
index a90becf..5e6dce8 100644
--- a/src/provider.ts
+++ b/src/provider.ts
@@ -26,6 +26,7 @@ const BuiltInProviders = [
   'ipx',
   'layer0',
   'netlify',
+  'prepr',
   'none',
   'prismic',
   'sanity',
diff --git a/src/runtime/providers/prepr/formatter.ts b/src/runtime/providers/prepr/formatter.ts
new file mode 100644
index 0000000..e1ab06b
--- /dev/null
+++ b/src/runtime/providers/prepr/formatter.ts
@@ -0,0 +1,3 @@
+export function formatter (key: string, value: string) {
+  return String(value) === 'true' ? key : `${key}_${value}`
+}
diff --git a/src/runtime/providers/prepr/getImage.ts b/src/runtime/providers/prepr/getImage.ts
new file mode 100644
index 0000000..3399eaf
--- /dev/null
+++ b/src/runtime/providers/prepr/getImage.ts
@@ -0,0 +1,42 @@
+import { joinURL } from 'ufo'
+
+import type { ImageOptions, ResolvedImage, ImageCTX } from '../../../types'
+
+import { formatter } from './formatter'
+import { keyMap } from './keyMap'
+import { valueMap } from './valueMap'
+
+import { createOperationsGenerator } from '#image'
+
+interface PreprImageOptions extends ImageOptions {
+  projectName: string
+}
+
+type PreprProviderGetImage = (src: string, options: PreprImageOptions, ctx: ImageCTX) => ResolvedImage
+
+const operationsGenerator = createOperationsGenerator({
+  formatter,
+  joinWith: ',',
+  keyMap,
+  valueMap
+})
+
+const getImage: PreprProviderGetImage = (src, options, _ctx) => {
+  const { projectName } = options
+
+  if (typeof projectName !== 'string' || !projectName.trim()) {
+    throw new TypeError('[nuxt] [image] [prepr] No project name provided.')
+  }
+
+  const fileBucket = 'stream'
+  const fileOperations = operationsGenerator(options.modifiers)
+  const filePath = fileOperations ? joinURL(fileOperations, src) : src
+
+  const projectUrl = `https://${projectName.trim()}.${fileBucket}.prepr.io`
+
+  return {
+    url: joinURL(projectUrl, filePath)
+  }
+}
+
+export { getImage }
diff --git a/src/runtime/providers/prepr/index.ts b/src/runtime/providers/prepr/index.ts
new file mode 100644
index 0000000..0baaba9
--- /dev/null
+++ b/src/runtime/providers/prepr/index.ts
@@ -0,0 +1,1 @@
+export * from './getImage'
diff --git a/src/runtime/providers/prepr/keyMap.ts b/src/runtime/providers/prepr/keyMap.ts
new file mode 100644
index 0000000..5e1028f
--- /dev/null
+++ b/src/runtime/providers/prepr/keyMap.ts
@@ -0,0 +1,18 @@
+/**
+ * Key map is responsible for mapping readable "properties", which can be passed
+ * as modifiers of `NuxtImg` component, to URL path parameters that can be
+ * interpreted Prepr's REST API.
+ */
+const keyMap = {
+  crop: 'c',
+  format: 'format',
+  height: 'h',
+  quality: 'q',
+  width: 'w'
+} as const
+
+type KeyMapKey = keyof typeof keyMap
+type KeyMapValue= typeof keyMap[KeyMapKey]
+
+export { keyMap }
+export type { KeyMapKey, KeyMapValue }
diff --git a/src/runtime/providers/prepr/valueMap.ts b/src/runtime/providers/prepr/valueMap.ts
new file mode 100644
index 0000000..7542456
--- /dev/null
+++ b/src/runtime/providers/prepr/valueMap.ts
@@ -0,0 +1,31 @@
+/**
+ * Value map is responsible for mapping readable "properties" defined in
+ * `keyMap.ts` aswell as native modifiers of `NuxtImg` component, to URL path
+ * parameter values that can be interpreted Prepr's REST API.
+ *
+ * ```Examples
+ * Prepr's `w` path param expects an arbitrary number, so, it does not need to be in `valueMap`
+ *
+ * Our custom param `width` maps to `w` in keyMap, so, it does not need to be in `valueMap`
+ *
+ * Prepr's `format` path param expects a string which can either be `jpg` or `png`,
+ * if we want to allow the user to pass <NuxtImage :modifiers="{ format: 'jpeg' }" />,
+ * because it is a valid option of <NuxtImg :format />, then we need to have
+ * `jpeg` to `jpg` because Prepr's API does not recognize `jpeg`. Similar things
+ * could be said for `fit=cover`, which should map to `fit=crop`
+ *```
+ */
+const valueMap = {
+  format: {
+    jpeg: 'jpg'
+  },
+  fit: {
+    cover: 'crop' // Prepr.io accepts value `crop` defaulting to value `centre`
+  }
+} as const
+
+type ValuesMapKey = keyof typeof valueMap
+type ValuesMapValue = typeof valueMap[ValuesMapKey]
+
+export { valueMap }
+export type { ValuesMapKey, ValuesMapValue }


Test patch:
diff --git a/test/e2e/__snapshots__/no-ssr.test.ts.snap b/test/e2e/__snapshots__/no-ssr.test.ts.snap
index 5f19ab9..2d6ab7c 100644
--- a/test/e2e/__snapshots__/no-ssr.test.ts.snap
+++ b/test/e2e/__snapshots__/no-ssr.test.ts.snap
@@ -242,6 +242,32 @@ exports[`browser (ssr: false) > none should render images 2`] = `
 ]
 `;
 
+exports[`browser (ssr: false) > prepr should render images 1`] = `
+[
+  "https://nuxt-prepr-demo.stream.prepr.io/q_5/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_200,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_250,h_250/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/c_southeast,w_250,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/c_centre,w_250,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_250,h_200,fit_[object Object]/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+]
+`;
+
+exports[`browser (ssr: false) > prepr should render images 2`] = `
+[
+  "https://nuxt-prepr-demo.stream.prepr.io/q_5/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_200,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_250,h_250/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/c_southeast,w_250,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/c_centre,w_250,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_250,h_200,fit_[object%20Object]/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+]
+`;
+
 exports[`browser (ssr: false) > prismic should render images 1`] = `
 [
   "https://images.prismic.io/200629-sms-hoy/f596a543-d593-4296-9abd-3d3ac15f1e39_ray-hennessy-mpw37yXc_WQ-unsplash.jpg?auto=compress,format&w=600&h=900",
diff --git a/test/e2e/__snapshots__/ssr.test.ts.snap b/test/e2e/__snapshots__/ssr.test.ts.snap
index a0114b3..f6cf8a7 100644
--- a/test/e2e/__snapshots__/ssr.test.ts.snap
+++ b/test/e2e/__snapshots__/ssr.test.ts.snap
@@ -242,6 +242,32 @@ exports[`browser (ssr: true) > none should render images 2`] = `
 ]
 `;
 
+exports[`browser (ssr: true) > prepr should render images 1`] = `
+[
+  "https://nuxt-prepr-demo.stream.prepr.io/q_5/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_200,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_250,h_250/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/c_southeast,w_250,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/c_centre,w_250,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_250,h_200,fit_[object Object]/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+]
+`;
+
+exports[`browser (ssr: true) > prepr should render images 2`] = `
+[
+  "https://nuxt-prepr-demo.stream.prepr.io/q_5/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_200,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_250,h_250/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/c_southeast,w_250,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/c_centre,w_250,h_200/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+  "https://nuxt-prepr-demo.stream.prepr.io/w_250,h_200,fit_[object%20Object]/4rhkcc7xzk7d-claymemoirscom-spider-pic.jpg",
+]
+`;
+
 exports[`browser (ssr: true) > prismic should render images 1`] = `
 [
   "https://images.prismic.io/200629-sms-hoy/f596a543-d593-4296-9abd-3d3ac15f1e39_ray-hennessy-mpw37yXc_WQ-unsplash.jpg?auto=compress,format&w=600&h=900",
diff --git a/test/providers.ts b/test/providers.ts
index 3119837..4e8f187 100644
--- a/test/providers.ts
+++ b/test/providers.ts
@@ -14,6 +14,7 @@ export const images = [
     unsplash: { url: '/test.png' },
     imagekit: { url: '/test.png' },
     netlify: { url: '/test.png' },
+    prepr: { url: 'https://projectName.stream.prepr.io/image-test-300x450-png' },
     prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=100&h=100' },
     sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?auto=format' },
     contentful: { url: '/test.png' },
@@ -39,6 +40,7 @@ export const images = [
     unsplash: { url: '/test.png?w=200' },
     imagekit: { url: '/test.png?tr=w-200' },
     netlify: { url: '/test.png?w=200&nf_resize=fit' },
+    prepr: { url: 'https://projectName.stream.prepr.io/w_200/image-test-300x450-png' },
     prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=100' },
     sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&auto=format' },
     contentful: { url: '/test.png?w=200' },
@@ -64,6 +66,7 @@ export const images = [
     unsplash: { url: '/test.png?h=200' },
     imagekit: { url: '/test.png?tr=h-200' },
     netlify: { url: '/test.png?h=200&nf_resize=fit' },
+    prepr: { url: 'https://projectName.stream.prepr.io/h_200/image-test-300x450-png' },
     prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=100&h=200' },
     sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?h=200&auto=format' },
     contentful: { url: '/test.png?h=200' },
@@ -90,6 +93,7 @@ export const images = [
     imagekit: { url: '/test.png?tr=w-200,h-200' },
     netlify: { url: '/test.png?w=200&h=200&nf_resize=fit' },
     prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=200' },
+    prepr: { url: 'https://projectName.stream.prepr.io/w_200,h_200/image-test-300x450-png' },
     sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&h=200&auto=format' },
     contentful: { url: '/test.png?w=200&h=200' },
     cloudimage: { url: 'https://demo.cloudimg.io/v7/test.png?width=200&height=200' },
@@ -115,6 +119,7 @@ export const images = [
     imagekit: { url: '/test.png?tr=w-200,h-200,cm-pad_resize' },
     netlify: { url: '/test.png?w=200&h=200&nf_resize=fit' },
     prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=200&fit=fill' },
+    prepr: { url: 'https://projectName.stream.prepr.io/w_200,h_200,fit_contain/image-test-300x450-png' },
     sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&h=200&fit=fill&auto=format&bg=ffffff' },
     contentful: { url: '/test.png?w=200&h=200&fit=fill' },
     cloudimage: { url: 'https://demo.cloudimg.io/v7/test.png?width=200&height=200&func=fit' },
@@ -141,6 +146,7 @@ export const images = [
     netlify: { url: '/test.png?w=200&h=200&nf_resize=fit' },
     prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=200&fit=fill&fm=jpeg' },
     sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&h=200&fit=fill&fm=jpg&bg=ffffff' },
+    prepr: { url: 'https://projectName.stream.prepr.io/w_200,h_200,fit_contain,format_jpg/image-test-300x450-png' },
     contentful: { url: '/test.png?w=200&h=200&fit=fill&fm=jpg' },
     cloudimage: { url: 'https://demo.cloudimg.io/v7/test.png?width=200&height=200&func=fit&force_format=jpeg' },
     edgio: { url: 'https://opt.moovweb.net/?img=/test.png&width=200&height=200&fit=contain&format=jpeg' },
diff --git a/test/unit/providers.test.ts b/test/unit/providers.test.ts
index 4c8eb05..624d091 100644
--- a/test/unit/providers.test.ts
+++ b/test/unit/providers.test.ts
@@ -11,6 +11,7 @@ import * as cloudflare from '#image/providers/cloudflare'
 import * as cloudinary from '#image/providers/cloudinary'
 import * as twicpics from '#image/providers/twicpics'
 import * as fastly from '#image/providers/fastly'
+import * as prepr from '#image/providers/prepr'
 import * as glide from '#image/providers/glide'
 import * as imgix from '#image/providers/imgix'
 import * as gumlet from '#image/providers/gumlet'
@@ -283,6 +284,17 @@ describe('Providers', () => {
     }
   })
 
+  it('prepr', () => {
+    const providerOptions = {
+      projectName: 'projectName'
+    }
+
+    for (const image of images) {
+      const [, modifiers] = image.args
+      const generated = prepr.getImage('image-test-300x450-png', { modifiers: { ...modifiers }, ...providerOptions }, emptyContext)
+      expect(generated).toMatchObject(image.prepr)
+    }
+  })
   it('contentful', () => {
     const providerOptions = {
       baseURL: ''